diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..61f7a2e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.gitea +.claude +.codex +.gemini +.impeccable +.superpowers +.worktrees +node_modules +dist +samples +docs +test +*.md +.env +.env.* +.dev.vars +docker-compose*.yml +Dockerfile diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8bcd474 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,99 @@ +name: Continuous Integration + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] + +env: + REGISTRY: gitea.lucaswinther.info + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Typecheck + run: bun run typecheck + - name: Run test suite + run: bun test + - name: Build page and userscript + run: bun run build + + docker-build-and-push: + runs-on: ubuntu-latest + needs: [lint-and-test] + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Gitea's job token cannot publish packages; use a package-write PAT. + - name: Log in to Gitea Container Registry + if: gitea.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ gitea.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ gitea.repository }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=semver,pattern={{version}} + type=sha,prefix=sha- + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + build-args: | + BUILD_VERSION=${{ gitea.sha }} + BUILD_REPOSITORY=https://gitea.lucaswinther.info/${{ gitea.repository }} + push: ${{ gitea.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + # Read-only smoke test: no storage, no outbound calls; the page, config and userscript must be served. + - name: Pull and smoke-test published image + if: gitea.event_name != 'pull_request' + env: + PUBLISHED_TAGS: ${{ steps.meta.outputs.tags }} + run: | + image="$(printf '%s\n' "$PUBLISHED_TAGS" | head -n 1)" + docker pull "$image" + docker run -d --rm --name mangabaka-seeder-ci "$image" + trap 'docker stop mangabaka-seeder-ci' EXIT + docker exec mangabaka-seeder-ci bun -e ' + const base = "http://127.0.0.1:8787"; + let ready = false; + for (let attempt = 0; attempt < 30; attempt++) { + try { + const response = await fetch(base + "/"); + if (response.ok && (await response.text()).includes("MangaBaka Seeder")) { + ready = true; + break; + } + } catch {} + await Bun.sleep(1000); + } + if (!ready) throw new Error("App did not serve its HTML"); + const config = await (await fetch(base + "/api/config")).json(); + if (config.storage !== false) throw new Error("expected no storage without MBSEED_STORAGE_DIR"); + const script = await fetch(base + "/mbseed.user.js"); + if (!script.ok || !(await script.text()).startsWith("// ==UserScript==")) throw new Error("/mbseed.user.js missing"); + const refused = await fetch(base + "/api/fetch?url=https://example.org/"); + if (refused.status !== 403) throw new Error(`/api/fetch without x-mbseed returned ${refused.status}`); + ' diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..800d69e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 +# mangabaka-seeder: the page, the userscript and the Bun server in one image. +# Configure with MBSEED_* environment variables (docs/ARCHITECTURE.md, "Running the server"). + +FROM oven/bun:1.3.14-slim AS build +WORKDIR /app +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile +COPY tsconfig.json ./ +COPY src ./src +COPY scripts ./scripts +RUN bun run build:web + +FROM oven/bun:1.3.14-slim +ARG BUILD_VERSION=dev +ARG BUILD_REPOSITORY=https://gitea.lucaswinther.info/lucasw89/mangabaka-seeder +LABEL org.opencontainers.image.title="mangabaka-seeder" \ + org.opencontainers.image.description="Seed WEBTOON covers into MangaBaka cover edit requests" \ + org.opencontainers.image.source="${BUILD_REPOSITORY}" \ + org.opencontainers.image.revision="${BUILD_VERSION}" +WORKDIR /app +# No runtime dependencies: the server, the page and the userscript build need only Bun. +COPY --from=build /app/package.json ./ +COPY --from=build /app/src ./src +COPY --from=build /app/scripts ./scripts +COPY --from=build /app/dist ./dist +# Storage is off unless MBSEED_STORAGE_DIR is set (with MBSEED_PUBLIC_URL); /data/covers is the conventional place. +RUN mkdir -p /data/covers && chown -R bun:bun /data /app/dist +ENV MBSEED_HOST=0.0.0.0 \ + MBSEED_PORT=8787 \ + MBSEED_ASSETS_DIR=/app/dist/web +USER bun +EXPOSE 8787 +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD ["bun", "-e", "fetch('http://127.0.0.1:' + (process.env.MBSEED_PORT ?? 8787) + '/api/config').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"] +# The userscript is built at start so its @connect list includes MBSEED_PUBLIC_URL's host. +CMD ["sh", "-c", "bun run scripts/build-userscript.ts && exec bun run src/server/bun.ts"] diff --git a/README.md b/README.md index c3b8de4..7dee664 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,19 @@ its own storage and the page offers litterbox, catbox and no rehosting. All settings, and how to put it behind a reverse proxy, are in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md#running-the-server). +### With Docker + +Every push to `main` builds an image and publishes it to +`gitea.lucaswinther.info/lucasw89/mangabaka-seeder` (`latest`, the branch name, +`sha-`, and the version for `v*` tags). Copy +[`docker-compose.example.yml`](docker-compose.example.yml) to `docker-compose.yml`, +set `MBSEED_PUBLIC_URL`, and run `docker compose up -d`. To build it yourself: +`docker build -t mangabaka-seeder .` + +The CI workflow (`.gitea/workflows/ci.yml`) publishes with a repository or user +secret `REGISTRY_TOKEN`: a Gitea access token with package write permission +(Gitea's job token cannot publish packages). + ### The userscript Install `/mbseed.user.js` from the running server (or `dist/web/mbseed.user.js`) diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..eba4cac --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,26 @@ +# Example: run mangabaka-seeder behind your reverse proxy. +# Copy to docker-compose.yml, set MBSEED_PUBLIC_URL to the address your proxy serves, then: +# docker compose up -d +# All settings: docs/ARCHITECTURE.md, "Running the server". +services: + mangabaka-seeder: + image: gitea.lucaswinther.info/lucasw89/mangabaka-seeder:latest + restart: unless-stopped + # Only the reverse proxy should reach it; it terminates TLS and appends X-Forwarded-For. + ports: + - "127.0.0.1:8787:8787" + environment: + # Public origin (no path). Stored covers are served from it, and the userscript may connect to it. + MBSEED_PUBLIC_URL: https://mbseed.example.org + # Rehost covers on this server for 30 days. Remove both lines to run without storage + # (the page then offers litterbox, catbox and no rehosting). + MBSEED_STORAGE_DIR: /data/covers + MBSEED_STORAGE_MAX_BYTES: "1073741824" # 1 GiB; uploads past it get 507, nothing is evicted early + # Rate-limit on the address your proxy appends to X-Forwarded-For. Leave unset without a proxy. + MBSEED_TRUST_PROXY: "1" + volumes: + # A named volume works as is. For a bind mount, make the directory writable by uid 1000 (the image's "bun" user). + - covers:/data/covers + +volumes: + covers: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 16b3710..6933dd4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -42,6 +42,13 @@ user edits them, then `rehost` (explicit) → `manualText` for the cover form, o Put it behind a reverse proxy that terminates TLS and **appends** the client address to `X-Forwarded-For` (nginx `$proxy_add_x_forwarded_for`, Caddy's default), then set `MBSEED_TRUST_PROXY=1`. +**Docker.** `Dockerfile` builds the page in one stage and runs the server as user `bun` (uid 1000) on +`0.0.0.0:8787`; `MBSEED_ASSETS_DIR=/app/dist/web`. Storage stays off unless `MBSEED_STORAGE_DIR` is set +(`/data/covers` exists for it). The container builds the userscript at start, so its `@connect` list +includes `MBSEED_PUBLIC_URL`'s host. `bun.ts` stops on SIGTERM/SIGINT (Bun as PID 1 ignores them otherwise). +`.gitea/workflows/ci.yml` typechecks, tests and builds, then builds and pushes the image (not for pull +requests) and smoke-tests it. Example deployment: `docker-compose.example.yml`. + ## External dependencies | Service | Reached via | If unavailable | |---|---|---| diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index bfef071..3e0475b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -16,6 +16,23 @@ the repo every session — measurably slower and more tokens). **Consequences:** Rule changes happen in one file; pointer files must stay thin. Plans are working artefacts: kept after merge they go stale and contradict the code, so the spec is the record. +## 2026-10-15 — Docker image built by Gitea CI +**Status:** accepted +**Context:** The user wants a Dockerfile that Gitea builds automatically, plus a docker-compose example, +mirroring the working workflow in their `budget2.0` repository. +**Decision:** Two-stage image on `oven/bun:1.3.14-slim` (no runtime dependencies, so only `src`, `scripts` +and the built page are copied). It runs as `bun`, listens on `0.0.0.0:8787`, and has a healthcheck on +`/api/config`. Storage is off by default; the compose example turns it on in a named volume at `/data/covers`. +The userscript is built when the container starts, not in the image, because its `@connect` list depends on +`MBSEED_PUBLIC_URL`, which is only known at run time. `bun.ts` handles SIGTERM/SIGINT: without it Bun as +PID 1 ignored `docker stop` and was killed after 10 s. The workflow copies budget2.0: tests first, then +Buildx, push to the Gitea registry with a `REGISTRY_TOKEN` secret (a package-write token; Gitea's job +token cannot publish packages), and a read-only smoke test of the pushed image. +**Alternatives:** Building the userscript in the image (wrong `@connect` for every deployment); +a distroless or compiled-binary image (smaller, but harder to debug, and Bun's slim image is enough). +**Consequences:** The `REGISTRY_TOKEN` secret must exist in Gitea before the image job can push. Starting +the container takes about a second longer for the userscript build. + ## 2026-10-15 — Harden the server proxy and uploads (final review) **Status:** accepted (amends "Implementation details: server API" below) **Context:** The whole-branch review found that `/api/fetch` passed the upstream content type through. Anyone can diff --git a/docs/TODO.md b/docs/TODO.md index 77ef970..0645fd3 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -8,6 +8,8 @@ Design: [specs/001-webtoons-covers.md](specs/001-webtoons-covers.md). - [ ] **User smoke test** in a real browser: the page (local server) and the userscript in Tampermonkey or Violentmonkey on a MangaBaka series page — Tower of God (9999 / title_no 95). The agent ran both in headless Chrome (the userscript with a GM shim); a real GM install is still untested. +- [ ] **Gitea secret `REGISTRY_TOKEN`** (needs the user): an access token with package write permission, as a + repository or user secret, so CI can push the image. Then check the first run of `.gitea/workflows/ci.yml`. - [ ] **Deploy** (needs the user): which server and domain (`MBSEED_PUBLIC_URL`), where the covers folder lives, whether the 1 GiB cap suits the disk, reverse proxy with TLS that appends `X-Forwarded-For`. diff --git a/src/server/bun.ts b/src/server/bun.ts index e4badce..5caf838 100644 --- a/src/server/bun.ts +++ b/src/server/bun.ts @@ -37,3 +37,10 @@ console.log( storage ? `${config.storageDir} (${storage.usedBytes()} of ${config.storageMaxBytes} bytes)` : "off" }`, ); + +// In a container Bun runs as PID 1, which ignores SIGTERM without a handler: stop cleanly on docker stop. +for (const signal of ["SIGTERM", "SIGINT"] as const) { + process.on(signal, () => { + void server.stop().finally(() => process.exit(0)); + }); +}