diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..cc37477 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,54 @@ +name: Build and Deploy + +on: + push: + branches: [master] + workflow_dispatch: + +jobs: + build-deploy: + runs-on: ubuntu-latest + container: + image: docker:latest + volumes: + - /opt/rate-my-shots:/deploy + steps: + - name: Install dependencies + run: apk add --no-cache git nodejs + + - uses: actions/checkout@v4 + + - name: Copy source to deploy directory + run: | + rm -rf /deploy/build + cp -r . /deploy/build + + - name: Stage live database for build + run: | + if [ -f /deploy/data/rate-my-shots.db ]; then + cp /deploy/data/rate-my-shots.db /deploy/build/.build-db.sqlite + else + touch /deploy/build/.build-db.sqlite + fi + + - name: Create .env file + working-directory: /deploy/build + run: | + cat > .env << 'EOF' + HOST_PORT=${{ vars.HOST_PORT }} + IMMICH_URL=${{ vars.IMMICH_URL }} + IMMICH_ALBUM_ID=${{ vars.IMMICH_ALBUM_ID }} + IMMICH_API_KEY=${{ secrets.IMMICH_API_KEY }} + ADMIN_PASSWORD_HASH=${{ secrets.ADMIN_PASSWORD_HASH }} + DATABASE_PATH=/app/data/rate-my-shots.db + DATA_DIR=/opt/rate-my-shots/data + EOF + + - name: Build and deploy + working-directory: /deploy/build + run: | + docker compose build + docker compose up -d + + - name: Cleanup + run: rm -rf /deploy/build diff --git a/Dockerfile b/Dockerfile index de37dc4..4cf1d54 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,18 @@ FROM oven/bun:1-alpine AS deps WORKDIR /app -RUN apk add --no-cache python3 make g++ +RUN apk add --no-cache python3 make g++ nodejs npm COPY package.json bun.lock ./ RUN bun install --frozen-lockfile +RUN npm rebuild better-sqlite3 FROM oven/bun:1-alpine AS builder WORKDIR /app -RUN apk add --no-cache python3 make g++ +RUN apk add --no-cache nodejs npm COPY --from=deps /app/node_modules ./node_modules COPY . . -RUN bun run build +RUN cp .build-db.sqlite /tmp/build.db \ + && DATABASE_PATH=/tmp/build.db bun run scripts/migrate.ts \ + && DATABASE_PATH=/tmp/build.db npm run build FROM oven/bun:1-alpine AS runner WORKDIR /app @@ -19,6 +22,7 @@ ENV NODE_ENV=production ENV HOSTNAME=0.0.0.0 ENV PORT=3000 ENV DATABASE_PATH=/app/data/rate-my-shots.db +ENV NODE_OPTIONS=--dns-result-order=ipv4first COPY --from=builder --chown=bun:bun /app/.next/standalone ./ COPY --from=builder --chown=bun:bun /app/.next/static ./.next/static diff --git a/docker-compose.yml b/docker-compose.yml index 57bf7ca..9a47a0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,13 @@ +name: rate-my-shots + services: app: build: . ports: - "${HOST_PORT:-3000}:3000" volumes: - - ./data:/app/data + - "${DATA_DIR:-./data}:/app/data" env_file: .env + extra_hosts: + - "host.docker.internal:host-gateway" restart: unless-stopped diff --git a/next.config.ts b/next.config.ts index e546f30..6aa23d0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,23 @@ import type { NextConfig } from "next"; -// CSP is set per-request in `src/proxy.ts` (nonce-based). The other headers -// are static and apply to every response, including API routes. +const isDev = process.env.NODE_ENV === "development"; + +const csp = [ + "default-src 'self'", + `script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self'", + "connect-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "upgrade-insecure-requests", +].join("; "); + const securityHeaders = [ + { key: "Content-Security-Policy", value: csp }, { key: "X-Frame-Options", value: "DENY" }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "same-origin" }, @@ -14,6 +29,9 @@ const nextConfig: NextConfig = { images: { unoptimized: true, }, + experimental: { + sri: { algorithm: "sha256" }, + }, async headers() { return [{ source: "/:path*", headers: securityHeaders }]; }, diff --git a/src/lib/admin-auth.ts b/src/lib/admin-auth.ts index fa35865..e1790ad 100644 --- a/src/lib/admin-auth.ts +++ b/src/lib/admin-auth.ts @@ -6,8 +6,10 @@ import { isValidSession } from "./session"; export const ADMIN_COOKIE = "rms-admin"; const SCRYPT_KEY_LEN = 64; +const isBuild = process.env.NEXT_PHASE === "phase-production-build"; const [SALT_HEX, HASH_HEX] = (() => { + if (isBuild) return ["", ""]; const parts = ADMIN_PASSWORD_HASH.split(":"); if (parts.length !== 2 || !parts[0] || !parts[1]) { throw new Error( @@ -20,7 +22,7 @@ const [SALT_HEX, HASH_HEX] = (() => { const SALT = Buffer.from(SALT_HEX, "hex"); const EXPECTED_HASH = Buffer.from(HASH_HEX, "hex"); -if (EXPECTED_HASH.length !== SCRYPT_KEY_LEN) { +if (!isBuild && EXPECTED_HASH.length !== SCRYPT_KEY_LEN) { throw new Error( `ADMIN_PASSWORD_HASH: hash must be ${SCRYPT_KEY_LEN} bytes (${SCRYPT_KEY_LEN * 2} hex chars).`, ); diff --git a/src/lib/env.ts b/src/lib/env.ts index 9812e04..91a35ac 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,6 +1,9 @@ +const isBuild = process.env.NEXT_PHASE === "phase-production-build"; + function required(name: string): string { const value = process.env[name]; if (!value) { + if (isBuild) return ""; throw new Error(`Missing required env var: ${name}`); } return value; diff --git a/src/proxy.ts b/src/proxy.ts index 6e4e6e1..42f65a1 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -24,55 +24,10 @@ function checkOrigin(request: NextRequest): NextResponse | null { return null; } -/* - * `'strict-dynamic'` lets nonce-loaded scripts load further scripts without having to enumerate - * every chunk; `'self'` is the fallback for browsers that don't honor strict-dynamic. - * `'unsafe-eval'` is required in dev for React's runtime debugging. `style-src` keeps - * `'unsafe-inline'` because HeroUI / Tailwind / next/font emit inline styles that aren't all - * reachable for nonce injection — script-XSS is the primary concern, not style-XSS. - */ -function buildCsp(nonce: string, isDev: boolean): string { - return [ - "default-src 'self'", - `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""}`, - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob:", - "font-src 'self'", - "connect-src 'self'", - "object-src 'none'", - "base-uri 'self'", - "form-action 'self'", - "frame-ancestors 'none'", - "upgrade-insecure-requests", - ].join("; "); -} - export function proxy(request: NextRequest) { - if (request.nextUrl.pathname.startsWith("/api/")) { - return checkOrigin(request) ?? NextResponse.next(); - } - - const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); - const csp = buildCsp(nonce, process.env.NODE_ENV === "development"); - - const requestHeaders = new Headers(request.headers); - requestHeaders.set("x-nonce", nonce); - requestHeaders.set("Content-Security-Policy", csp); - - const response = NextResponse.next({ request: { headers: requestHeaders } }); - response.headers.set("Content-Security-Policy", csp); - return response; + return checkOrigin(request) ?? NextResponse.next(); } export const config = { - matcher: [ - "/api/:path*", - { - source: "/((?!api|_next/static|_next/image|favicon.ico|img).*)", - missing: [ - { type: "header", key: "next-router-prefetch" }, - { type: "header", key: "purpose", value: "prefetch" }, - ], - }, - ], + matcher: "/api/:path*", };