diff --git a/.env.example b/.env.example index 447fc4c72..852cc1e88 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,18 @@ APP_IMAGE=ghcr.io/your-org/projectx:sha- # App publish port on host APP_PORT=3000 +# Host folder that appears as /books in the container. +# Point this at your library root; use an absolute path on NAS/Portainer installs. +BOOKS_HOST_PATH=./books + +# Container runtime UID/GID for files written to app-managed data folders. +# Most users can leave these defaults. +PUID=1000 +PGID=1000 + +# Node.js JavaScript heap limit in MB. Raise this for very large libraries. +NODE_MAX_OLD_SPACE_SIZE=2048 + # Database credentials POSTGRES_USER=projectx POSTGRES_PASSWORD=change-this-password @@ -28,6 +40,9 @@ APP_URL=https://projectx.example.com # CORS origin for the frontend when served from a different domain than APP_URL # CLIENT_URL=https://app.example.com +# Set to false only if your platform manages bind mount ownership externally. +# PROJECTX_FIX_PERMISSIONS=true + # Recommended if you store SMTP provider credentials (generate: openssl rand -hex 32) # EMAIL_ENCRYPTION_KEY= diff --git a/Dockerfile b/Dockerfile index e405f43e0..7e0ed62a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,7 +41,7 @@ FROM ${NODE_IMAGE} AS runtime WORKDIR /app RUN apk upgrade --no-cache && \ - apk add --no-cache poppler-utils && \ + apk add --no-cache poppler-utils su-exec && \ rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx ENV NODE_ENV=production @@ -51,9 +51,7 @@ COPY --from=server-builder --chown=node:node /deploy ./ COPY --from=client-builder --chown=node:node /app/client/dist ./public COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh ./entrypoint.sh -RUN chmod +x /app/entrypoint.sh && mkdir -p /books /book-bucket /data /tmp && chown -R node:node /books /book-bucket /data /tmp - -USER node +RUN chmod +x /app/entrypoint.sh && mkdir -p /books /data/covers /data/book-bucket /tmp && chown -R node:node /data /tmp EXPOSE 3000 diff --git a/README.md b/README.md index 8583a0346..533b5f507 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Both must be running. The client proxies all `/api` and `/socket.io` requests to 2. **Create a library** - open Settings (gear icon) > Libraries > Create Library. Add a folder path pointing to some books on your machine, e.g. any directory containing `.epub`, `.pdf`, or `.cbz` files. 3. **Scan** - the library scans automatically after creation. You should see books appear on the home page once the scan completes. -> **Where does data go?** `BOOKS_PATH` in `server/.env` defaults to `../local/data` (resolves to `/local/data`). This is the app's data directory where extracted cover images and thumbnails are stored (`local/data/covers/`). It is **not** where your book files live - those paths are configured per-library in the UI. The `local/` folder is gitignored. +> **Where does data go?** `APP_DATA_PATH` in `server/.env` defaults to `../local/data` (resolves to `/local/data`). This is the app's data directory where extracted cover images and thumbnails are stored (`local/data/covers/`). It is **not** where your book files live - those paths are configured per-library in the UI. The `local/` folder is gitignored. --- @@ -211,15 +211,16 @@ The fastest way to learn the patterns is to follow an existing module: 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 | -| `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) | +| 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` | +| `APP_DATA_PATH` | `../local/data` | App data directory for cover images (not where books live) | +| `APP_URL` | `http://localhost:5173` | Client URL (used in emails) | +| `NODE_MAX_OLD_SPACE_SIZE` | `2048` | Docker Node.js heap limit in MB | ## Production Deploy @@ -233,10 +234,31 @@ Quick start: ```bash cp .env.example .env -# edit .env and set APP_IMAGE + secrets +# edit .env and set APP_IMAGE, secrets, APP_URL, and BOOKS_HOST_PATH pnpm prod:up ``` +For production compose, `BOOKS_HOST_PATH` is the only host folder most users need to choose. It is mounted as `/books` in the container, and library paths created in the UI should point under `/books`. + +Generated app data and PostgreSQL live in explicit folders beside the compose file: + +```text +./data/app generated covers, author images, staging files, and Book Bucket uploads +./data/postgres PostgreSQL data +``` + +The container starts as root only long enough to create/chown app-managed data paths, then runs the Node process as `PUID:PGID`. + +On NAS installs, set `PUID` and `PGID` to the user/group that should own app-generated files: + +```bash +id your-media-user +``` + +Do not add a compose `user:` override unless your platform requires it; that bypasses the startup permission repair. + +The app container defaults Node's JavaScript heap limit to 2048 MB. Set `NODE_MAX_OLD_SPACE_SIZE` in `.env` if the host needs a lower or higher limit. + --- ## Database Workflow @@ -331,6 +353,12 @@ docker compose up -d --wait cd server && pnpm db:migrate ``` +### `EACCES: permission denied, mkdir '/data/covers'` + +The default compose uses `./data/app` for generated app data. The container repairs `/data` ownership during startup, then runs as `PUID:PGID`. + +If this still happens, make sure the folder mounted at `/data` is writable by `PUID:PGID`. Avoid adding `user:` to the app service, because startup cannot repair ownership when it is forced to start as a non-root user. + ### `Cannot find module '@projectx/types'` The shared types package needs to be built or the workspace link is broken: diff --git a/client/src/components/AppHeader.vue b/client/src/components/AppHeader.vue index d9122dcfd..16b94b891 100644 --- a/client/src/components/AppHeader.vue +++ b/client/src/components/AppHeader.vue @@ -30,6 +30,7 @@ import { useLibraryUploadEvents } from '@/features/library/composables/useLibrar import { useBookBucketSummary } from '@/features/book-bucket/composables/useBookBucketSummary' import UserAvatar from '@/components/UserAvatar.vue' import { DEFAULT_FORMAT_PRIORITY } from '@projectx/types' +import { useThemeStore } from '@/stores/theme' const router = useRouter() const route = useRoute() @@ -38,10 +39,13 @@ const { open: openChangePassword } = useChangePasswordDialog() const { hasPermission } = usePermissions() const { onLibraryUploadCompleted } = useLibraryUploadEvents() const { summary: bookBucketSummary, fetchSummary: fetchBookBucketSummary, subscribe: subscribeBookBucketSummary } = useBookBucketSummary() +const themeStore = useThemeStore() const isBookBucketActive = computed(() => route.name === 'book-bucket') const isStatisticsActive = computed(() => route.name === 'statistics') +const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full')) + function navigateToBookBucket() { router.push({ name: 'book-bucket' }) } @@ -147,7 +151,7 @@ onMounted(() => { } }) -const stopUploadCompletedListener = onLibraryUploadCompleted((event) => { +const stopLibraryUploadListener = onLibraryUploadCompleted((event) => { if (event.uploadedCount === 0 && event.failedCount === 0) return const uploadedLabel = `${event.uploadedCount} book${event.uploadedCount === 1 ? '' : 's'}` @@ -167,7 +171,7 @@ const stopUploadCompletedListener = onLibraryUploadCompleted((event) => { onUnmounted(() => { window.removeEventListener('keydown', handleGlobalKeydown) - stopUploadCompletedListener() + stopLibraryUploadListener() }) function highlightSegments(text: string | null, query: string) { @@ -300,7 +304,7 @@ function formatBadgeClass(fmt: string): string {