diff --git a/.dockerignore b/.dockerignore index fcb52cf3e..910d0011f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,3 +6,10 @@ client/.env server/.env docker/postgres/data +/data +/books +/book-bucket +/local +/coverage +/test-results +**/*.tsbuildinfo diff --git a/.env.example b/.env.example index 30b1e63fd..447fc4c72 100644 --- a/.env.example +++ b/.env.example @@ -25,18 +25,14 @@ APP_URL=https://projectx.example.com # DATABASE_URL=postgres://user:password@yourhost:5432/projectx # Or set individual parts (POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB) -# JWT token lifetimes -# JWT_EXPIRES_IN=15m -# JWT_REFRESH_EXPIRES_IN=7d - # CORS origin for the frontend when served from a different domain than APP_URL # CLIENT_URL=https://app.example.com -# Book Bucket directory (defaults to {BOOKS_PATH}/book-bucket inside the container) -# BOOK_BUCKET_PATH=/book-bucket - -# AES-256 key for encrypting stored email credentials (generate: openssl rand -hex 32) +# Recommended if you store SMTP provider credentials (generate: openssl rand -hex 32) # EMAIL_ENCRYPTION_KEY= +# Recommended if you store migration source credentials (generate: openssl rand -hex 32) +# MIGRATION_ENCRYPTION_KEY= + # Log level (default: info). Set to debug for verbose output. # LOG_LEVEL=info diff --git a/Dockerfile b/Dockerfile index 308c7dd63..e405f43e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,33 @@ ARG NODE_IMAGE=node:24.11-alpine -# Stage 1: Build client -FROM ${NODE_IMAGE} AS client-builder +FROM ${NODE_IMAGE} AS base RUN npm install -g pnpm@10 + +# Stage 1: Build client +FROM base AS client-builder WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ -COPY packages/ packages/ +COPY packages/types/package.json ./packages/types/ COPY client/package.json ./client/ RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \ pnpm install --filter client... --frozen-lockfile +COPY packages/ ./packages/ COPY client/ ./client/ RUN pnpm --filter client run build-only # Stage 2: Build server + create deploy bundle -FROM ${NODE_IMAGE} AS server-builder -RUN npm install -g pnpm@10 +FROM base AS server-builder WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ -COPY packages/ packages/ +COPY packages/types/package.json ./packages/types/ COPY server/package.json ./server/ RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \ pnpm install --filter server... --frozen-lockfile +COPY packages/ ./packages/ COPY server/ ./server/ RUN pnpm --filter server run build @@ -38,6 +41,7 @@ FROM ${NODE_IMAGE} AS runtime WORKDIR /app RUN apk upgrade --no-cache && \ + apk add --no-cache poppler-utils && \ rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx ENV NODE_ENV=production @@ -56,4 +60,4 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD node -e "const p=process.env.PORT||3000;fetch('http://127.0.0.1:'+p+'/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" -CMD ["sh", "entrypoint.sh"] +CMD ["sh", "/app/entrypoint.sh"] diff --git a/README.md b/README.md index 2d1751ceb..8583a0346 100644 --- a/README.md +++ b/README.md @@ -69,14 +69,11 @@ projectx/ │ └── types/ Shared TypeScript types (@projectx/types) ├── docker/ │ └── postgres/ Postgres init scripts (extensions) -├── docs/ -│ └── production.md Production deploy/backup/restore runbook ├── local/ Local dev data (covers, docs) - gitignored -├── docker-compose.yml Dev: runs Postgres only (app runs natively) -├── docker-compose.prod.yml Prod: app + Postgres + migration job -├── .env.prod.example Template env file for production compose -├── Dockerfile Production multi-stage build -└── Dockerfile.dev Runs entire stack in Docker (not used for normal dev) +├── docker-compose.dev.yml Dev: runs Postgres only (app runs natively) +├── docker-compose.yml Prod: app + Postgres +├── .env.example Template env file for production compose +└── Dockerfile Production multi-stage build ``` ### Monorepo layout @@ -212,39 +209,34 @@ The fastest way to learn the patterns is to follow an existing module: ## Environment Variables -Server environment is configured in `server/.env` (created from `.env.example` during setup). +Server environment is configured in `server/.env` for local development. Production compose uses the repo-root `.env` created from `.env.example`. -| Variable | Default | Description | -| ------------------------ | ------------------------------------------------------ | ---------------------------------------------------------- | -| `DATABASE_URL` | `postgres://projectx:projectx@localhost:5432/projectx` | PostgreSQL connection string | -| `PORT` | `3000` | Server port | -| `NODE_ENV` | `development` | Environment mode | -| `JWT_SECRET` | `change-me-in-production` | JWT signing secret | -| `JWT_EXPIRES_IN` | `15m` | Access token lifetime | -| `JWT_REFRESH_EXPIRES_IN` | `7d` | Refresh token lifetime | -| `SETUP_BOOTSTRAP_TOKEN` | (empty) | Required in production for initial `/api/v1/auth/setup` | -| `BOOKS_PATH` | `../local/data` | App data directory for cover images (not where books live) | -| `SMTP_*` | (empty) | Optional SMTP config for password-reset emails | -| `APP_URL` | `http://localhost:5173` | Client URL (used in emails) | +| Variable | Default | Description | +| ----------------------- | ------------------------------------------------------ | ---------------------------------------------------------- | +| `DATABASE_URL` | `postgres://projectx:projectx@localhost:5432/projectx` | PostgreSQL connection string | +| `PORT` | `3000` | Server port | +| `NODE_ENV` | `development` | Environment mode | +| `JWT_SECRET` | `change-me-in-production` | JWT signing secret | +| `SETUP_BOOTSTRAP_TOKEN` | (empty) | Required in production for initial `/api/v1/auth/setup` | +| `BOOKS_PATH` | `../local/data` | App data directory for cover images (not where books live) | +| `APP_URL` | `http://localhost:5173` | Client URL (used in emails) | ## Production Deploy -Production uses a separate compose file and env file: +Production uses the root compose file and env file: -- Compose: `docker-compose.prod.yml` -- Env file: `.env.prod` (from `.env.prod.example`) +- Compose: `docker-compose.yml` +- Env file: `.env` (from `.env.example`) - Image source: immutable images built in CI (`.github/workflows/container-image.yml`) Quick start: ```bash -cp .env.prod.example .env.prod -# edit .env.prod and set APP_IMAGE + secrets +cp .env.example .env +# edit .env and set APP_IMAGE + secrets pnpm prod:up ``` -Detailed runbook (deploy, backup, restore, restore drills): [docs/production.md](docs/production.md) - --- ## Database Workflow diff --git a/client/public/assets/foliate/paginator.js b/client/public/assets/foliate/paginator.js index 316115b5d..427e4b0fc 100644 --- a/client/public/assets/foliate/paginator.js +++ b/client/public/assets/foliate/paginator.js @@ -292,6 +292,7 @@ class View { } render(layout) { if (!layout) return + if (!this.document) return this.#column = layout.flow !== 'scrolled' this.#layout = layout if (this.#column) this.columnize(layout) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6d2605465..1740e682d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -2,20 +2,22 @@ name: projectx-dev services: postgres: + container_name: projectx-dev-db image: pgvector/pgvector:pg16 restart: unless-stopped environment: - POSTGRES_USER: ${POSTGRES_USER:-projectx} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-projectx} - POSTGRES_DB: ${POSTGRES_DB:-projectx} + POSTGRES_USER: ${DEV_POSTGRES_USER:-projectx} + POSTGRES_PASSWORD: ${DEV_POSTGRES_PASSWORD:-projectx} + POSTGRES_DB: ${DEV_POSTGRES_DB:-projectx} POSTGRES_HOST_AUTH_METHOD: trust ports: - - "5432:5432" + - "${DEV_POSTGRES_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql healthcheck: - test: ["CMD-SHELL", "pg_isready -U projectx"] + test: + ["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"'] interval: 5s timeout: 5s retries: 5 diff --git a/docker-compose.yml b/docker-compose.yml index 713748eff..874c09e24 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,46 +1,35 @@ name: projectx services: - postgres: - image: pgvector/pgvector:pg16@sha256:7d400e340efb42f4d8c9c12c6427adb253f726881a9985d2a471bf0eed824dff - restart: unless-stopped - environment: - POSTGRES_USER: ${POSTGRES_USER?required} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?required} - POSTGRES_DB: ${POSTGRES_DB?required} - volumes: - - ./data/postgres:/var/lib/postgresql/data - healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${POSTGRES_USER?required} -d ${POSTGRES_DB?required}", - ] - interval: 10s - timeout: 5s - retries: 10 - start_period: 20s - app: + container_name: projectx-app image: ${APP_IMAGE?required} restart: unless-stopped init: true + env_file: + - .env ports: - - "${APP_PORT:-3000}:3000" + - "${APP_PORT:-3000}:${PORT:-3000}" environment: + NODE_ENV: production + PORT: ${PORT:-3000} + DATABASE_URL: ${DATABASE_URL:-} + POSTGRES_HOST: ${POSTGRES_HOST:-postgres} + POSTGRES_PORT: ${POSTGRES_PORT:-5432} POSTGRES_USER: ${POSTGRES_USER?required} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?required} POSTGRES_DB: ${POSTGRES_DB?required} JWT_SECRET: ${JWT_SECRET?required} SETUP_BOOTSTRAP_TOKEN: ${SETUP_BOOTSTRAP_TOKEN?required} APP_URL: ${APP_URL?required} + CLIENT_URL: ${CLIENT_URL:-${APP_URL?required}} depends_on: postgres: condition: service_healthy volumes: - ./books:/books - - ./book-bucket:/book-bucket - - ./data:/data + - ./data/app:/data + - ./book-bucket:/data/book-bucket read_only: true tmpfs: - /tmp @@ -59,3 +48,21 @@ services: timeout: 5s retries: 3 start_period: 20s + + postgres: + container_name: projectx-db + image: pgvector/pgvector:pg16@sha256:7d400e340efb42f4d8c9c12c6427adb253f726881a9985d2a471bf0eed824dff + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER?required} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?required} + POSTGRES_DB: ${POSTGRES_DB?required} + volumes: + - ./data/postgres:/var/lib/postgresql/data + healthcheck: + test: + ["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"'] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s diff --git a/server/entrypoint.sh b/server/entrypoint.sh index 2c5b2132c..faf6f389d 100644 --- a/server/entrypoint.sh +++ b/server/entrypoint.sh @@ -9,4 +9,4 @@ export BOOKS_PATH="${BOOKS_PATH:-/data}" export BOOK_BUCKET_PATH="${BOOK_BUCKET_PATH:-/book-bucket}" node dist/scripts/migrate.js -exec node dist/main.js +exec node --max-old-space-size=512 dist/main.js diff --git a/server/package.json b/server/package.json index d312f4c99..f5e8b3f7b 100644 --- a/server/package.json +++ b/server/package.json @@ -37,8 +37,7 @@ "@fastify/cookie": "^11.0.2", "@fastify/formbody": "^8.0.2", "@fastify/helmet": "^13.0.2", - "@fastify/multipart": "^9.4.0", - "@fastify/rate-limit": "^10.3.0", + "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.0", "@nestjs/common": "^11.1.18", "@nestjs/config": "^4.0.3", @@ -59,7 +58,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "drizzle-orm": "^0.45.2", - "fast-xml-parser": "^5.5.10", + "fast-xml-parser": "^5.5.11", "fastest-levenshtein": "^1.0.16", "fastify": "^5.8.4", "jose": "^6.2.2", @@ -69,7 +68,7 @@ "mysql2": "^3.20.0", "nestjs-pino": "^4.6.1", "node-unrar-js": "^2.0.2", - "nodemailer": "^8.0.4", + "nodemailer": "^8.0.5", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pdf-lib": "^1.17.1", @@ -85,7 +84,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^10.0.1", - "@nestjs/cli": "^11.0.17", + "@nestjs/cli": "^11.0.18", "@nestjs/schematics": "^11.0.10", "@nestjs/testing": "^11.1.18", "@swc/cli": "^0.8.1", @@ -98,7 +97,7 @@ "@types/pg": "^8.18.0", "@types/supertest": "^7.2.0", "@types/unzipper": "^0.10.11", - "@vitest/coverage-v8": "^4.1.2", + "@vitest/coverage-v8": "^4.1.3", "drizzle-kit": "^0.31.10", "eslint": "^10.2.0", "eslint-config-prettier": "^10.1.8", @@ -113,7 +112,7 @@ "tsconfig-paths": "^4.2.0", "tsx": "^4.21.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.58.0", - "vitest": "^4.1.2" + "typescript-eslint": "^8.58.1", + "vitest": "^4.1.3" } } diff --git a/server/src/main.ts b/server/src/main.ts index 11213e62d..8aea1d596 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -9,7 +9,6 @@ import { join } from 'path'; import { Readable } from 'stream'; import fastifyCookie from '@fastify/cookie'; import fastifyMultipart from '@fastify/multipart'; -import fastifyRateLimit from '@fastify/rate-limit'; import fastifyStatic from '@fastify/static'; import fastifyHelmet from '@fastify/helmet'; import fastifyCompress from '@fastify/compress'; @@ -69,11 +68,12 @@ async function bootstrap() { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], - styleSrc: ["'self'", "'unsafe-inline'"], + styleSrc: ["'self'", "'unsafe-inline'", 'blob:'], imgSrc: ["'self'", 'data:', 'blob:'], connectSrc: ["'self'", 'ws:', 'wss:'], - fontSrc: ["'self'", 'data:'], + fontSrc: ["'self'", 'data:', 'blob:'], objectSrc: ["'none'"], + frameSrc: ["'self'", 'blob:'], frameAncestors: ["'none'"], }, }, @@ -84,19 +84,6 @@ async function bootstrap() { await app.register(fastifyCookie as never); await app.register(fastifyMultipart as never, { limits: { fileSize: MAX_COVER_BYTES } }); - // Rate limit unauthenticated requests only (brute-force protection on public endpoints). - // Bypass for: JWT cookie (web app), Authorization header (OPDS Basic Auth), - // and Kobo device endpoints (token-authenticated via URL). - await app.register(fastifyRateLimit as never, { - max: 100, - timeWindow: '1 minute', - allowList: (req: { cookies?: { access_token?: string }; headers?: { authorization?: string }; url?: string }) => - !!req.cookies?.access_token || - !!req.headers?.authorization || - /^\/api\/v1\/kobo\//.test(req.url ?? '') || - /^\/api\/v1\/epub\/\d+\/file\//.test(req.url ?? ''), - }); - if (process.env.NODE_ENV !== 'production') { app.enableCors({ origin: process.env.CLIENT_URL ?? 'http://localhost:5173', @@ -105,10 +92,21 @@ async function bootstrap() { } if (process.env.NODE_ENV === 'production') { + // NestJS calls setNotFoundHandler during init — intercept it so non-API 404s + // fall back to index.html instead of returning a JSON 404 (SPA routing support). + const adapterAny = adapter as any; + adapterAny.setNotFoundHandler = (nestHandler: (req: unknown, res: unknown) => void) => { + fastify.setNotFoundHandler(async (request, reply) => { + if (request.url.startsWith('/api')) { + return nestHandler(request, reply); + } + return reply.sendFile('index.html'); + }); + }; + await app.register(fastifyStatic as never, { root: join(__dirname, '..', 'public'), prefix: '/', - decorateReply: false, }); } diff --git a/server/src/modules/scanner/scanner.service.ts b/server/src/modules/scanner/scanner.service.ts index e187656fd..039bc25dd 100644 --- a/server/src/modules/scanner/scanner.service.ts +++ b/server/src/modules/scanner/scanner.service.ts @@ -16,7 +16,7 @@ import { ScannerRepository } from './scanner.repository'; import { assembleBookCards } from '../book/utils/assemble-book-cards'; const METADATA_FORMATS = new Set(['epub', 'mobi', 'azw3', 'azw', 'cbz', 'cbr', 'cb7', 'fb2', 'pdf', 'm4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac']); -const BATCH_SIZE = 5; +const BATCH_SIZE = 3; const BOOK_EMIT_BUFFER_SIZE = 30; const BOOK_EMIT_FLUSH_INTERVAL_MS = 1500; type OrganizationMode = 'book_per_file' | 'book_per_folder';