Self-hosted screenshot capture and sharing for you and a small circle of friends. A browser extension (Chrome + Firefox) captures the visible tab, a selected region or the whole page and uploads it to your server; each capture gets an unguessable link where the owner annotates (red/white rectangles, arrows, text) and anyone with the link sees the flat render plus a link back to the original page. Nothing goes to a third party.
It is built defensively from day one — capability URLs with 160 bits of entropy, byte-identical 404s so nothing can be enumerated, per-IP bans and a global breaker, full attribution of every upload, an append-only admin audit log enforced by the database, strict CSP, automated TLS via DNS-01 with a single published port, retention with tombstones, nightly verified backups. Design: PLAN.md. Working rules: CLAUDE.md. All docs: docs/README.md.
Status: v1 complete (milestones M0–M8, PLAN.md §16). What remains is human-gated: store submission and AMO signing with the owner's accounts (extension/STORE_SUBMISSION.md).
extension ──POST /api/v1/captures (bearer token)──▶ Caddy (TLS) ──▶ app (Fastify) ──▶ Postgres + image volume
Linux client (tray) ──────────── same ──────────────▶ │
viewer ──GET /s/<id> ─────────────────────────────▶ page + /s/<id>/image.png (flat render, cached)
Accounts are required to upload; the admin creates them with one-time links (no email needed) or opens registration for a while. Captures expire after 30 days by default; owners can extend to a year, admins can keep forever.
You need a Linux box with Docker + Compose v2, a hostname in a Cloudflare DNS zone, and one inbound port open in the firewall: 28443 (TCP and UDP) — nothing on 80 or 443, since certificates come from Let's Encrypt via the DNS-01 challenge and never need an inbound connection. The port is conflict avoidance, not a defense: scanners sweep every port, and what protects captures is the 160-bit link plus the guard (PLAN.md §6, §12). Node is only needed on the machine that builds the extension (below).
-
DNS + API token. Create an A/AAAA record for your hostname (say
shots.example.com) pointing at the box, DNS-only (grey cloud) — Cloudflare's proxy neither forwards port 28443 nor coexists with the origin's own certificate. Wait untildig +short shots.example.comanswers from outside. Then create a Cloudflare API token scoped to that one zone with Zone → Zone → Read and Zone → DNS → Edit (both are required; step-by-step in deploy/README.md "TLS"). -
Configure.
git clone <this repository> snapping-turtle && cd snapping-turtle cp deploy/.env.example deploy/.env $EDITOR deploy/.env
Set at least:
PUBLIC_HOST=shots.example.com;PUBLIC_PORT=28443(the one port you opened);CLOUDFLARE_API_TOKEN(the token from step 1 — it lives only in this git-ignored file and Caddy's environment);POSTGRES_PASSWORDandAPP_DB_PASSWORD(openssl rand -hex 24each);SESSION_SECRET(openssl rand -base64 48);ADMIN_BOOTSTRAP_USER/_PASSWORDfor the first admin (≥ 12 chars); andEXTENSION_GECKO_ID=snapping-turtle@shots.example.com(the Firefox add-on id — pick it once, never change it). Every other variable has a sane default and is documented in the file. Every URL the service mints carries the port (https://shots.example.com:28443/…). -
Start.
docker compose -f deploy/docker-compose.yml up -d --build docker compose -f deploy/docker-compose.yml ps # wait for app: healthy; caddy publishes 28443 only docker compose -f deploy/docker-compose.yml logs caddy | grep 'certificate obtained' curl -sSI https://shots.example.com:28443/login | head -1 # HTTP/2 200
Caddy obtains the Let's Encrypt certificate at startup through DNS-01 (a temporary
_acme-challengeTXT record; typically under a minute); migrations run when the app starts. The nightly backup sidecar starts too (see day-2 below). -
Seed the admin (once), then drop the bootstrap variables from
.env:docker compose -f deploy/docker-compose.yml run --rm app node dist/db/seed.js
-
Get an API token. Sign in at
https://shots.example.com:28443/login, open Account, create a token. It is shown once; the page also prints a ready-to-pastecurlline if you want to test the upload path without the extension. -
Install the extension. Two ways:
-
Published builds (after you have submitted them — extension/STORE_SUBMISSION.md): open
https://shots.example.com:28443/— the home page offers Install for Firefox (a stable link,/ext/firefox-latest, that always resolves to the newest signed.xpi; Firefox then updates itself from/ext/updates.json) and Install for Chrome onceCHROME_EXTENSION_URLindeploy/.envpoints at the unlisted Web Store listing. -
Your own build, on a machine with Node 22 + pnpm (
corepack enable):pnpm install pnpm --filter extension build:release # reads deploy/.env; audits the artifactsChrome:
chrome://extensions→ Developer mode → Load unpacked →extension/dist/chrome/. Firefox:about:debugging#/runtime/this-firefox→ Load Temporary Add-on →extension/dist/firefox/manifest.json(temporary add-ons vanish on restart; the signed.xpiis the permanent route).
Then: toolbar icon → Settings → paste the token → Test connection → Save. Firefox asks for host permission on the first save; Chrome only if you enter a server other than the built-in default.
Linux desktop (Fedora 44 / KDE Plasma): install the client RPM from the GitHub release (or build it with
client-linux/scripts/package-rpm.sh— setCLIENT_APP_IDindeploy/.envfirst, once, and never change it), thensnapping-turtle --configurewith a token and launch snapping-turtle from the app menu. Details in "Linux client" below. -
-
First capture. On any normal web page click the toolbar icon → Visible (or
Alt+Shift+S). The capture page opens in a new tab: draw a rectangle, an arrow, some text — it autosaves. Copy page link shares the annotated view; Copy image link shares the flat PNG. Open the page link in a private window to see what recipients see. Region (Alt+Shift+R) and Full page (Alt+Shift+F) work the same way.
A native, tray-resident capture client for Linux desktops — primary target Fedora 44 with KDE Plasma on Wayland — lives in client-linux/ (Rust; the one non-TypeScript component, rationale in CLAUDE.md).
-
Install the RPM attached to the release, or build it:
client-linux/scripts/package-rpm.sh # needs cargo, gcc, rpm-build; reads deploy/.env sudo dnf install client-linux/dist/snapping-turtle-*.rpm
-
Configure with a token from your Account page (stored in the KWallet / Secret Service keyring, or a 0600 file when there is none); it also asks whether to start at login:
snapping-turtle --configure
-
Capture from the tray menu, the launcher entry's desktop actions, or the global shortcuts Plasma asks you to approve (Meta+Alt+S full screen, Meta+Alt+W window, Meta+Alt+R region). Full screen and window go through KWin's ScreenShot2 (no dialogs); region opens the desktop's own chooser — on Plasma 6.7 that chooser has no rectangle option yet, which client-linux/README.md documents per mode; the practical answer there is a full-screen capture followed by Crop on the capture page (non-destructive, the shared image is trimmed to the rectangle at its own size). The upload has no source page, so the capture page shows no "Open original page" link; everything else is the same page.
Disable autostart with snapping-turtle --autostart off. The manual
checklist for a real desktop session is
client-linux/TESTING.md.
Registration stays closed by default; the admin hands out accounts:
https://shots.example.com:28443/admin→ Users → Create user → enter a username → copy the one-time set-password link (valid 24 h, usable once; you never see or choose their password).- Send the link over any channel. They open it, set a password and are signed in.
- They create their API token on Account and paste it into the extension's settings (with your server address, if they installed a build made for a different default).
Forgot password → Reset password on their row issues the same kind of link. Disable revokes their sessions and tokens in one step. Every one of these actions lands in the audit log. To let people sign up themselves for a while, flip Registration on the admin page (also audited) and off again.
| Task | Where |
|---|---|
Backups (nightly pg_dump + image snapshots, optional restic off-box) and restore |
deploy/README.md — run deploy/backup/verify-restore.sh after the first night |
| Someone banned themselves testing links; see bans and breaker state; unban | /admin → Guard |
What the app is telling you about attacks and admin actions (sec.* log lines) |
docs/security-events.md; docker compose -f deploy/docker-compose.yml logs app | grep '"tag":"sec.' |
| Prove the guard still trips under load | pnpm loadtest — docs/loadtest.md |
| Upgrade the app | git pull && docker compose -f deploy/docker-compose.yml up -d --build — patch bumps of the Postgres pin flow through Dependabot; majors follow the runbook in deploy/README.md |
| Move to a new domain | docs/runbooks/domain-migration.md — rehearse with deploy/test-domain-migration.sh first |
| TLS: DNS-01, the single port, rotating the Cloudflare token, swapping providers | deploy/README.md "TLS" |
| Ship an extension update | bump extension/package.json, build:release, then Web Store upload / sign:firefox — extension/STORE_SUBMISSION.md |
| Dependency and image scanning | docs/supply-chain.md (CI: pnpm audit, Trivy, Dependabot) |
pnpm install # Node 22 (.nvmrc) + pnpm via corepack
cp deploy/.env.example deploy/.env # compose and pnpm dev read the same file
docker run -d --name st-pg -e POSTGRES_USER=app -e POSTGRES_PASSWORD=devpassword \
-e POSTGRES_DB=snapping_turtle -p 5432:5432 postgres:16.15-alpine
# in deploy/.env: DATABASE_URL=postgres://app:devpassword@localhost:5432/snapping_turtle
# PUBLIC_ORIGIN=http://localhost:3000 (overrides the https://$PUBLIC_HOST derivation)
pnpm --filter server db:migrate
pnpm --filter server db:seed # ADMIN_BOOTSTRAP_* from deploy/.env
pnpm dev # http://localhost:3000, web/shared watch builds
PUBLIC_ORIGIN=http://localhost:3000 pnpm --filter extension build # dev builds pointing at itPlain http:// is accepted only for localhost and only as the build-time
default. For the full stack locally, PUBLIC_HOST=localhost plus the
docker-compose.local.yml override gives you https://localhost:28443
under Caddy's internal CA, no DNS token needed — see deploy/README.md. The
Playwright suites need Chromium once:
pnpm --filter web exec playwright install chromium.
Uploading from a terminal, if you want to see the wire contract:
curl -sS -X POST http://localhost:3000/api/v1/captures \
-H "Authorization: Bearer st_XXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-F "image=@screenshot.png" -F "sourceUrl=https://example.com/page" -F "title=Example page"
# → {"pageUrl":"http://localhost:3000/s/<27-char id>","imageUrl":".../image.png"}Only the bearer token authenticates uploads; sessions are deliberately not accepted there. Uploads are sniffed by magic bytes (PNG/JPEG), decoded under pixel and dimension caps and re-encoded — stored files never contain the uploaded bytes.
| Command | What it does |
|---|---|
pnpm dev |
server watch + web/shared watch builds |
pnpm build |
all packages (shared → server, web, extension) |
pnpm test |
unit tests, one vitest project per package |
pnpm test:integration |
server against a real Postgres (DATABASE_URL): API, authz matrix, guard, purge, links |
pnpm test:parity |
Playwright: pages under production CSP; editor ↔ server render goldens |
pnpm lint && pnpm typecheck |
must pass before commit |
pnpm --filter extension build:chrome / build:firefox |
dev builds → extension/dist/ |
pnpm --filter extension build:release |
audited production builds of both targets (needs PUBLIC_HOST+PUBLIC_PORT/PUBLIC_ORIGIN + EXTENSION_GECKO_ID) |
pnpm --filter extension sign:firefox |
AMO signing (env credentials) → deploy/ext/ .xpi + updates.json; --xpi <file> publishes a pre-signed file |
pnpm --filter extension test:smoke |
Playwright: overlay/driver fixtures + the built Chrome extension (build:chrome first) |
cd client-linux && cargo fmt --check && cargo clippy --all-targets --locked -- -D warnings && cargo test --locked && cargo build --release --locked |
the Linux client's contract (CI runs it in a fedora:44 container on the toolchain client-linux/rust-toolchain.toml pins) |
client-linux/scripts/toolchain.sh |
rustup (installed if absent) + the pinned toolchain — the only compiler install path for CI and container rehearsals |
client-linux/scripts/package-rpm.sh |
release binary + RPM → client-linux/dist/ (CLIENT_APP_ID, PUBLIC_HOST+PUBLIC_PORT from deploy/.env) |
DATABASE_URL=… client-linux/scripts/integration.sh |
real server + the client binary's upload path + row/page assertions (CI) |
pnpm --filter server db:generate / db:migrate / db:seed |
new migration / apply / bootstrap admin |
docker compose -f deploy/docker-compose.yml up -d --build |
the stack |
docker compose -f deploy/docker-compose.yml run --rm backup run |
ad-hoc backup |
deploy/backup/verify-restore.sh |
prove the latest backup restores |
deploy/test-domain-migration.sh |
rehearse a domain migration on a throwaway stack |
pnpm loadtest [ban|breaker|baseline] |
k6 guard scenarios (dedicated compose project) |
scripts/check-image-pins.sh |
every Postgres image tag equals the compose pin (CI) |
CI (.github/workflows/ci.yml) runs the lot on every push and PR plus
pnpm audit and Trivy scans of the three images; a v* tag runs
.github/workflows/release.yml, which builds both extension zips and attaches
them to a GitHub release (signing stays local, credentials never enter CI).
shared/ annotation schema (TypeBox), API types, constants — source of truth
server/ Fastify app, Drizzle + Postgres, sharp flat renderer, guard, purge job
web/ Vite bundles served by server/: capture page + editor, auth, account, admin
extension/ MV3 codebase → chrome + firefox builds; STORE_SUBMISSION.md, TESTING.md
client-linux/ Rust tray client (portal + KWin capture, upload, RPM); README.md per-mode findings, TESTING.md
deploy/ compose (+ local/loadtest/migration overrides), Caddyfiles, Dockerfiles, backup/, caddy.d/, ext/
loadtest/ k6 guard scenarios
scripts/ check-image-pins.sh
docs/ index in docs/README.md
Not chosen yet — the owner's decision. Until a LICENSE file exists, all
rights are reserved by default.