diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index d5c7780..a9ceee4 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -1,15 +1,36 @@ name: Publish -# Builds the application image and pushes it to the Gitea container registry -# as git.unsupervised.ca/unsupervised/antisocial. +# Builds the application image and pushes it to a container registry. # -# Runs on main, and on demand. Pull requests build without pushing, so a -# broken Dockerfile is caught before it can move a published tag. +# push to main -> :main and :sha- +# tag 1.2.3 -> :1.2.3, :1.2, :1 and :latest +# pull request -> builds without pushing, so a broken Dockerfile is +# caught before it can move a published tag +# +# Configure with repository variables and secrets: +# +# vars.REGISTRY required, e.g. registry.example.com +# vars.IMAGE_NAME optional, defaults to this repository's owner/name +# vars.REGISTRY_USER optional, defaults to the actor running the workflow +# secrets.REGISTRY_TOKEN required to push +# +# Point REGISTRY at a host the runner reaches directly, without an intermediate +# proxy that caps request bodies: a browser image has layers well over 100MB, +# and such a proxy rejects them mid-push with `413 Payload Too Large`. +# +# If that host serves plain HTTP, the builder's Docker daemon also needs it in +# `insecure-registries` — that is daemon configuration, not something a +# workflow can set. on: push: branches: - main + tags: + - '[0-9]+.[0-9]+.[0-9]+' + - '[0-9]+.[0-9]+.[0-9]+-*' + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-*' pull_request: paths: - 'Dockerfile' @@ -18,10 +39,6 @@ on: - '.gitea/workflows/publish.yml' workflow_dispatch: -env: - REGISTRY: git.unsupervised.ca - IMAGE: unsupervised/antisocial - jobs: build: name: Build and push @@ -29,55 +46,97 @@ jobs: steps: - uses: actions/checkout@v4 - # Images are built natively, so each carries the architecture of the - # runner that built it. Every runner in the pool is arm64, which is - # also what Kallone is. A runner of a different architecture joining - # would overwrite these tags with its own arch, at which point this - # needs buildx and a multi-arch manifest. - - name: Check Docker is available + - name: Check the runner can build images run: | if ! docker info >/dev/null 2>&1; then echo "No usable Docker daemon in the job container." >&2 - echo "act_runner needs container.docker_host set, or left empty to autodetect." >&2 exit 1 fi docker version --format 'client {{.Client.Version}} / server {{.Server.Version}} / arch {{.Server.Arch}}' + # Images are built natively, so each carries the architecture of the + # runner that built it. A runner of a different architecture joining the + # pool would overwrite these tags with its own arch, at which point this + # needs buildx and a manifest list. - name: Work out the tags - id: tags - run: echo "sha=sha-$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + id: meta + env: + REGISTRY: ${{ vars.REGISTRY }} + IMAGE_NAME: ${{ vars.IMAGE_NAME }} + run: | + set -euo pipefail - # REGISTRY_TOKEN is an organisation secret: a personal access token - # with the package scope, Read and Write. Gitea's Actions task token is - # rejected by the container registry (go-gitea/gitea#23642), so this - # cannot fall back to secrets.GITHUB_TOKEN. The login user must own the - # token; set the REGISTRY_USER variable if it is not github.actor. + if [ -z "${REGISTRY}" ]; then + echo "The REGISTRY repository variable is not set." >&2 + echo "Set it to the registry host to publish to, e.g. registry.example.com" >&2 + exit 1 + fi + + image="${REGISTRY}/$(echo "${IMAGE_NAME:-${{ github.repository }}}" | tr '[:upper:]' '[:lower:]')" + tags="" + + if [ "${{ github.ref_type }}" = "tag" ]; then + version="${{ github.ref_name }}" + version="${version#v}" + tags="${version}" + + # Only a final release moves the rolling aliases; a prerelease is + # published under its own exact version and nothing else. + case "${version}" in + *-*) ;; + *) + major="${version%%.*}" + minor="${version%.*}" + tags="${tags} ${minor} ${major} latest" + ;; + esac + else + tags="main sha-$(git rev-parse --short HEAD)" + fi + + args="" + for tag in ${tags}; do args="${args} --tag ${image}:${tag}"; done + + { + echo "image=${image}" + echo "tags=${tags}" + echo "args=${args}" + } >> "$GITHUB_OUTPUT" + + echo "Publishing ${image} as:${tags// /, :}" + + # The Actions task token is rejected by some registries, so pushing uses + # a token that belongs to a real user. - name: Log in to the container registry if: github.event_name != 'pull_request' run: | if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then - echo "REGISTRY_TOKEN is not set. The Actions task token cannot push packages." >&2 + echo "REGISTRY_TOKEN is not set." >&2 + exit 1 + fi + if ! echo "${{ secrets.REGISTRY_TOKEN }}" \ + | docker login "${{ vars.REGISTRY }}" -u "${{ vars.REGISTRY_USER || github.actor }}" --password-stdin + then + echo >&2 + echo "If that failed with 'server gave HTTP response to HTTPS client', the" >&2 + echo "registry is plain HTTP and the builder's Docker daemon has to be told" >&2 + echo "to allow it: add ${{ vars.REGISTRY }} to insecure-registries in the" >&2 + echo "daemon config on the runner. The workflow cannot configure that." >&2 exit 1 fi - echo "${{ secrets.REGISTRY_TOKEN }}" \ - | docker login "${REGISTRY}" -u "${{ vars.REGISTRY_USER || github.actor }}" --password-stdin - name: Build - run: | - docker build \ - --pull \ - --tag "${REGISTRY}/${IMAGE}:${{ steps.tags.outputs.sha }}" \ - --tag "${REGISTRY}/${IMAGE}:latest" \ - --file Dockerfile \ - . + run: docker build --pull ${{ steps.meta.outputs.args }} --file Dockerfile . - name: Push if: github.event_name != 'pull_request' run: | - docker push "${REGISTRY}/${IMAGE}:${{ steps.tags.outputs.sha }}" - docker push "${REGISTRY}/${IMAGE}:latest" - echo "Published ${REGISTRY}/${IMAGE}:${{ steps.tags.outputs.sha }}" + set -euo pipefail + for tag in ${{ steps.meta.outputs.tags }}; do + docker push "${{ steps.meta.outputs.image }}:${tag}" + done + echo "Published ${{ steps.meta.outputs.image }} as: ${{ steps.meta.outputs.tags }}" - name: Log out if: always() && github.event_name != 'pull_request' - run: docker logout "${REGISTRY}" || true + run: docker logout "${{ vars.REGISTRY }}" || true diff --git a/CLAUDE.md b/CLAUDE.md index 368bbcf..4ecd53a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,9 +5,14 @@ links to X, Threads, Instagram, TikTok and Bluesky into `//`. Manifests live -in the **`infra` repo** at `k8s/kallone/antisocial.unsupervised.studio/`; Flux syncs -`k8s/kallone` every 10 minutes. The DNS CNAME is in `infra/cloudflare/unsupervised.studio.tf`. +`.gitea/workflows/publish.yml` builds the image and pushes it. A push to main publishes +`:main` and `:sha-`; a version tag like `1.2.3` publishes `:1.2.3`, `:1.2`, `:1` +and `:latest`. A prerelease tag (`1.2.3-rc1`) publishes only its exact version and does +not move `latest`. -Chromium needs the memory-backed `/dev/shm` volume in the deployment — it crashes against -the 64Mi default. Measured 623Mi resident. +The registry comes from the `REGISTRY` repository variable, the image name from +`IMAGE_NAME` or the repository name, and credentials from `REGISTRY_USER` and the +`REGISTRY_TOKEN` secret. Nothing about any particular deployment is committed here. + +Two things any deployment has to get right, both learned the hard way: + +- **Chromium needs more than the default 64Mi `/dev/shm`** or it crashes. Mount a + memory-backed volume of a few hundred Mi at `/dev/shm`. +- **The registry must be reachable without a proxy that caps request bodies.** The image + has a layer well over 100MB; a proxy with a smaller limit fails the push partway + through with `413 Payload Too Large`. + +Measured around 620Mi resident with the browser up. diff --git a/Dockerfile b/Dockerfile index d5fb4a2..2c3590a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,18 @@ -# antisocial — reads social posts back to you without the app. +# antisocial # -# Both stages sit on the Playwright image so the Node that compiles the code -# is the Node that runs it, and so the Chromium build matches the Playwright -# package exactly. The image is large because a browser is large; the -# alternative, apt Chromium on a slim Node base, saves a few hundred MB and -# is where arm64 browser builds usually go wrong. +# Only headless Chromium is ever launched, so the image installs just that +# browser onto a slim Node base rather than using the Playwright image, which +# also carries Firefox and WebKit — roughly two thirds of its size, for +# browsers this never opens. # -# Pinned in lockstep with the `playwright` dependency in package.json. -ARG PLAYWRIGHT_VERSION=1.62.1 +# Built natively; the tag carries the architecture of the machine that built +# it. Multi-arch would need buildx and a manifest list. -FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble AS build +FROM node:22-bookworm-slim AS build WORKDIR /app -# Browsers are already in the base image; downloading them again during -# `npm ci` would double the build for nothing. +# The browser is installed in the runtime stage, not here. ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 COPY package.json package-lock.json ./ @@ -25,26 +23,41 @@ COPY src ./src RUN npm run build && npm prune --omit=dev -FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble AS runtime +FROM node:22-bookworm-slim AS runtime WORKDIR /app ENV NODE_ENV=production \ - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ HOST=0.0.0.0 \ PORT=8080 \ - PROFILE_DIR=/data/profile + PROFILE_DIR=/data/profile \ + PLAYWRIGHT_BROWSERS_PATH=/ms-playwright + +# `chromium-headless-shell` and not `chromium`: a headless launch resolves to +# the shell either way, so installing the full browser alongside it would ship +# a binary nothing ever executes. `src/browser/pool.ts` names the same channel +# so the two cannot drift apart. +# +# Fonts matter more than they look: without them any rendered text — including +# a verification puzzle we have to show a person — comes out as empty boxes. +RUN npx --yes playwright@1.62.1 install --with-deps chromium-headless-shell \ + && apt-get update \ + && apt-get install -y --no-install-recommends fonts-liberation fonts-noto-color-emoji \ + && rm -rf /var/lib/apt/lists/* /root/.npm COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY package.json ./ COPY public ./public -# The profile directory is normally a mounted volume; create it anyway so -# the image runs standalone. -RUN mkdir -p /data/profile && chown -R pwuser:pwuser /data /app +# Chromium will not start as root without its sandbox disabled outright, and +# there is no reason to run as root anyway. +RUN useradd --create-home --uid 10001 antisocial \ + && mkdir -p /data/profile \ + && chown -R antisocial:antisocial /data /app \ + && chmod -R a+rx /ms-playwright -USER pwuser +USER antisocial EXPOSE 8080 diff --git a/README.md b/README.md index e48dc6f..71be3fe 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,10 @@ tracking. antisocial is the other half of a StopTheMadness rewrite rule: the lin redirected here, and you get the post — the media and the words — plus a badge saying where it came from and a button to copy the original URL if you do want to go there. -Single user, no authentication, tailnet only. It resolves posts by driving a real -headless browser from a residential IP, logged out, exactly as if you'd opened the link -yourself. - -Served at from Kallone. Deployment manifests -live in the `infra` repo under `k8s/kallone/antisocial.unsupervised.studio/`. +Built for one person, on a private network. There is **no authentication of any kind** — +anything that can reach it can drive a browser through it, so put it somewhere only you +can reach. It resolves posts by driving a real headless browser, logged out, exactly as +if you had opened the link yourself. ## StopTheMadness rules @@ -21,16 +19,18 @@ One redirect rule per platform. The host is swapped for antisocial plus a short segment; the rest of the path is left alone, so the original is always recoverable and readable in your history. +Replace `antisocial.example.com` with wherever you are running it. + | Platform | Find | Replace | | --- | --- | --- | -| X | `^https://(?:www\.\|mobile\.)?(?:x\|twitter)\.com/(.*)$` | `https://antisocial.unsupervised.studio/x/$1` | -| Threads | `^https://(?:www\.)?threads\.(?:net\|com)/(.*)$` | `https://antisocial.unsupervised.studio/threads/$1` | -| Instagram | `^https://(?:www\.)?instagram\.com/(.*)$` | `https://antisocial.unsupervised.studio/ig/$1` | -| TikTok | `^https://(?:www\.\|vm\.\|vt\.)?tiktok\.com/(.*)$` | `https://antisocial.unsupervised.studio/tiktok/$1` | -| Bluesky | `^https://bsky\.app/(.*)$` | `https://antisocial.unsupervised.studio/bsky/$1` | +| X | `^https://(?:www\.\|mobile\.)?(?:x\|twitter)\.com/(.*)$` | `https://antisocial.example.com/x/$1` | +| Threads | `^https://(?:www\.)?threads\.(?:net\|com)/(.*)$` | `https://antisocial.example.com/threads/$1` | +| Instagram | `^https://(?:www\.)?instagram\.com/(.*)$` | `https://antisocial.example.com/ig/$1` | +| TikTok | `^https://(?:www\.\|vm\.\|vt\.)?tiktok\.com/(.*)$` | `https://antisocial.example.com/tiktok/$1` | +| Bluesky | `^https://bsky\.app/(.*)$` | `https://antisocial.example.com/bsky/$1` | So `https://x.com/user/status/123` becomes -`https://antisocial.unsupervised.studio/x/user/status/123`. +`https://antisocial.example.com/x/user/status/123`. Tracking parameters (`igsh`, `utm_*`, `s`, `t`, and friends) are stripped on arrival, so the URL the copy button gives back is the clean one. TikTok `vm.`/`vt.` share codes lose @@ -132,8 +132,8 @@ link preview offers. ## Development -Needs Node 22+. Local container builds use Apple `container`, which is arm64 native and -therefore the same architecture as Kallone. +Needs Node 22+. The container commands below use Apple `container`; `docker` takes the +same arguments if that is what you have. ```sh npm ci @@ -165,7 +165,7 @@ Everything has a working default; the container needs none of it set. | `RESOLVE_TIMEOUT_MS` | `30000` | whole resolve, including extraction | | `CACHE_TTL_MS` / `CACHE_MAX` | `3600000` / `200` | resolved posts, in memory | | `MEDIA_TOKEN_TTL_MS` | `21600000` | how long a `/m/` reference stays valid | -| `PUBLIC_ORIGIN` | `https://antisocial.unsupervised.studio` | only used to print the rules on `/` | +| `PUBLIC_ORIGIN` | `http://localhost:8080` | only used to print the rules on `/` | | `LOG_LEVEL` | `info` | | ## Adding a platform diff --git a/src/browser/pool.ts b/src/browser/pool.ts index e9a130b..9bcfe57 100644 --- a/src/browser/pool.ts +++ b/src/browser/pool.ts @@ -67,6 +67,9 @@ async function start(): Promise { await mkdir(config.profileDir, { recursive: true }); const launched = await chromium.launch({ + // Named explicitly: a headless launch resolves to the shell anyway, and + // saying so keeps the image from having to carry the full browser too. + channel: 'chromium-headless-shell', args: [ // The container is unprivileged and single-user; the kernel sandbox // cannot start without extra capabilities we would rather not grant. diff --git a/src/config.ts b/src/config.ts index be7deca..59245a1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -46,9 +46,9 @@ export const config = { * watching a video, short enough that a leaked URL goes stale. */ mediaTokenTtlMs: int('MEDIA_TOKEN_TTL_MS', 6 * 60 * 60 * 1000), - /** Public origin, used to build absolute URLs. Only needs setting if - * something ever cares about absolute links. */ - publicOrigin: str('PUBLIC_ORIGIN', 'https://antisocial.unsupervised.studio'), + /** Where this instance is reachable. Only used to print the rewrite rules + * on the index page with the right hostname in them. */ + publicOrigin: str('PUBLIC_ORIGIN', 'http://localhost:8080'), } as const; export type Config = typeof config;