readest/Dockerfile
Huang Xin 731b13ab6f
fix(docker): unbreak large uploads and self-hosted cloud sync (#6100)
* fix(web): stop truncating request bodies over 10MB (#6091)

`middleware.ts` matches /api/*, so Next buffers a clone of every non-GET
request body. Past `experimental.proxyClientMaxBodySize` (10MB by default)
`getCloneableBody` pushes null into both the middleware's stream and the one
`finalize()` splices back over the IncomingMessage, so the route handler sees a
short body ending cleanly: no error, no 413, just a stored file of exactly
10,485,760 bytes. `/api/send/inbox/file` accepts 40MB, so an inbox EPUB over
10MB was already being stored corrupt.

The clone is buffered in memory before any handler can reject it, which makes
the ceiling an unauthenticated memory budget on every /api/* route. Only the
self-hosted image, which may proxy whole book files through the Node server,
gets real headroom; `standaloneOutput` is the gate because BUILD_STANDALONE is
set by the Dockerfile alone and web.readest.com runs on Cloudflare/Vercel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(settings): stop gating cloud sync as Premium when self-hosted (#6093)

Two causes of the same symptom on a self-hosted Docker deployment.

The badge keyed off sign-in, not entitlement:

  !user || (userProfilePlan !== undefined && !isCloudSyncPremium)

`!user` short-circuits, so a signed-out self-hoster — the normal state right
after `docker compose up` — got a Premium chip on WebDAV, Google Drive, S3 and
OneDrive. The rows opened fine and sync was never paused, but the chip reads as
"not available in this deployment". `shouldShowCloudProviderBadge` decides on
entitlement instead: self-hosting unlocks with or without a signed-in user.

`SELF_HOSTED` also only reached the container through `compose.yaml`
(`${SELF_HOSTED:-true}`), which landed in #5996. A compose file copied before
that survives every `docker compose pull`, so those deployments came up gated
with nothing on screen to explain why. The image now defaults it on: it is the
self-hosted artifact, web.readest.com never runs it, and an operator selling
plans still overrides with SELF_HOSTED=false.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(settings): clarify that the tier chip still gates signed-out visitors

The doc comment described what changed rather than what the predicate does, so
"entitlement is the deciding input, not sign-in state" read as if signed-out
users stopped being gated. They are not: a signed-out visitor to a hosted
deployment is not entitled, so the row is badged and stays behind
canToggleCloudProvider and the upgrade route, exactly as before. Only
entitlement — which self-hosting confers — removes the chip.

Assert the pairing in one test so a future refactor cannot decouple the badge
from the gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:56:25 +02:00

79 lines
4 KiB
Docker

FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS dependencies
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@11.1.1 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/readest-app/package.json ./apps/readest-app/
COPY patches/ ./patches/
COPY packages/ ./packages/
RUN --mount=type=cache,id=pnpm,sharing=locked,target=/pnpm/store pnpm install --frozen-lockfile
RUN test -f packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css \
&& test -d packages/simplecc-wasm/dist/web \
|| { printf '\nERROR: Required git submodules are not initialized in the source directory.\nEnsure submodules are initialized before running docker build.\nRun: git submodule update --init packages/foliate-js packages/simplecc-wasm\n\n'; exit 1; }
RUN pnpm --filter @readest/readest-app setup-vendors
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS development-stage
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@11.1.1 --activate
WORKDIR /app
COPY --from=dependencies /app /app
COPY . .
WORKDIR /app/apps/readest-app
EXPOSE 3000
ENTRYPOINT ["pnpm", "dev-web", "-H", "0.0.0.0"]
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS build
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@11.1.1 --activate
WORKDIR /app
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG NEXT_PUBLIC_APP_PLATFORM
ARG NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_OBJECT_STORAGE_TYPE
ARG NEXT_PUBLIC_STORAGE_FIXED_QUOTA
ARG NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA
COPY --from=dependencies /app/node_modules /app/node_modules
COPY --from=dependencies /app/apps/readest-app/node_modules /app/apps/readest-app/node_modules
COPY --from=dependencies /app/apps/readest-app/public/vendor /app/apps/readest-app/public/vendor
COPY --from=dependencies /app/packages/foliate-js/node_modules /app/packages/foliate-js/node_modules
COPY . .
WORKDIR /app/apps/readest-app
# Opt into the self-contained `.next/standalone` tree for this image only;
# next.config.mjs gates `output: 'standalone'` on BUILD_STANDALONE so other
# web builds keep their default output.
ENV BUILD_STANDALONE=true
RUN pnpm build-web
# Production runtime ships only the standalone server, its traced node_modules,
# and the static/public assets — no pnpm, no source tree, no dev dependencies,
# no build cache. `output: 'standalone'` (next.config.mjs) emits the self-contained
# tree under .next/standalone, so the entrypoint is a plain `node server.js`.
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS production-stage
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# This image is the self-hosted artifact — web.readest.com runs on
# Cloudflare/Vercel, never on it — so premium features are unlocked by default
# rather than left to the operator's compose file. `compose.yaml` passes
# `SELF_HOSTED: ${SELF_HOSTED:-true}`, but that line only landed in #5996: a
# compose file copied before it survives every `docker compose pull`, so the
# container came up gated with nothing on screen to explain why (#6093).
# An operator running a hosted service that sells plans sets SELF_HOSTED=false.
ENV SELF_HOSTED=true
WORKDIR /app
# Monorepo-rooted standalone tree: server.js + hoisted, traced node_modules.
COPY --from=build --chown=node:node /app/apps/readest-app/.next/standalone ./
# Static and public assets are not part of the standalone trace; copy them next
# to the server so their default relative paths resolve.
COPY --from=build --chown=node:node /app/apps/readest-app/.next/static ./apps/readest-app/.next/static
COPY --from=build --chown=node:node /app/apps/readest-app/public ./apps/readest-app/public
USER node
EXPOSE 3000
ENTRYPOINT ["node", "apps/readest-app/server.js"]