commit b9721ff13f5d95985fdb11f0393afc715d97a5c2 Author: Andrew S Erwin Date: Mon Oct 19 16:23:55 2026 -0500 initial commit on Federted servers diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..471df51 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.env* +web +db +*.log +# Go build context needs only go.mod, go.sum, main.go and internal/. +scripts +*.md +Makefile +docker-compose* +.dockerignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..13e92c9 --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# docker compose reads this file for every command (even db-up/migrate), +# because AUTH_SECRET and POSTGRES_PASSWORD use fail-fast `:?` interpolation. +POSTGRES_PASSWORD=computer +# See RUNNING.md to generate this with Docker. Keep stable for IMAP passwords. +AUTH_SECRET=replace-with-a-random-secret +NEXTAUTH_URL=http://localhost:3000 +# Optional: cookie Secure flag. Defaults to the NEXTAUTH_URL scheme +# (https => secure); set explicitly only to override that derivation. +# COOKIE_SECURE=1 +# Reserved administrator emails; provision them using the operator seed in README.md. +ADMIN_EMAILS= +# Keep these defaults on a new install; change only to reuse an existing volume. +POSTGRES_VOLUME=computer_pgdata +POSTGRES_VOLUME_EXTERNAL=false +# Host-side ports for the 127.0.0.1 bindings of app/web. Change on servers +# where 8080/3000 are already used (scripts/server-deploy.sh pins 18080/13000). +# If you shift WEB_HOST_PORT locally, set NEXTAUTH_URL to the same +# browser-facing origin (e.g. http://localhost:3100): the API proxy's origin +# allowlist matches browser Origin against NEXTAUTH_URL, so a mismatch makes +# every mutating request fail with 403 "Invalid request origin". +#APP_HOST_PORT=8080 +#WEB_HOST_PORT=3000 +# Client-owned Authelia integration remains stubbed until connected. +OIDC_ISSUER=REPLACE_ME +OIDC_CLIENT_ID=REPLACE_ME +OIDC_CLIENT_SECRET=REPLACE_ME + +# --- Optional, not needed for the Compose stack --- +# Go API listen port (default 8080). +#PORT=8080 +# Database URL fallback used by the Makefile when DATABASE_URL is unset. +#DB_URL=postgres://computer:computer@localhost:5432/computer?sslmode=disable +# Host-level integration tests (normally run via `make integration` instead): +#TEST_DATABASE_URL=postgres://computer:computer@localhost:5432/computer?sslmode=disable +# Browser tests (scripts/e2e.mjs): +#BASE_URL=http://localhost:3000 +#CHROMIUM_PATH=/usr/bin/chromium +# E2E_KEEP_DATA=1 keeps the QA account instead of deleting it. +# Allows CardDAV/IMAP/preview dials to private ranges, and relaxes CardDAV's +# https requirement (Basic-auth credentials never travel plaintext otherwise). +# Test fakes and deliberate LAN deployments only — never set it in production +# Compose. +#INTEGRATIONS_ALLOW_PRIVATE=1 diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..5d3a34a --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,82 @@ +# Release gate automation (updated code review, work-order item 6): every +# push/PR runs the same validation `make check` enforces locally, plus the +# real-stack browser smoke tests (`make e2e`) against the composed stack. +name: release gate + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: check-${{ github.ref }} + cancel-in-progress: true + +env: + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: corepack enable + + # Compose fails fast without .env (POSTGRES_PASSWORD/AUTH_SECRET use :? + # interpolation). CI generates a throwaway one; it is never committed. + - name: Write CI .env + run: | + { + echo "POSTGRES_PASSWORD=ci-only-$(openssl rand -hex 8)" + echo "AUTH_SECRET=$(openssl rand -hex 32)" + echo "POSTGRES_VOLUME=computer_pgdata" + } > .env + + - name: Install web dependencies + working-directory: web + run: corepack yarn install --frozen-lockfile + + - name: Install browser-test dependencies and chromium + working-directory: scripts + run: | + npm ci + npx playwright install --with-deps chromium + echo "CHROMIUM_PATH=$(node -p "require('playwright').chromium.executablePath()")" >> "$GITHUB_ENV" + + # build + vet + DB integration + schema convergence/migration atomicity + # + web lint/SSR-lint/build + browser regressions. + - name: make check + run: make check YARN="corepack yarn" + + - name: Start the composed stack + run: make run + + - name: Wait until app and web are healthy + run: | + for _ in $(seq 1 100); do + app=$(docker inspect -f '{{.State.Health.Status}}' "$(docker compose ps -q app)") + web=$(docker inspect -f '{{.State.Health.Status}}' "$(docker compose ps -q web)") + if [ "$app" = healthy ] && [ "$web" = healthy ]; then exit 0; fi + sleep 3 + done + echo "stack did not become healthy" >&2 + docker compose ps >&2 + exit 1 + + - name: Browser smoke tests against the real stack + run: make e2e + + - name: Collect logs on failure + if: failure() + run: | + docker compose ps || true + docker compose logs --tail=200 app web migrate || true diff --git a/API.md b/API.md new file mode 100644 index 0000000..611c48a --- /dev/null +++ b/API.md @@ -0,0 +1,207 @@ +# API reference + +Go API: `http://localhost:8080`. The web UI uses the same paths through its +server-side proxy at `http://localhost:3000` and its NextAuth session. + +Successful responses are JSON unless marked Markdown. Errors are +`{"error":"message"}`. Data endpoints require authentication: **401** without +a valid session, **404** for another user's private record, **403** for +non-admin administration, **400** for invalid input, **409** for duplicates. + +## Authentication + +| Method | Path | Returns | +|---|---|---| +| GET | `/healthz` | `{"status":"ok"}` (process liveness) | +| GET | `/readyz` | `{"status":"ready"}` after database ping; 503 when unavailable | +| POST | `/api/signup` | 201 `{ok:true}` — the same uniform body for fresh, duplicate and operator-reserved addresses (review CR-03: no existence oracle); body `{email,password,name?}` | +| POST | `/api/auth/login` | `{ok:true,user:{id,name,email,image,role}}` and signed HttpOnly cookie | +| GET | `/api/auth/session` | `{user}` or `null` for no valid cookie | +| POST | `/api/auth/signout` | `{ok:true}` and expired cookie | +| GET | `/api/me` | `{user,effectiveUser,isAdmin,viewingAs}`; real and effective identities | +| GET | `/api/auth/oidc/status` | `{enabled,issuerSet,clientIdSet,clientSecretSet,issuer}`; never includes secrets | +| GET | `/api/auth/oidc/login` | **501**, client-owned Authelia integration stub | +| GET, POST | `/api/auth/callback/authelia` | **501**, client-owned callback stub | + +The browser's NextAuth `/api/auth/*` endpoints remain on the Next server. +Direct Go API consumers use the `computer_session` cookie from login. +Go login accepts JSON and rejects foreign browser origins. The custom Next.js +login endpoint also requires the configured same-origin `Origin` header. +Login and signup are throttled per client IP (429 with `Retry-After`), plus a +consecutive-failure lockout per IP+email on login; loopback clients are +exempt. Browser traffic arrives through the trusted Next proxy, which +forwards the observed client IP in `X-Computer-Client-Ip` signed with +HMAC-SHA256 under the shared secret (`X-Computer-Client-Sig`); Go buckets on +that identity only when the signature verifies and falls back to the direct +peer otherwise, so proxied browsers neither share one bucket nor forge +another identity. +`ADMIN_EMAILS` reserves administrator addresses for operator provisioning and +disables public first-admin bootstrap while set. Automatic promotion of an +existing allowlisted user requires `emailVerified`; DB administrator roles +remain authoritative. First-signup administrator promotion is one-shot: once +bootstrap completion is recorded (AppSetting `adminBootstrap`), the absence +of a qualifying administrator — e.g. after demoting the last one — never +re-opens promotion through signup. Recovery is operator-driven +(`ADMIN_EMAILS` + `make seed`, or promotion by an existing admin). + +## Bookmarks + +Bookmark responses are permission-specific. Owners receive camelCase scalar +fields plus `tags:[{tag}]`, `contact`, `sharedWith:[{userId,user}]` and +`owner` (with email). Authenticated shared readers receive a narrowed +allowlist — URL, title/description, notes, preview metadata, `dueDate`, +`isPublic`, `kanbanStatus`, timestamps, `tags` and `owner:{id,name}` — never +the owner's contact record, the recipient list, alerts, mailbox watches or +board placement. Detail responses also include `pageLinks:[{pageId,page}]`, +limited to the effective user's pages. + +| Method | Path | Returns / behavior | +|---|---|---| +| GET | `/api/bookmarks` | Array, newest-updated then ID descending; query `search,tagId,contactId,kanban,date,shared,limit,cursor`; default/max page size 200 | +| POST | `/api/bookmarks` | 201 bookmark; body `{url,title?,description?,notes?,dueDate?,alertAt?,kanbanStatus?,contactId?,tagIds?,isPublic?,imapWatchEnabled?,imapQuery?}` | +| GET | `/api/bookmarks/{id}` | Owner-full or shared-reader projection if visible; public-only access uses the redacted public endpoint | +| PUT | `/api/bookmarks/{id}` | Updated bookmark, owner only; fields above plus `kanbanColumnId`; omitted fields stay unchanged; nullable fields can be cleared. **Requires `version`** (the revision the client edited from): 400 when missing, 409 when stale — success bumps `version` (review CR-04) | +| DELETE | `/api/bookmarks/{id}` | `{ok:true}`, owner only | +| POST | `/api/bookmarks/{id}/share` | `{ok:true}`; body `{userIds?,isPublic?}`; adds shares | +| DELETE | `/api/bookmarks/{id}/share/{userId}` | `{ok:true}`; revokes that direct share | +| GET | `/api/bookmarks/{id}/markdown` | Markdown attachment, owned/shared only | +| GET | `/api/preview?url=...` | `{title,description,ogTitle,ogDescription,ogImage,favicon}`; nullable on fetch failure | +| GET | `/api/public/bookmarks/{id}` | Public bookmark projection without login; private records return 404 | + +Public HTML pages remain at `/share/bookmark/{id}` on the Next.js server. +For pagination, read the `X-Next-Cursor` response header and pass that opaque +value as `cursor` with the same filters. An absent header marks the final page. +`limit` must be between 1 and 200. The web proxy forwards the cursor header. +Public responses allowlist URL/preview/notes/date/tag fields only; owner +identity, contacts, share recipients, mailbox queries, and alert metadata are +excluded. +Updating `alertAt` resets `alertSent`; replacing `tagIds` is transactional. +Tags, columns and contacts are checked for accessibility/ownership. +The `version` contract covers the owner-driven PUT (fields and tag +replacement) and kanban card moves (`PUT /api/kanban/cards/{id}`): kanban +column deletion moves bookmarks server-side and bumps their `version` too, so +a stale board save 409s instead of moving a card back into a deleted column. +Sharing (`POST /share`) and the server-driven `alertSent` flag are deliberately +outside the contract (review CR-04). + +Input limits are enforced per field, not just by the 1 MiB request-body cap: +bookmark `url` 2048, `title` 500, `description` 2000, `notes` 100000 and +`imapQuery` 500 characters; tag names 80; page `content` 524288 characters and +`icon` 64; collection names/properties/views and item values have their own +documented bounds. Caps on these fields reject over-limit input with 400; a +few older metadata fields (page titles, kanban labels, collection +names/options) still trim to their existing limits. + +Outbound preview fetches are restricted to HTTP/HTTPS ports 80/443 and mailbox +connections to IMAP ports 143/993 (the fixture/LAN escape hatches are +test-only). CardDAV keeps the configured HTTPS port. + +## Tags, kanban and pages + +| Method | Path | Returns / behavior | +|---|---|---| +| GET | `/api/tags` | Owned/shared tags with `_count.bookmarks` and `sharedWith` | +| POST | `/api/tags` | 201 tag; body `{name,color?}` | +| PUT | `/api/tags/{id}` | Updated tag; body `{name?,color?,shareUserIds?}`, owner only | +| DELETE | `/api/tags/{id}` | `{ok:true}` | +| DELETE | `/api/tags/{id}/share/{userId}` | `{ok:true}`, revokes tag share | +| GET | `/api/kanban/columns` | Ordered columns; creates four defaults if none exist | +| POST | `/api/kanban/columns` | 201 column; body `{label,color?}` | +| PUT | `/api/kanban/columns` | Ordered columns; body `{order:[id,...]}`, complete permutation required | +| PATCH | `/api/kanban/columns/{id}` | Updated column; body `{label?,color?}` | +| DELETE | `/api/kanban/columns/{id}` | `{ok:true,movedTo,moved}`; moves cards, never deletes bookmarks; refuses last column | +| PUT | `/api/kanban/cards/{id}` | Moved bookmark; body `{columnId,position,version}`. Reorders the target column (0-based position, clamped at the end) and normalizes every card's `kanbanPosition`. Owner only. `version` is REQUIRED: 400 when missing/invalid, 409 when stale; success bumps the moved card's `version` | +| GET | `/api/pages?search=...` | Pages, pinned first, with `_count.bookmarks`; title/content substring search treats `%` and `_` literally and uses trigram indexes | +| POST | `/api/pages` | 201 page; body `{title?,content?}` | +| PUT | `/api/pages` | `{ok:true}`; complete `{order:[id,...]}` | +| GET | `/api/pages/{id}` | Page plus ordered `bookmarks:[{bookmark}]` | +| PATCH | `/api/pages/{id}` | Updated page; `{title?,content?,pinned?,icon?,version?,addBookmarkId?,removeBookmarkId?}`. `version` (the base revision from GET) is REQUIRED with `content`: 400 when missing, 409 when stale. Metadata-only patches stay version-free and do not bump the revision | +| DELETE | `/api/pages/{id}` | `{ok:true}`; keeps bookmarks | +| GET | `/api/pages/{id}/markdown` | Markdown attachment | + +## Collections + +Contextual workflows over existing bookmarks (phase 2 of the Notion-like +upgrades). Collections are owner-scoped; membership references bookmarks +without owning them — removing an item or a whole collection never touches +the bookmark. Membership never broadens access: items whose bookmark the +caller cannot see are omitted from lists/counts and behave as not-found for +mutations; re-sharing restores the membership. Every mutating PATCH requires +the expected `version` (400 missing, 409 stale). Indirect mutations advance +revisions too (review CR-09): item reorder bumps every affected item's +version, and property deletion bumps the versions of items whose value it +dropped and views whose filter/sort definition it changed. Deletions are +idempotent removals and take no revision — they never write state computed +from a client snapshot. + +| Method | Path | Returns / behavior | +|---|---|---| +| GET | `/api/collections` | Owned collections with `_count.{items,properties,views}`; item counts include only bookmarks the caller can see | +| POST | `/api/collections` | 201 detail `{...collection,properties:[],views:[default Table view]}`; body `{name,description?,icon?}` | +| GET | `/api/collections/{id}` | Collection plus ordered `properties`, `views` and authorized `_count.{items,properties,views}` | +| PATCH | `/api/collections/{id}` | Updated collection; `{version,name?,description?,icon?}`, bumps `version` | +| DELETE | `/api/collections/{id}` | `{ok:true}`; memberships/properties/views cascade, bookmarks kept | +| POST | `/api/collections/{id}/properties` | 201 property; `{name,type,config?}`. Types: `TEXT,NUMBER,SELECT,MULTI_SELECT,DATE,CHECKBOX,URL` (`RELATION` reserved, rejected). SELECT config `{options:[{label,color?}]}` — the server assigns stable option ids; DATE config `{mode:"date"\|"datetime"}`. Duplicate name → 409 | +| PATCH | `/api/collections/{id}/properties/{propertyId}` | Updated property; `{version,name?,position?,config?}`. Type and date-mode changes are rejected (400) — conversion must never destroy values. Option updates send the full list: existing ids rename/recolor (identity preserved), omitted ids are removed but refused (400 with the using-item count) while any item still carries them | +| DELETE | `/api/collections/{id}/properties/{propertyId}` | `{ok:true}`; strips the property's values from all items and its clauses/sorts/grouping from saved views | +| GET | `/api/collections/{id}/items?viewId=&cursor=&limit=` | Items `{...item,bookmark}` (bookmark uses the permission-specific projection), visible bookmarks only, ≤`limit` (default/max 200) with `X-Next-Cursor`. With `viewId`: the view's filters (AND/OR over typed operator allowlists) and its FIRST sort key run server-side; ordering always tie-breaks on item id. Without `viewId`: canonical manual order (`position,id`) | +| POST | `/api/collections/{id}/items` | 201 item; `{bookmarkId,propertyValues?}`. 400 if the bookmark is not currently accessible; duplicate membership → 409. Values are validated per property type; DATE values store canonically (`YYYY-MM-DD` or fixed-width UTC RFC3339) so comparisons never shift across timezones | +| PUT | `/api/collections/{id}/items` | `{ok:true}`; complete `{order:[itemId,...]}` permutation | +| PATCH | `/api/collections/{id}/items/{itemId}` | Updated item; `{version,propertyValues?,position?}`. `propertyValues` merges: omitted keys untouched, `null` removes a value, unknown property ids 400 | +| DELETE | `/api/collections/{id}/items/{itemId}` | `{ok:true}`; membership only, bookmark untouched | +| POST | `/api/collections/{id}/views` | 201 view; `{name,type?,filters?,sorts?,groupByPropertyId?,displaySettings?}`, types `TABLE,BOARD,CALENDAR,LIST` (default TABLE) | +| PATCH | `/api/collections/{id}/views/{viewId}` | Updated view; `{version,name?,type?,position?,filters?,sorts?,groupByPropertyId?,displaySettings?}` | +| DELETE | `/api/collections/{id}/views/{viewId}` | `{ok:true}` | + +Filter shape: `{operator:"AND"|"OR",clauses:[{propertyId,op,value?}]}` (max 20 +clauses; no nested groups). Operator allowlists per type: TEXT/URL +`eq,ne,contains,empty,notEmpty`; NUMBER/DATE `eq,ne,gt,gte,lt,lte,empty, +notEmpty`; SELECT/CHECKBOX `eq,ne,empty,notEmpty`; MULTI_SELECT `contains, +empty,notEmpty`. Sorts: max 3 `{field,dir}` where field is `position`, +`addedAt`, `title` or a sortable property id (not multi-select); only the +first drives server-side ordering/pagination in this phase. `ne` matches +missing values (`IS DISTINCT FROM`). Property ids and values are bind +parameters end to end — filter JSON never reaches SQL text. Collection +sharing does not exist yet; views/counts can only expose what the owner can +already see. + +## Signals and integrations + +| Method | Path | Returns / behavior | +|---|---|---| +| GET | `/api/bookmarks/{id}/signals` | `{sources,signals,types}`, newest 50 events | +| POST | `/api/bookmarks/{id}/signals` | 201 watcher for `{type:"email",config:{from?,subject?}}`; `{checked,created}` for `{action:"check"}` | +| PATCH | `/api/signal-sources/{id}` | Updated watcher for `{enabled:boolean}` | +| DELETE | `/api/signal-sources/{id}` | `{ok:true}` | +| GET | `/api/alerts/check` | Undismissed bookmarks due within 15 minutes, soonest first | +| DELETE | `/api/alerts/{bookmarkId}` | `{ok:true}`; sets `alertSent` | +| GET | `/api/contacts` | Synced contacts with `_count.bookmarks` | +| POST | `/api/contacts/sync` | `{synced,removed}`; `{nextcloudUrl,username,password,cardDavPath?}`. Mirrors the configured address book: contacts previously imported from the SAME source URL that the server no longer returns are removed (`removed`), but only on a complete, non-empty snapshot — partial or empty responses never delete. Manually created contacts and other address books' imports are untouched; bookmarks keep working (contactId is SET NULL) | +| GET | `/api/settings/imap` | Configuration with masked password, or `null` | +| POST | `/api/settings/imap` | `{ok:true}`; `{host,port?,tls?,username,password,folder?}`; empty/masked password preserves an existing password. `port` must be 143 or 993, and TLS is required outside the test/LAN flags | +| POST | `/api/imap/test` | `{ok:true}` after real connection/login; same mailbox fields and port rules | +| GET | `/api/imap/check` | `{matches:[{bookmarkId,title,from,externalId,occurredAt}]}` for legacy watchers; if a search fails after finding matches, the partial `matches` are returned with an `error` field | +| GET | `/api/shared-dates` | Created/received dates with `creator` and `recipient` | +| POST | `/api/shared-dates` | 201 array of created shares for `{date,note?,recipientIds:[...]}` | +| DELETE | `/api/shared-dates/{id}` | `{ok:true}`, creator only | +| GET | `/api/users/search?q=...` | Safe user projections; minimum two characters, maximum ten results | + +Email signals are deduplicated by source and Message-ID. Failed mailbox checks +are recorded on `lastStatus` rather than reported as successful email matches; +messages fetched before a partial failure are still persisted (the checkpoint +does not advance, so the failed batch is retried and deduplicated). +Sharing a tag or date exposes associated bookmarks read-only; direct-share +revocation does not remove access independently granted by a tag/date share. + +## Administration + +Always uses the real user, even while viewing another account. + +| Method | Path | Returns / behavior | +|---|---|---| +| GET | `/api/admin/users` | Safe user projections with bookmark/page/tag counts; excludes `@example.com` test users | +| PATCH | `/api/admin/users` | `{ok:true}` for `{userId,role:"USER"\|"ADMIN"}`; refuses self-demotion | +| POST | `/api/admin/view-as` | `{ok:true}`; `{userId}` sets an eight-hour HttpOnly cookie, `{userId:null}` clears it | + +Route registration: `internal/http/server.go`. Only the client-owned OIDC +login/callback remain stubs; application data routes use PostgreSQL. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9665110 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Pinned patch level; keep in lockstep with the tests service in docker-compose.yml. +FROM golang:1.26.1-alpine3.22 AS build +RUN apk add --no-cache ca-certificates tzdata +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY main.go ./ +COPY internal ./internal +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/computer . + +FROM scratch +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=build /out/computer /computer +USER 65532:65532 +EXPOSE 8080 +ENTRYPOINT ["/computer"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2f25b57 --- /dev/null +++ b/Makefile @@ -0,0 +1,66 @@ +.PHONY: run docker stop docker-down db-up db-shell build vet test integration db-converge check audit e2e logs migrate seed deploy +run docker: + docker compose up -d --build +stop docker-down: + docker compose down +db-up: + docker compose up -d db +db-shell: + docker compose exec db psql -U computer -d computer +build: + go build ./internal/... . +vet: + go vet ./internal/... . +test: + @if [ -z "$$TEST_DATABASE_URL" ]; then \ + echo "warning: TEST_DATABASE_URL is unset - PostgreSQL integration tests will SKIP."; \ + echo " run 'make integration' for full coverage."; \ + fi + go test -race ./internal/... +integration: + docker compose run --rm tests +# schema.sql <-> migration-chain convergence + runner atomicity proofs +# (review MIG-03): fails when the two paths to the same schema drift apart +# or when apply/record atomicity regresses. Scratch databases only. +db-converge: + docker compose run --rm converge + docker compose run --rm converge sh /db/migrate-atomicity.sh +# Web toolchain command; override when yarn comes through corepack: +# make check YARN="corepack yarn" +YARN ?= yarn +# Release validation (review TEST-02): the database-backed suite is +# mandatory here — unlike `make test`, this target fails when Docker (and +# therefore the PostgreSQL integration coverage) is unavailable instead of +# quietly accepting skipped tests. +check: + @docker compose ps >/dev/null 2>&1 || { echo "error: release checks require docker; database-backed coverage is mandatory" >&2; exit 1; } + $(MAKE) build + $(MAKE) vet + $(MAKE) integration + $(MAKE) db-converge + cd web && $(YARN) lint && $(YARN) eslint -c eslint.ssr.config.mjs . && $(YARN) build + @[ -d scripts/node_modules ] || npm --prefix scripts ci + npm --prefix scripts run test:regressions + @echo "release check complete: vet + database integration + schema convergence + web lint/build + browser regressions" + @echo "note: 'make e2e' (real-stack browser smoke tests) is a separate step; run it against the composed stack before release." +# Dependency advisory scanning (review "Dependency assessment boundary"): +# opt-in because it needs network access and the Go vuln tool installed +# (go install golang.org/x/vuln/cmd/govulncheck@latest). +audit: + @command -v govulncheck >/dev/null 2>&1 || { echo "error: govulncheck not installed: go install golang.org/x/vuln/cmd/govulncheck@latest" >&2; exit 1; } + govulncheck ./internal/... . + cd web && $(YARN) audit + npm --prefix scripts audit +e2e: + @[ -d scripts/node_modules ] || npm --prefix scripts ci + npm --prefix scripts test +logs: + docker compose logs -f app web +migrate: + docker compose run --rm migrate +seed: + docker compose exec -e SEED_EMAIL -e SEED_PASSWORD -e SEED_NAME app /computer --seed +# Target-agnostic server deploy; requires HOST, DOMAIN and CERTBOT_EMAIL +# (no defaults). See README "Deploying to a server". +deploy: + ./scripts/deploy.sh diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..49b9e80 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,196 @@ +# Computer — Where We Stand and What's Next + +A plain-language look at the app: what it does today, what it doesn't do +yet, and what we can build over the next 3–6 months. + +*Written October 2026. The big assumption in this plan: **Authelia** (a +self-hosted login server) becomes the way people sign in, and the app stops +keeping its own passwords.* + +--- + +## What we have today + +**Saving and finding links** + +- Save any URL with a title, notes, a due date, and tags. +- Saved links show a preview (page title, description, small icon) so lists + are easy to scan. +- Search and filters: by text, tag, contact, board column, date, or what's + shared with you. +- Export any bookmark as a Markdown file. + +**Writing** + +- Pages: Markdown documents with a `/` menu (headings, to-do lists, quotes, + code) and checkbox shortcuts. +- Autosave that is safe: if two edits collide, the app stops and asks what + to do. It never silently throws away writing. +- Pin pages, reorder them, link bookmarks into them. + +**Projects and views** + +- Collections: Notion-style projects where the rows are your bookmarks. + Add columns (text, number, select, date, checkbox, URL), save filtered + table views, or use a drag-and-drop board view. +- A kanban board over all bookmarks, with custom columns. +- A calendar of due dates, including dates teammates shared with you. +- Ctrl/Cmd+K quick-find jumps to anything. + +**Sharing** + +- Share one bookmark, a whole tag, or a calendar date. +- Public links: publish a bookmark to anyone with the URL. The app tells you + exactly what becomes public before you turn it on. +- Shared people see a trimmed version — your contacts, alerts, and mail + settings stay private. + +**Email and contacts** + +- Email watchers: connect a mailbox and get a signal on a bookmark when + mail arrives from a sender or matching a subject. Mail passwords are + encrypted; connections must use proper TLS. +- Due-date alerts for bookmarks coming due in the next 15 minutes. +- Contact sync from Nextcloud (CardDAV); attach people to bookmarks. + +**Admin and safety** + +- Multi-user with two roles (USER, ADMIN). +- Admins can "view as" another user — read-only, time-limited, and logged. +- Guard rails: you can't demote yourself or the last admin; the first + signup becomes admin once, and open registration can be turned off. +- Signing out really signs you out everywhere. +- Logins are rate-limited so attackers can't guess passwords at speed. +- The server refuses to fetch hidden internal network addresses (a common + self-hosted app attack). +- Database upgrades are checksummed and atomic; a release gate runs real + tests on every change. + +--- + +## What is missing today + +**Login and accounts** + +- No "forgot password" — a locked-out user needs someone to edit the + database by hand. This is the biggest gap. +- No two-factor authentication (2FA). +- No single sign-on (logging in with an existing account system). +- No email verification. + +**Product features** + +- Collections can't be shared — they're private to their owner. +- No page history (can't see or restore older versions). +- No trash — deletes are permanent. +- No nested pages or backlinks ("which pages mention this one?"). +- Calendar and list views for collections are stored but still display as + tables. +- Alerts only show inside the app — nothing is emailed or pushed to you. +- Email is the only watcher type (no RSS feeds, no webhooks). + +**Technical debt** (remaining after the hardening pass) + +- The email library is on an unmaintained version; the login library + (NextAuth) is still a beta. +- Database tests silently skip when the test database isn't configured; + the web (React) code never got a line-by-line review; web libraries have + never been audited. +- Vulnerability scanning exists (`make audit`) but isn't run automatically. + +**Recently completed** (see the hardening section below) + +- Rare IPv6 transition forms are blocked, and preview/IMAP dials are limited + to their normal ports. +- Per-field text limits replace the old "anything up to 1 MB" behavior. +- Page search escapes `%`/`_` and uses `pg_trgm` GIN indexes. +- Partial mailbox-check results are kept instead of discarded. + +**Operations** + +- Backup and restore are documented in `RUNNING.md`, and the deploy script + takes a pre-deploy dump plus `.env` snapshot; a repeatable restore drill is + still manual. +- Releases are identified by git revision (and revision-tagged images), not + semantic version numbers with a changelog. +- Only works as one server instance (fine for now, but it's an unstated + assumption). + +--- + +## What we can do over the next 3–6 months + +### The next 3 months: switch the login, shore up the base + +**1. Move sign-in to Authelia.** This one move fixes several gaps at once: + +- No more stored passwords — so no password-reset problem to solve. +- 2FA (authenticator apps, security keys) comes free with Authelia. +- One login can work across several apps (single sign-on). +- A lot of our own login-protection code can be deleted. +- Before building, we need four decisions: + - **Break-glass:** keep one special local admin for "Authelia is down" + emergencies? (Strongly recommended: yes.) + - **Lockout speed:** when someone is disabled in Authelia, how fast must + the app kick them out? (Their existing session can outlive the + disable unless we design for it.) + - **Sharing:** today you can share with an account that exists; with + Authelia, accounts appear on first login. Do we pre-create people by + email, or only allow sharing with people who have logged in once? + - **Roles:** should Authelia groups decide who is an admin, or keep + roles managed inside the app? (Start with: inside the app.) + +**2. Finish the review backlog** — completed: + +- Block the rare IPv6 tricks; limit outbound fetches to normal ports. +- Add size limits to text fields. +- Fix page search (escape wildcards, add an index). +- Make partial email-check results show up instead of nothing. + +The remaining small error-handling cleanups are folded into ongoing +maintenance rather than a separate milestone. + +**3. Update the two aging libraries:** + +- Email (IMAP) library to its maintained version. +- NextAuth to a stable release — this becomes urgent, since it will be the + only front door. + +**4. Make the safety net automatic:** + +- Vulnerability scanning on every CI run. +- Database tests fail loudly in CI instead of skipping. +- A review pass over the web/React code (never reviewed line by line). + +**5. Operations basics:** + +- Backup + restore are documented (done); add an automated restore drill. +- Start tagging releases with semantic version numbers and a changelog. + +### 3–6 months: grow the product + +- **Page history and trash** — see old versions, restore them; deleted + things are recoverable. +- **Nested pages and backlinks** — a real page tree, and "what links here" + that never shows titles you're not allowed to see. +- **Collection sharing** — the big one. Explicit, shown in the UI, and + revocable. Design the permission rules first (a project must never + accidentally widen who can see a bookmark), then build it. Once it + exists, cross-project "relation" columns can follow. +- **Alerts that reach you** — email or webhook when a due date hits or an + email watcher matches. Turns the app from a dashboard into a notifier. +- **RSS watchers** — "tell me when this feed mentions X." Reuses everything + the email-watcher system already does. +- **Real calendar and list views** for collections; sorting by more than + one column. +- **Authelia single sign-on for the client install** (the OIDC stubs + currently in the code get removed, not implemented — NextAuth handles it). + +### Things we are choosing NOT to do + +- No block editor — Markdown first. +- No real-time collaboration, formulas, rollups, plugins, or AI-made + changes. +- No automatic/project-wide magic collections — membership stays explicit. +- No local passwords for regular users once Authelia ships (only the + break-glass admin, if we keep one). diff --git a/README.md b/README.md new file mode 100644 index 0000000..b1beef5 --- /dev/null +++ b/README.md @@ -0,0 +1,315 @@ +# computer + +A multi-user bookmark workspace: URLs with Markdown notes, tags, calendar +dates, editable kanban boards, project collections with typed properties and +saved views, pages, previews, sharing, contacts and email signals. + +## Run the whole app + +**Client setup guide:** [Running computer](RUNNING.md) covers cloning, setup, +first login, everyday commands, updates, and troubleshooting on Windows, +macOS, and Linux. + +Requires Docker with Compose v2 or newer. On a new installation: + +```bash +git clone https://github.com/andrewthecodertx/computer.git +cd computer +cp .env.example .env +docker run --rm node:22-alpine node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))" +``` + +Put the generated value in `AUTH_SECRET` in `.env`, save the file, then run: + +```bash +docker compose up -d --build # builds and starts PostgreSQL, Go and Next.js +``` + +On Windows PowerShell, use `Copy-Item .env.example .env` instead of `cp`. + +- Web interface: **http://localhost:3000**. Sign up, then sign in. +- Go API: **http://localhost:8080** (local access; endpoints in [API.md](API.md)). +- PostgreSQL: `db:5432` inside Docker; not published on the host. +- With `ADMIN_EMAILS` empty, the first non-`@example.com` account becomes + administrator and completes a one-shot bootstrap: afterwards signup never + auto-promotes again, even if the last administrator is demoted or removed + — recovery is operator-driven (`ADMIN_EMAILS` + seed, or promotion by an + existing admin). Addresses in `ADMIN_EMAILS` are reserved for + operator-provisioned accounts; automatic + promotion requires a verified email. Existing accounts are preserved on restart. +- `docker compose down` stops the stack. It does not delete the database volume. + +`make run` and `make stop` are optional shortcuts for the Compose commands. +`docker-compose.full.yml` is a compatibility alias for the same complete stack. + +### Existing Docker database + +Set `POSTGRES_VOLUME` to its existing Docker volume name and +`POSTGRES_VOLUME_EXTERNAL=true`. The volume must contain the `computer` +database and `computer` role. Stop the previous PostgreSQL container before +mounting that volume in this stack. + +`db/schema.sql` initializes a **new, empty** database once. `db/migrations/` +holds the ordered, checksummed additive migrations that `db/migrate.sh` runs +(ledger table `_migration`) before Go starts. Each migration is applied and +recorded in a single transaction under an advisory lock, so a crash never +leaves an applied file unrecorded (or vice versa) and a second concurrent +runner aborts instead of double-applying. Neither path drops a database. +Do not run the initial schema against an already initialized database, and do +not edit a migration that has been applied — add a new numbered file. +Migrations are additive (new columns/tables/indexes with defaults), so the +previous application release keeps working against a migrated database; +rolling the data itself back means restoring the pre-deploy dump (see +RUNNING.md), not reverting SQL. Because schema.sql and the chain are two +paths to the same schema, every schema change needs both a schema.sql edit +and a new migration; `make db-converge` (included in `make check`) fails +when they drift apart. The page-search migration enables the `pg_trgm` +extension (bundled with PostgreSQL and trusted, so the database owner can +install it); the index-backed page search depends on it. + +## Architecture + +- `internal/`: Go API, persistence, authorization and integrations. SQL values + are parameterized; camelCase JSON matches the Next.js client contract. +- `web/`: Next.js UI and NextAuth browser sessions. Credentials are verified + by Go. A server-side API proxy signs 60-second identity assertions; Go + reloads roles and resolves admin impersonation on every data request. + Signing out in the browser also revokes backend sessions ("sign out + everywhere"): it bumps the user's token version, so prior assertions and + Go cookie sessions stop working. +- The frontend has no runtime Prisma client or direct database connection. +- Go also supports its own HttpOnly `computer_session` cookie for direct API + consumers. Administration uses the real user; data uses the effective user. +- Containers share the Compose backend network. The app runs as non-root + with a read-only filesystem; the web container has temporary `/tmp` storage. + +## Features and integrations + +- Bookmarks: create/edit/delete, tags, contacts, previews, calendar dates, + alerts, Markdown downloads and public read-only links. +- Bookmark collections use cursor pagination; the UI follows every page so + collections larger than 200 remain visible on all organization screens. +- Notion-style workspace shell: a Quick Find command palette (Ctrl/Cmd+K) + jumps to any page, bookmark, tag or view and runs commands (new page, add + bookmark, theme toggle); the slim top bar shows a breadcrumb path + (workspace / section / page); sidebar sections collapse and page rows + expose hover actions (pin, inline rename, delete). +- Pages: Markdown editing/autosave with a Notion-style slash-command block + menu (type `/` for headings, to-dos, lists, quotes, dividers and code) and + the `[]` + space to-do shortcut, link collections, pinning, reorder/export. + Saves are queued per page; unsaved drafts are kept in the current browser tab + for recovery after a failed save, together with the server version they were + based on. Page switching waits for pending saves. When another tab or device + saved first, autosave pauses in an explicit conflict state — the editor + offers overwrite, save-as-copy and discard, and typing alone never + overwrites the newer version. +- Kanban: custom columns, card moves, reorder; deleting a column moves cards. +- Collections: per-project workflow state over your existing bookmarks. The + same link joins many collections without duplication, each with its own + typed properties (text, number, select, multi-select, date, checkbox, URL), + saved table/board views, server-side filters with typed operator + allowlists, and manual ordering. Membership adds context — it never owns + or publishes the bookmark: removing an item or a whole collection keeps + the bookmark, and a bookmark whose share was revoked disappears from + collections everywhere. Every mutating PATCH carries an expected version, + so concurrent editors get a conflict instead of a silent overwrite; + reordering and property deletion advance the affected items' and views' + revisions too, and deletions are idempotent removals. +- Sharing: bookmarks, tags and calendar dates; recipients read shared content. + Owners can revoke shares. Shared tags and dates expose the associated URLs. + Tag sharing is group-wide: every recipient of a tag sees all bookmarks + carrying it, including ones other recipients add. Shared readers get a + narrowed response: the link and its reader-facing content only — never the + owner's contact record, the other recipients, alerts, or mailbox watches. +- Public bookmark links deliberately publish the title, description, notes, + due date, tags and preview; contacts, alerts, mailbox watches and the + owner identity stay private. The share panel states this before you toggle. +- Remote preview images and favicons are loaded by your browser directly + from the third-party host, with `referrerPolicy="no-referrer"` so the app + URL is not disclosed; opening a page can still let those hosts observe the + request. Server-side fetches (previews, CardDAV, IMAP) never touch private + network ranges, including 6to4/Teredo/NAT64-wrapped addresses. Previews are + pinned to HTTP/HTTPS ports 80/443 and mailbox connections to IMAP ports + 143/993; CardDAV uses the explicitly configured HTTPS port. +- Text fields have per-field caps in addition to the request-body limit, so + an oversized bookmark title, tag name, note or page body is rejected + instead of bloating every list and search. +- Alerts: browser notifications and in-app dismissal, polled every five minutes. +- Nextcloud: CardDAV REPORT sync using each user's app password. Credentials + are used for that request only, not stored, and never travel plaintext: + the Nextcloud URL must use https (redirects are not followed). +- IMAP: encrypted per-user settings, real login tests, sender/subject searches, + email watchers, and Message-ID deduplication. Verified TLS is required — + plaintext mailbox logins are rejected. Watcher ingestion is incremental: a + durable per-source UID checkpoint processes bounded batches until caught up + instead of re-scanning the last 50 matches. Configure integrations in + Settings, then check watchers from a bookmark. + Enabled email watchers also run every five minutes in the Go process, + including watches enabled with the original mailbox-watch switch. The + scheduler runs users with bounded concurrency, per-user failure backoff, + and a database-backed lease so extra instances never double-poll a mailbox. + +### Connecting Authelia (OIDC) + +**Authelia sign-in is currently stubbed. Connecting it requires both server +configuration and developer integration; setting environment variables alone +does not enable it.** Email/password sign-in works while that integration is +pending. The full [Authelia connection guide](web/docs/AUTHELIA.md) includes +the client registration example and the implementation handoff. + +#### 1. Register the app in Authelia + +Use an Authelia installation with OIDC enabled and a valid HTTPS issuer URL. +Register `computer` as a confidential client with: + +- Scopes: `openid`, `profile`, `email`. +- Grant type: `authorization_code`; response type: `code`. +- Token endpoint authentication: `client_secret_basic`. +- Redirect URI: `/api/auth/callback/authelia`, for example + `https://computer.company.tld/api/auth/callback/authelia`. + +Generate a client secret as described in the connection guide. Put its +**hashed digest** in Authelia and its **plain value** in the app's +`OIDC_CLIENT_SECRET`. The redirect URI must exactly match the public web URL +and callback path. It points to the Next.js server, not the Go API on port 8080. + +#### 2. Configure computer + +Edit the root `.env`, replacing the sample values with your deployment values: + +```dotenv +NEXTAUTH_URL=https://computer.company.tld +COOKIE_SECURE=1 +OIDC_ISSUER=https://auth.company.tld +OIDC_CLIENT_ID=computer +OIDC_CLIENT_SECRET= +``` + +Serve the web app through an HTTPS reverse proxy; the default Docker ports +bind to localhost. Keep your existing `AUTH_SECRET` unchanged. Apply the +environment changes with `docker compose up -d`, then check **Settings → OIDC**. +It will report that the variables are configured but integration is pending. +The login button remains disabled at this stage. + +#### 3. Complete the developer integration + +The trusted Next.js server owns OIDC verification and the callback. The +remaining work is to: + +1. Implement Go-backed lookup/provisioning of a local `User` and `Account` + for the verified Authelia identity. A provisioning endpoint is not yet + supplied; any new endpoint must authenticate the trusted Next server. +2. Update `web/auth.ts` to resolve the verified identity to the local + `User.id` and permit sign-in only after that succeeds. The session and + API assertion must use this local ID, not Authelia's `sub` claim. Existing + accounts need an explicit, verified account-linking policy. +3. Enable the readiness gate in `web/app/login/client.tsx` after mapping works, + rebuild with `docker compose up -d --build`, and verify sign-in, first-login + provisioning, returning users, and Go-backed data access. + +The Go OIDC login/callback handlers in `internal/oidc/provider.go` deliberately +return **501**. They are not the NextAuth callback implementation. Config status +(including an `enabled` flag) indicates that values are present, not that an +end-to-end login has been implemented. + +## Developer commands + +```bash +make run # build and start the full Compose stack +make stop # stop the stack, keep the PostgreSQL volume +make build # Go compile +make vet # Go vet +make test # Go unit tests with race detection +make integration # Docker PostgreSQL + CardDAV/IMAP fixture tests +make db-shell # psql inside the database container +make logs # Go and Next.js logs +make migrate # ordered additive database migrations +make db-converge # schema.sql vs migration-chain drift check +make check # release validation (fails without DB coverage) +make audit # dependency advisory scans (needs govulncheck) +``` + +The same release gate runs automatically on every push and pull request +(`.github/workflows/check.yml`), including the browser smoke tests against +the composed stack. Pass `YARN="corepack yarn"` to `make check` when yarn +comes through corepack. + +Integration tests create/drop random test schemas; they never clear app data. +They exercise CRUD, sharing, ownership boundaries, impersonation, encryption, +CardDAV sync, IMAP login/search and signal deduplication. + +Frontend checks (from `web/`): `yarn lint`, `yarn build`, and +`yarn eslint -c eslint.ssr.config.mjs .`. Builds enforce TypeScript checks. + +Browser smoke tests (with the stack running): + +```bash +npm --prefix scripts ci +CHROMIUM_PATH=/usr/bin/chromium make e2e +``` + +These verify login, UI bookmark creation, Go-backed persistence, public links, +Quick Find deep links, kanban, the collections workflow (membership without +duplication, typed properties, table+board over one membership), page +autosave/slash commands/exports and every application screen. Test accounts +have unique names and only those accounts are deleted afterward. + +Targeted browser regressions (after `yarn build` in `web/`): + +```bash +npm --prefix scripts run test:regressions +``` + +These run an isolated Next server with API fixtures and test login CSRF, +browser sign-out revocation, autosave switching/ordering/recovery (including +drafts whose base version is unknown, which pause for review), pagination, +and bookmark editing without creating or deleting application users. + +Optional operator-provisioned account: set its address in `ADMIN_EMAILS` in +the root `.env`, run `docker compose up -d`, then: + +```bash +docker compose exec -e SEED_EMAIL=admin@company.tld -e SEED_PASSWORD='' app /computer --seed +``` + +This trusted command creates an admin for an allowlisted address. It never +changes an existing account's password or role, or deletes data. Additional +existing users can be promoted from the Admin screen. + +## Deploying to a server + +Deployment is target-agnostic — there are no built-in host or domain +defaults. Point DNS for your domain at the server, have an ssh alias (or +host) ready, then run from the repository root: + +```bash +HOST=app-server DOMAIN=computer.example.tld CERTBOT_EMAIL=ops@example.tld ./scripts/deploy.sh +``` + +(`make deploy` runs the same script with the same variables.) It records the +exact git revision, refuses dirty trees unless `ALLOW_DIRTY=1`, rsyncs the +source, and on the server: provisions Docker/nginx/certbot, writes `.env` on +first deploy, takes a pre-deploy database dump plus `.env` snapshot into +`/var/backups/computer` (15 retained), builds revision-tagged images, and +installs the nginx + Let's Encrypt reverse proxy from +`deploy/nginx/computer.conf.template`. Rollback means re-deploying an older +revision or restoring a pre-deploy dump; migrations are additive. See +RUNNING.md → "Running on a remote server" for the operator checklist. + +## Client test build + +Use [RUNNING.md](RUNNING.md) for the client handoff. Test login, bookmark editing, +tag/contact organization, calendar dates, kanban, a collections workflow +(create a collection, add the same bookmark to two collections, give each a +status, move a card on a board view), Markdown pages/exports, +Quick Find (Ctrl/Cmd+K), slash commands in the page editor, and sharing with +separate accounts. + +Current integration boundaries: + +- Authelia still requires the developer identity-mapping work described above; + use email/password for this test build. +- IMAP and Nextcloud require the client's own service credentials. +- Alerts are browser-only and checked every five minutes while the app is open. +- Page autosave needs an API connection. Check the save indicator; use **Save + now** after a failed save. Tab-local drafts are recovery aids, not backups. diff --git a/RUNNING.md b/RUNNING.md new file mode 100644 index 0000000..f085c87 --- /dev/null +++ b/RUNNING.md @@ -0,0 +1,335 @@ +# Running computer + +This guide takes you from cloning the repository to signing in on your own +computer. Docker runs the web app, Go API, and PostgreSQL database together. +You do not need to install Go, Node.js, PostgreSQL, Make, or OpenSSL separately. + +## Before you start + +- **Docker with Docker Compose v2 or newer.** Docker Desktop includes Compose + on Windows and macOS. On Linux, install Docker Engine and the Compose plugin. +- **Git** to clone the repository. Alternatively, download and extract the + repository ZIP from GitHub. +- An internet connection for the first build to download images and packages. +- Local ports **3000** and **8080** available for the app. + +Start Docker Desktop, or make sure the Docker service is running on Linux. +Open a terminal (PowerShell on Windows) and check: + +```sh +docker info +docker compose version +``` + +Both commands should succeed. On Windows, Docker must be using Linux containers. + +## 1. Download the app + +```sh +git clone https://github.com/andrewthecodertx/computer.git +cd computer +``` + +If you downloaded a ZIP, open a terminal in the extracted folder instead. +Run the remaining commands from the folder containing `docker-compose.yml`. + +## 2. Create your configuration + +Copy the supplied example to a file named `.env` in the repository root. +Do this once on a new installation; keep an existing `.env` when updating. + +**macOS / Linux:** + +```sh +cp .env.example .env +``` + +**Windows PowerShell:** + +```powershell +Copy-Item .env.example .env +``` + +Generate an authentication secret using Docker. This command works in either +terminal: + +```sh +docker run --rm node:22-alpine node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))" +``` + +The final line of output is a 64-character random value. Open `.env` in a text +editor and replace `replace-with-a-random-secret` on the `AUTH_SECRET` line +with that value. Save the file. + +For a new local installation, keep these other settings: + +```dotenv +NEXTAUTH_URL=http://localhost:3000 +COOKIE_SECURE=0 +ADMIN_EMAILS= +POSTGRES_VOLUME=computer_pgdata +POSTGRES_VOLUME_EXTERNAL=false +``` + +The supplied `POSTGRES_PASSWORD=computer` works with the bundled database. +If you choose a different password, set it before the first startup; use a +random hexadecimal value to avoid special characters in the connection URL. +Keep the `OIDC_*` placeholders as supplied: use email/password login for now. + +Keep `.env` for future starts and upgrades. It is ignored by Git. **Keep +`AUTH_SECRET` unchanged:** changing it invalidates sessions, and — unless a +separate `ENCRYPTION_KEY` is set — makes saved IMAP passwords unreadable. +Setting `ENCRYPTION_KEY` (a random hex string, backed up with the database) +decouples integration-credential encryption from session signing: you can +then rotate `AUTH_SECRET` without losing stored mailbox passwords, and rows +encrypted under the old derivation are re-encrypted transparently on first +use. Back up `.env` with your database backups: without the secrets it holds, +encrypted integration data in a database backup cannot be decrypted. + +Mailbox connections always use verified TLS; plaintext IMAP logins are +rejected. Use the normal IMAP ports: **993** (the default) or **143**. + +## 3. Build and start + +```sh +docker compose up -d --build +``` + +The first build can take several minutes. Docker automatically: + +1. Starts PostgreSQL and initializes a new database. +2. Applies any pending additive database migrations. +3. Starts the Go API. +4. Starts the Next.js web app. + +Check startup status: + +```sh +docker compose ps -a +``` + +Expected results: + +| Service | Expected state | +|---|---| +| `db` | Running, healthy | +| `migrate` | Exited with code **0** — normal; it runs once per startup | +| `app` | Running, healthy | +| `web` | Running, healthy | + +If `db`, `app`, or `web` still says `health: starting`, wait a few seconds and +check again. The `tests` service is optional and does not start during normal use. + +## 4. Open the app and create your account + +Open **http://localhost:3000** in your browser. + +1. Choose **Sign up** and create an account with your email and a password. +2. Sign in using those credentials. +3. Add a bookmark to check that saving works. + +With the default empty `ADMIN_EMAILS`, the first account whose email does not +end in `@example.com` becomes the administrator. Create your own account first. +Later accounts are regular users; an administrator can manage their roles +from the Admin screen. + +Addresses listed in `ADMIN_EMAILS` are reserved and cannot be claimed through +public signup. To create an account for one of those addresses, use the +operator-provisioning command in [README.md](README.md#developer-commands). + +The default setup is accessible only from this computer. Public bookmark +links are also local until you configure externally accessible hosting. + +### What to test + +- Create, edit, and delete a bookmark, including its URL, description, and tags. +- Organize bookmarks using tags, calendar dates, contacts, and kanban columns. +- Create a collection, add an existing bookmark to it (nothing is duplicated), + add a select property such as Status, and switch between the table and a + board view over the same membership. +- Write a Markdown page, switch between pages, and download its Markdown export. +- Share a bookmark, tag, or date with a second account and revoke the share. +- Open a public bookmark link in a signed-out browser window. + +Pages autosave after a short pause and finish saving before switching page +tabs. If saving fails, the page remains **Unsaved** and a draft is kept in the +current browser tab. Reconnect and click **Save now**; tab-local drafts are +not a replacement for database backups. If another tab or device saved the +same page first, autosave pauses with a conflict banner — choose explicitly +between overwriting, saving a copy, or discarding; typing alone never +overwrites the newer version. + +## Everyday commands + +Run these from the repository root: + +| Task | Command | +|---|---| +| Stop the app and keep your data | `docker compose down` | +| Start it again | `docker compose up -d` | +| Restart the web app and API | `docker compose restart app web` | +| Check service status | `docker compose ps -a` | +| Follow application logs | `docker compose logs -f app web` | + +Press **Ctrl+C** to stop following logs; the app keeps running. + +After changing `.env`, use `docker compose up -d` to recreate affected +containers with the new settings. A simple `restart` does not load changed +environment variables. + +### Where your data lives + +Bookmarks, accounts, pages, and settings live in the Docker PostgreSQL volume +named `computer_pgdata` by default. They survive normal stops, container +recreation, and image rebuilds. + +**Do not use `docker compose down -v` or delete that volume if you want to +keep your data.** The volume is not a backup; back up the database separately. + +### Backups and restore + +Take dumps with the database running (review OPS-03: a dump is only a backup +once a restore from it has been proven): + +```sh +docker compose exec -T db pg_dump -U computer -d computer | gzip > computer-$(date -u +%Y%m%d).sql.gz +cp .env env-$(date -u +%Y%m%d).bak # AUTH_SECRET / ENCRYPTION_KEY / POSTGRES_PASSWORD +``` + +Keep the dump **and** the `.env` snapshot together and copy both off-host: +stored IMAP passwords are AES-GCM ciphertext, and without the secret they +were encrypted under, a restored database cannot decrypt them. The +deployment entry point (`scripts/deploy.sh`, which runs +`scripts/server-deploy.sh` on the server) does this automatically before every +deploy into `/var/backups/computer` (0600 dumps, 15 retained); it starts a +stopped database rather than skipping the backup silently. + +Restore into an isolated environment first to verify a dump before trusting +it. Use a separate checkout (or stop the live stack first) and a scratch +volume so the live `computer_pgdata` volume is never touched: + +```sh +POSTGRES_VOLUME=computer_restore docker compose up -d db +gunzip -c computer-YYYYMMDD.sql.gz | docker compose exec -T db psql -U computer -d computer +# inspect the restored data, then remove the scratch container and volume +docker compose rm -sf db +docker volume rm computer_restore +``` + +Then restore the matching `.env` so the secrets can decrypt integration +data, and start the stack against the real volume. Migrations are additive, +so any current or newer application release reads an older dump; reverting to +an older data state means restoring that dump, not reverting SQL. + +### Updating the app + +Back up your database and `.env` before updating. Then run: + +```sh +git pull --ff-only +docker compose up -d --build +docker compose ps -a +``` + +Keep your existing `.env` and database volume. Database migrations run +automatically during startup. The bundled PostgreSQL image already includes +the `pg_trgm` extension used by page search; the database owner role must be +allowed to create extensions if you point the app at an external database. + +## Optional integrations + +The core app works without any external services. + +- **IMAP email:** In **Settings → IMAP**, enter your mail server, port + (**993** or **143**), TLS setting, username, password, and folder. Test the + connection, then save. + Add an email watcher from a bookmark's Signals panel. Enabled watchers are + checked every five minutes while the API is running. +- **Nextcloud contacts:** In **Settings → Nextcloud**, enter your Nextcloud + URL, username, and app password, then sync contacts. You can associate a + synced contact with a bookmark. +- **Browser alerts:** Allow notifications when prompted. Alert checks run + while the app is open in your browser. +- **Authelia:** This integration is intentionally unfinished. Setting OIDC + environment variables alone does not enable sign-in. See + [web/docs/AUTHELIA.md](web/docs/AUTHELIA.md) for integration details. + +## Troubleshooting + +### Docker cannot connect to its daemon + +Start Docker Desktop or the Linux Docker service, then retry `docker info`. +On Linux, ensure your account has permission to use Docker. + +### `docker compose` is not recognized + +Install the Docker Compose plugin or update Docker Desktop. These instructions +use `docker compose` with a space, rather than the older `docker-compose`. + +### `Set AUTH_SECRET in .env` + +Confirm `.env` exists beside `docker-compose.yml`, has a nonempty `AUTH_SECRET`, +and was not saved as `.env.txt`. Follow step 2 to generate the value. + +### Port 3000 or 8080 is already in use + +Stop the other application or Docker stack using that port, then retry +`docker compose up -d --build`. Alternatively move this stack's host ports in +`.env` (`WEB_HOST_PORT`, `APP_HOST_PORT`). When you shift `WEB_HOST_PORT`, +`NEXTAUTH_URL` must be updated to the same browser-facing origin (for example +`http://localhost:3100`): the API proxy rejects requests whose Origin matches +neither, so sign-in works but saving fails with "Invalid request origin". +Recreate the stack with `docker compose up -d` after changing `.env`. + +### The app does not open, or sign-in fails + +Use **http://localhost:3000** consistently; keep `NEXTAUTH_URL` set to that +address and `COOKIE_SECURE=0` for local HTTP. Then check: + +```sh +docker compose ps -a +docker compose logs --tail=100 db migrate app web +``` + +The API's database readiness check is available in your browser at +**http://localhost:8080/readyz**. A ready API returns `{"status":"ready"}`. + +If PostgreSQL reports a password mismatch on an existing volume, restore +the original `POSTGRES_PASSWORD` in `.env`. Changing that variable does not +change the password of a database that was already initialized. + +When asking for help, include service status and relevant error messages. +Remove passwords, secrets, and personal data from anything you share. + +## Running on a remote server + +The default Compose ports bind to `127.0.0.1`. For remote access, configure an +HTTPS reverse proxy to the web app on port 3000, set `NEXTAUTH_URL` to your +public HTTPS URL, and set `COOKIE_SECURE=1`. The database remains internal to +Docker. + +### Bundled deployment script + +The repository ships a target-agnostic deployment for exactly this setup — +no host or domain is hardcoded. Prerequisites: a Debian/Ubuntu server with +ssh access (an ssh config alias works well), DNS for your domain already +pointing at it, and `rsync` + `git` on your workstation. Then, from the +repository root: + +```sh +HOST=app-server DOMAIN=computer.example.tld CERTBOT_EMAIL=ops@example.tld ./scripts/deploy.sh +``` + +The script deploys a **known git revision** (dirty working trees are refused +unless `ALLOW_DIRTY=1`) and on the server provisions Docker/nginx/certbot, +writes `.env` on first deploy, takes a pre-deploy database dump plus `.env` +snapshot into `/var/backups/computer` (0600, 15 retained), builds +revision-tagged images, renders the nginx + Let's Encrypt proxy from +`deploy/nginx/computer.conf.template`, and verifies `https://DOMAIN/login` +end to end. + +Rollback: re-deploy an older revision (`git checkout && ./scripts/deploy.sh`), +or restore one of the pre-deploy dumps together with its `.env` snapshot. +Database migrations are additive, so an older application release keeps +working against a migrated database; rolling the data itself back means the +dump, not reverting SQL. diff --git a/db/check-convergence.sh b/db/check-convergence.sh new file mode 100755 index 0000000..58a6945 --- /dev/null +++ b/db/check-convergence.sh @@ -0,0 +1,87 @@ +#!/bin/sh +# schema.sql <-> migration-chain convergence check (review MIG-03). +# +# A fresh volume is built from db/schema.sql (initdb) and then runs every +# migration on top; an existing volume only runs migrations. Both paths must +# land on the same structure — i.e. schema.sql has to be kept current with +# the chain, so each migration is a structural no-op against it. This builds +# two scratch databases, compares columns/indexes/constraints, and fails on +# any drift. Runs inside the postgres image against the compose db; needs +# CREATEDB rights for DB_USER (the compose superuser has them). +# +# Environment: PGPASSWORD (required), DB_HOST, DB_USER. +set -eu + +DB_HOST="${DB_HOST:-db}" +DB_USER="${DB_USER:-computer}" +[ -n "${PGPASSWORD:-}" ] || { echo "error: PGPASSWORD is required" >&2; exit 1; } + +BASE_DB=computer_conv_schema +CHAIN_DB=computer_conv_chain + +psql() { command psql -h "$DB_HOST" -U "$DB_USER" -v ON_ERROR_STOP=1 "$@"; } + +drop() { psql -q -d postgres -c "DROP DATABASE IF EXISTS $1 WITH (FORCE)" >/dev/null 2>&1 || true; } +drop "$BASE_DB"; drop "$CHAIN_DB" +trap 'drop "$BASE_DB"; drop "$CHAIN_DB"' EXIT INT TERM + +psql -q -d postgres -c "CREATE DATABASE $BASE_DB" +psql -q -d postgres -c "CREATE DATABASE $CHAIN_DB" + +# Path 1: schema.sql alone. Path 2: schema.sql + the full ledger chain +# (exactly what initdb + migrate.sh do on a fresh install). schema.sql +# carries its own BEGIN/COMMIT, so no -1 wrapper here. +psql -q -d "$BASE_DB" -f /db/schema.sql +psql -q -d "$CHAIN_DB" -f /db/schema.sql +DB_NAME="$CHAIN_DB" sh /db/migrate.sh >/dev/null + +# Structural catalog, minus the ledger itself (chain path only). +catalog() { + psql -Atq -d "$1" -c " + SELECT 'column '||table_name||'.'||column_name||' '||data_type||' '||is_nullable||' '||COALESCE(column_default,'-') + FROM information_schema.columns + WHERE table_schema='public' AND table_name<>'_migration' + UNION ALL + SELECT 'index '||indexname||': '||indexdef FROM pg_indexes + WHERE schemaname='public' AND tablename<>'_migration' + UNION ALL + SELECT 'constraint '||c.relname||' '||con.conname||': '||pg_get_constraintdef(con.oid) + FROM pg_constraint con JOIN pg_class c ON c.oid=con.conrelid + WHERE con.connamespace='public'::regnamespace AND c.relname<>'_migration' + UNION ALL + SELECT 'sequence '||sequence_name FROM information_schema.sequences + WHERE sequence_schema='public' + ORDER BY 1" +} + +catalog "$BASE_DB" > /tmp/base.catalog +catalog "$CHAIN_DB" > /tmp/chain.catalog + +fail() { + echo "error: db/schema.sql and db/migrations disagree (diff above)." >&2 + echo " Update schema.sql (fresh installs) AND ship a new numbered migration (existing installs)." >&2 + exit 1 +} + +# Pass 1: the chain is a structural no-op against the current schema.sql. +diff -u /tmp/base.catalog /tmp/chain.catalog || fail + +# Pass 2 — upgraded-install simulation: pass 1 alone cannot see the DDL +# inside CREATE TABLE IF NOT EXISTS blocks (schema.sql always wins there). +# Drop every migration-created table, wipe the ledger, and re-run the chain +# so those definitions materialize for real, then compare again. This is what +# an existing install upgraded through the ledger ends up with. Caveat: if a +# schema.sql table ever gains an FK to a migration-created table, the CASCADE +# drop removes that constraint and this check fails conservatively. +tables=$(sed -n 's/.*CREATE TABLE IF NOT EXISTS "\([^"]*\)".*/\1/p' /db/migrations/*.sql | sort -u) +if [ -n "$tables" ]; then + for tb in $tables; do + psql -q -d "$CHAIN_DB" -c "DROP TABLE IF EXISTS \"$tb\" CASCADE" + done + psql -q -d "$CHAIN_DB" -c "DELETE FROM _migration" + DB_NAME="$CHAIN_DB" sh /db/migrate.sh >/dev/null + catalog "$CHAIN_DB" > /tmp/chain.catalog + diff -u /tmp/base.catalog /tmp/chain.catalog || fail +fi + +echo "schema.sql and the migration chain converge (fresh and upgraded paths)" diff --git a/db/migrate-atomicity.sh b/db/migrate-atomicity.sh new file mode 100755 index 0000000..32f33e0 --- /dev/null +++ b/db/migrate-atomicity.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# Proves migrate.sh apply+record atomicity (review MIG-03). Runs inside the +# converge service (postgres image, /db mounted) against the compose db, on +# scratch databases it creates and drops; app data is never touched. Invoked +# by 'make db-converge' alongside the schema convergence check. +set -u +FAIL_DB=computer_conv_atomicity +PSQL="psql -h db -U computer -v ON_ERROR_STOP=1" +$PSQL -d postgres -qc "DROP DATABASE IF EXISTS $FAIL_DB WITH (FORCE)" +$PSQL -d postgres -qc "CREATE DATABASE $FAIL_DB" +mkdir -p /tmp/migs + +echo "--- case 1: failing migration leaves no ledger row and no partial DDL" +printf 'CREATE TABLE conv_probe(id text);\nSELECT 1/0;\n' > /tmp/migs/999_probe.sql +MIGRATIONS=/tmp/migs DB_HOST=db DB_USER=computer DB_NAME=$FAIL_DB sh /db/migrate.sh +rc=$? +echo "migrate exit=$rc" +[ "$rc" -ne 0 ] || { echo "FAIL: broken migration exited 0"; exit 1; } +rows=$($PSQL -At -d $FAIL_DB -c "SELECT count(*) FROM _migration") +probe=$($PSQL -At -d $FAIL_DB -c "SELECT count(*) FROM pg_tables WHERE tablename='conv_probe'") +[ "$rows" = "0" ] || { echo "FAIL: ledger recorded a rolled-back migration ($rows)"; exit 1; } +[ "$probe" = "0" ] || { echo "FAIL: partial DDL survived the rollback"; exit 1; } +echo "case 1 OK" + +echo "--- case 2: successful migration applies and records atomically" +printf 'CREATE TABLE conv_probe(id text);\n' > /tmp/migs/999_probe.sql +MIGRATIONS=/tmp/migs DB_HOST=db DB_USER=computer DB_NAME=$FAIL_DB sh /db/migrate.sh +rc=$? +[ "$rc" -eq 0 ] || { echo "FAIL: valid migration exited $rc"; exit 1; } +rows=$($PSQL -At -d $FAIL_DB -c "SELECT count(*) FROM _migration") +probe=$($PSQL -At -d $FAIL_DB -c "SELECT count(*) FROM pg_tables WHERE tablename='conv_probe'") +{ [ "$rows" = "1" ] && [ "$probe" = "1" ]; } || { echo "FAIL: apply/record mismatch rows=$rows probe=$probe"; exit 1; } +echo "case 2 OK" + +echo "--- case 3: re-run is a no-op; tampered checksum aborts before applying" +MIGRATIONS=/tmp/migs DB_HOST=db DB_USER=computer DB_NAME=$FAIL_DB sh /db/migrate.sh || { echo "FAIL: idempotent re-run failed"; exit 1; } +printf 'CREATE TABLE conv_probe2(id text);\n' > /tmp/migs/999_probe.sql +MIGRATIONS=/tmp/migs DB_HOST=db DB_USER=computer DB_NAME=$FAIL_DB sh /db/migrate.sh +rc=$? +[ "$rc" -ne 0 ] || { echo "FAIL: tampered migration did not abort"; exit 1; } +probe2=$($PSQL -At -d $FAIL_DB -c "SELECT count(*) FROM pg_tables WHERE tablename='conv_probe2'") +[ "$probe2" = "0" ] || { echo "FAIL: tampered migration applied anyway"; exit 1; } +echo "case 3 OK" + +echo "--- case 4: concurrent runners — exactly one applies, the loser aborts" +printf 'CREATE TABLE conv_race(id text);\n' > /tmp/migs/999_probe.sql +RACE_DB=computer_conv_race +$PSQL -d postgres -qc "DROP DATABASE IF EXISTS $RACE_DB WITH (FORCE)" +$PSQL -d postgres -qc "CREATE DATABASE $RACE_DB" +MIGRATIONS=/tmp/migs DB_HOST=db DB_USER=computer DB_NAME=$RACE_DB sh /db/migrate.sh >/tmp/race1.log 2>&1 & +p1=$! +MIGRATIONS=/tmp/migs DB_HOST=db DB_USER=computer DB_NAME=$RACE_DB sh /db/migrate.sh >/tmp/race2.log 2>&1 & +p2=$! +rc1=0; rc2=0 +wait $p1 || rc1=$? +wait $p2 || rc2=$? +# The loser may abort loudly (primary-key violation after the lock handoff) +# or no-op via the ledger check; what must hold: at least one succeeds, the +# ledger has exactly one row, and the file applied exactly once. +{ [ $rc1 -eq 0 ] || [ $rc2 -eq 0 ]; } || { echo "FAIL: both runners failed"; cat /tmp/race1.log /tmp/race2.log; exit 1; } +rows=$($PSQL -At -d $RACE_DB -c "SELECT count(*) FROM _migration") +race=$($PSQL -At -d $RACE_DB -c "SELECT count(*) FROM pg_tables WHERE tablename='conv_race'") +{ [ "$rows" = "1" ] && [ "$race" = "1" ]; } || { echo "FAIL: race left rows=$rows table=$race"; cat /tmp/race1.log /tmp/race2.log; exit 1; } +echo "case 4 OK" +$PSQL -d postgres -qc "DROP DATABASE IF EXISTS $RACE_DB WITH (FORCE)" + +$PSQL -d postgres -qc "DROP DATABASE IF EXISTS $FAIL_DB WITH (FORCE)" +echo "ATOMICITY TESTS PASSED" diff --git a/db/migrate.sh b/db/migrate.sh new file mode 100755 index 0000000..6a8b668 --- /dev/null +++ b/db/migrate.sh @@ -0,0 +1,95 @@ +#!/bin/sh +# Ordered, checksummed migration runner (review MIG-01). +# +# Applies every /db/migrations/*.sql file exactly once, in name order, and +# records it in the _migration ledger. Apply and record share ONE transaction +# under a fixed advisory lock (review MIG-03): a crash can no longer leave an +# applied file unrecorded (or a recorded file unapplied), and a concurrent +# runner blocks on the lock, then aborts on the ledger primary key instead of +# double-applying. A recorded file is immutable: if its checksum no longer +# matches, the runner aborts before applying anything else. +# +# Migration files must therefore be transaction-safe and must NOT carry +# their own BEGIN/COMMIT (the runner wraps each file): statements that cannot +# run inside a transaction block (CREATE INDEX CONCURRENTLY, VACUUM, ...) are +# NOT supported by this runner — handle those as explicit operator steps +# outside the ledger. Files stay idempotent by convention (defense in depth; +# migration 001 must remain so for first ledger adoption on databases already +# upgraded via the legacy upgrade.sql). +# +# Environment: PGPASSWORD (required), DB_HOST, DB_USER, DB_NAME, MIGRATIONS. +set -eu + +DB_HOST="${DB_HOST:-db}" +DB_USER="${DB_USER:-computer}" +DB_NAME="${DB_NAME:-computer}" +MIGRATIONS="${MIGRATIONS:-/db/migrations}" +[ -n "${PGPASSWORD:-}" ] || { echo "error: PGPASSWORD is required" >&2; exit 1; } + +psql() { + command psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 "$@" +} + +psql -qc 'CREATE TABLE IF NOT EXISTS _migration(name text PRIMARY KEY, checksum text NOT NULL, "appliedAt" timestamptz NOT NULL DEFAULT now())' + +# --- Preflight (review CR-12): validate the COMPLETE inventory/ledger pair +# BEFORE any schema change. The old single loop applied pending files as it +# walked, so a pending migration earlier in the chain could mutate the +# database before a later file's checksum mismatch was detected. Policy: +# - a recorded migration missing from disk is an integrity failure (the +# applied chain must stay complete — restore the file, do not edit it); +# - a recorded migration whose checksum changed is an integrity failure +# (applied files are immutable; ship a new numbered file); +# - late insertion of an EARLIER-numbered file is allowed by design: files +# apply in name order on the next run and the ledger is keyed by name, +# not by sequence position. +# A concurrent runner between preflight and apply is still safe: the apply +# loop re-checks the ledger per file, and apply+record share one transaction +# under the advisory lock with no ON CONFLICT, so a racer fails loudly on the +# primary key instead of double-applying. +# Colon-joined so shell word splitting yields one entry per ledger row +# (names contain no colons; checksums are hex). +for entry in $(psql -Atc "SELECT name || ':' || checksum FROM _migration"); do + name="${entry%%:*}" + sum="${entry#*:}" + f="$MIGRATIONS/$name" + if [ ! -e "$f" ]; then + echo "error: migration $name is recorded in the ledger but missing from $MIGRATIONS." >&2 + echo " The applied chain must stay complete; restore the file (ledger checksum $sum)." >&2 + exit 1 + fi + actual="$(sha256sum "$f" | cut -d' ' -f1)" + if [ "$actual" != "$sum" ]; then + echo "error: migration $name changed after being applied (ledger $sum, file $actual)." >&2 + echo " Applied migrations are immutable; ship the change as a new numbered file." >&2 + exit 1 + fi +done + +for f in "$MIGRATIONS"/*.sql; do + [ -e "$f" ] || { echo "error: no migrations found in $MIGRATIONS" >&2; exit 1; } + name="$(basename "$f")" + sum="$(sha256sum "$f" | cut -d' ' -f1)" + recorded="$(psql -Atc "SELECT checksum FROM _migration WHERE name='$name'")" + if [ -n "$recorded" ]; then + # Preflight proved the checksum matches; re-check here so a file mutated + # mid-run is still refused before anything else applies. + if [ "$recorded" != "$sum" ]; then + echo "error: migration $name changed after being applied (ledger $recorded, file $sum)." >&2 + echo " Applied migrations are immutable; ship the change as a new numbered file." >&2 + exit 1 + fi + continue + fi + echo "applying $name" + # One transaction: psql executes interleaved -c/-f options under -1 as a + # single transaction (PostgreSQL >= 12). Take the lock (arbitrary fixed + # key), record, then apply — any failure rolls back both. Deliberately no + # ON CONFLICT: a runner that lost the race must fail loudly on the primary + # key rather than silently re-execute an already-recorded file. + psql -1 -q \ + -c 'DO $$ BEGIN PERFORM pg_advisory_xact_lock(742417); END $$' \ + -c "INSERT INTO _migration(name,checksum) VALUES('$name','$sum')" \ + -f "$f" +done +echo "migrations up to date" diff --git a/db/migrations/001_legacy_additive.sql b/db/migrations/001_legacy_additive.sql new file mode 100644 index 0000000..ea2e944 --- /dev/null +++ b/db/migrations/001_legacy_additive.sql @@ -0,0 +1,68 @@ +-- 001_legacy_additive.sql — additive upgrades historically shipped as +-- db/upgrade.sql, adopted into the ordered ledger (review MIG-01). +-- Idempotent: every statement is guarded by an existence check, so first +-- ledger adoption on an already-upgraded database is a no-op. From ledger +-- adoption onward recorded migrations are immutable; new changes go into +-- new numbered files. The runner (db/migrate.sh) applies each file in its +-- own transaction with ON_ERROR_STOP. + +-- Review MIG-02: report orphaned bookmark->contact references with an +-- actionable error before adding the foreign key, instead of failing with a +-- bare constraint violation. Never erase relationships silently — repair is +-- a deliberate operator decision. +DO $$ +DECLARE orphans bigint; +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='Bookmark_contactId_fkey' AND conrelid='"Bookmark"'::regclass) THEN + SELECT count(*) INTO orphans FROM "Bookmark" b + WHERE b."contactId" IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM "Contact" c WHERE c.id=b."contactId"); + IF orphans > 0 THEN + RAISE EXCEPTION 'migration 001: % bookmark(s) reference missing Contact rows; repair them deliberately (e.g. UPDATE "Bookmark" SET "contactId"=NULL WHERE "contactId" IS NOT NULL AND NOT EXISTS (SELECT 1 FROM "Contact" c WHERE c.id="Bookmark"."contactId")) and re-run', orphans; + END IF; + ALTER TABLE "Bookmark" ADD CONSTRAINT "Bookmark_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact"(id) ON DELETE SET NULL; + END IF; +END $$; +-- Bring pre-port mailbox switches into the background signal scheduler. +-- Existing sources (including paused watches) and modern watchers are preserved. +INSERT INTO "SignalSource"(id,type,config,enabled,"bookmarkId","ownerId") +SELECT 'legacy-'||b.id,'email',json_build_object('subject',COALESCE(b."imapQuery",''),'legacy',true),true,b.id,b."ownerId" +FROM "Bookmark" b +WHERE b."imapWatchEnabled" AND NOT EXISTS ( + SELECT 1 FROM "SignalSource" s WHERE s."bookmarkId"=b.id AND s."ownerId"=b."ownerId" AND s.config->>'legacy'='true' +) +ON CONFLICT(id) DO NOTHING; +CREATE INDEX IF NOT EXISTS "Bookmark_ownerId_updatedAt_id_idx" ON "Bookmark"("ownerId","updatedAt" DESC,id DESC); +-- Revocable sessions: cookie JWTs embed this version; signout bumps it. +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "tokenVersion" integer NOT NULL DEFAULT 1; +-- Query-pattern indexes: shared-date visibility join and the alerts poll. +CREATE INDEX IF NOT EXISTS "SharedDate_creatorId_recipientId_date_idx" ON "SharedDate"("creatorId","recipientId",date); +CREATE INDEX IF NOT EXISTS "Bookmark_ownerId_alertAt_unsent_idx" ON "Bookmark"("ownerId","alertAt") WHERE NOT "alertSent"; +-- Prisma's Json maps to jsonb; enables GIN indexing and dedups keys. +-- Re-running on an already-jsonb column is a validated no-op. +ALTER TABLE "SignalSource" ALTER COLUMN config TYPE jsonb USING config::jsonb; +-- Optimistic concurrency for page saves (review DATA-01): content saves bump +-- this revision and a stale base version in PATCH is rejected with 409. +ALTER TABLE "Page" ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1; +-- Durable per-source mailbox ingestion checkpoints (review SYNC-02): polls +-- process only UIDs newer than lastUid and advance only after durable inserts. +CREATE TABLE IF NOT EXISTS "SignalCheckpoint" ( + "sourceId" text PRIMARY KEY REFERENCES "SignalSource"(id) ON DELETE CASCADE, + "uidValidity" bigint NOT NULL, + "lastUid" bigint NOT NULL, + "updatedAt" timestamptz NOT NULL DEFAULT now() +); +-- One-shot administrator bootstrap state (review AUTH-06): once recorded, +-- signup never auto-promotes again; recovery is operator-driven. +CREATE TABLE IF NOT EXISTS "AppSetting" ( + key text PRIMARY KEY, + value jsonb NOT NULL DEFAULT '{}', + "updatedAt" timestamptz NOT NULL DEFAULT now() +); +-- Installations that already have a qualifying administrator completed +-- bootstrap before this ledger existed; record it so demoting that +-- administrator cannot re-open first-signup promotion. +INSERT INTO "AppSetting"(key,value) +SELECT 'adminBootstrap',jsonb_build_object('completed',true) +WHERE EXISTS (SELECT 1 FROM "User" WHERE role='ADMIN' AND email NOT LIKE '%@example.com') +ON CONFLICT(key) DO NOTHING; diff --git a/db/migrations/002_collections.sql b/db/migrations/002_collections.sql new file mode 100644 index 0000000..7418326 --- /dev/null +++ b/db/migrations/002_collections.sql @@ -0,0 +1,78 @@ +-- 002_collections.sql — collections, typed properties, memberships and saved +-- views (Notion-like upgrades phase 2: N-01/N-02/N-03 data layer). +-- +-- Design decisions (computer_notion_upgrades.md §4, locked before shipping): +-- * Membership is contextual: the same bookmark can belong to many +-- collections with independent property values; removing a membership or a +-- whole collection never touches the bookmark (N-01). +-- * Membership never broadens access: item reads re-check BookmarkVisible at +-- query time, and creation validates visibility (decision #4, N-08). +-- * Views are queries, not copies: filters/sorts/grouping live on the view +-- and never duplicate record data (decision #3). +-- * Deviation from the proposal draft, per its own API rule ("PATCH must +-- require the expected version", the DATA-05 lesson): CollectionProperty, +-- CollectionItem and CollectionView carry a version column bumped by every +-- mutating PATCH; a stale base is a 409. Property rows also gained +-- updatedAt for the same reason. +-- * Property types are a CHECK constraint, not a Postgres enum (easier to +-- evolve). RELATION is reserved in the constraint but the API rejects it +-- until cross-collection access checks are designed (draft §6 open issue). +-- * Date semantics (decision #7): DATE properties declare config.mode +-- 'date' (YYYY-MM-DD, calendar-day stable across timezones) or 'datetime' +-- (RFC3339, normalized to UTC on write). Values live in CollectionItem. +-- propertyValues (JSONB keyed by property id) and are validated server-side +-- against CollectionProperty on every write. + +CREATE TABLE IF NOT EXISTS "Collection" ( + id text PRIMARY KEY, + name text NOT NULL, + description text, + icon text, + "ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + version integer NOT NULL DEFAULT 1, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS "Collection_ownerId_idx" ON "Collection"("ownerId"); + +CREATE TABLE IF NOT EXISTS "CollectionProperty" ( + id text PRIMARY KEY, + "collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE, + name text NOT NULL, + type text NOT NULL CHECK (type IN ('TEXT','NUMBER','SELECT','MULTI_SELECT','DATE','CHECKBOX','URL','RELATION')), + position integer NOT NULL DEFAULT 0, + config jsonb NOT NULL DEFAULT '{}', + version integer NOT NULL DEFAULT 1, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now(), + UNIQUE ("collectionId", name) +); + +CREATE TABLE IF NOT EXISTS "CollectionItem" ( + id text PRIMARY KEY, + "collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE, + "bookmarkId" text NOT NULL REFERENCES "Bookmark"(id) ON DELETE CASCADE, + propertyValues jsonb NOT NULL DEFAULT '{}', + position integer NOT NULL DEFAULT 0, + version integer NOT NULL DEFAULT 1, + "addedAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now(), + UNIQUE ("collectionId", "bookmarkId") +); +CREATE INDEX IF NOT EXISTS "CollectionItem_bookmarkId_idx" ON "CollectionItem"("bookmarkId"); + +CREATE TABLE IF NOT EXISTS "CollectionView" ( + id text PRIMARY KEY, + "collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE, + name text NOT NULL, + type text NOT NULL DEFAULT 'TABLE' CHECK (type IN ('TABLE','BOARD','CALENDAR','LIST')), + position integer NOT NULL DEFAULT 0, + filters jsonb NOT NULL DEFAULT '{"operator":"AND","clauses":[]}', + sorts jsonb NOT NULL DEFAULT '[]', + "groupByPropertyId" text REFERENCES "CollectionProperty"(id) ON DELETE SET NULL, + "displaySettings" jsonb NOT NULL DEFAULT '{}', + version integer NOT NULL DEFAULT 1, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS "CollectionView_collectionId_idx" ON "CollectionView"("collectionId"); diff --git a/db/migrations/003_collections_propertyvalues_case.sql b/db/migrations/003_collections_propertyvalues_case.sql new file mode 100644 index 0000000..d6ffd01 --- /dev/null +++ b/db/migrations/003_collections_propertyvalues_case.sql @@ -0,0 +1,19 @@ +-- 003_collections_propertyvalues_case.sql — repair a quoting mistake in 002. +-- 002 created CollectionItem.propertyValues unquoted, so PostgreSQL folded it +-- to lowercase "propertyvalues" on databases where the migration's own DDL +-- materialized the table (existing installs upgraded through the ledger). +-- schema.sql and the API contract use the quoted camelCase name. 002 is +-- recorded and immutable, so the repair ships as this guarded rename; on +-- databases built from the corrected schema.sql the column already has the +-- right name and this is a no-op. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_attribute + WHERE attrelid='"CollectionItem"'::regclass + AND attname='propertyvalues' + AND NOT attisdropped + ) THEN + ALTER TABLE "CollectionItem" RENAME COLUMN propertyvalues TO "propertyValues"; + END IF; +END $$; diff --git a/db/migrations/004_bookmark_version.sql b/db/migrations/004_bookmark_version.sql new file mode 100644 index 0000000..213c040 --- /dev/null +++ b/db/migrations/004_bookmark_version.sql @@ -0,0 +1,7 @@ +-- 004_bookmark_version.sql — optimistic concurrency for bookmark updates +-- (review CR-04). Owner-driven PUT /api/bookmarks/{id} (fields and tag +-- replacement) now requires the expected version and bumps it; the +-- column-deletion move bumps it too, so a client holding a stale snapshot +-- gets a 409 instead of silently overwriting a concurrent edit. Sharing and +-- server-driven flags (alertSent) stay outside the contract. +ALTER TABLE "Bookmark" ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 0; diff --git a/db/migrations/005_contact_source.sql b/db/migrations/005_contact_source.sql new file mode 100644 index 0000000..f645bcd --- /dev/null +++ b/db/migrations/005_contact_source.sql @@ -0,0 +1,8 @@ +-- 005_contact_source.sql — CardDAV provenance for deletion reconciliation +-- (review CR-07). Sync mirrors the configured address book, scoped by this +-- column: only contacts previously imported from the SAME source URL are +-- candidates for remote-deletion removal; manually created contacts (NULL) +-- and other address books' imports are never touched. Existing rows keep +-- NULL and are adopted (stamped with the source) the next time the server +-- returns them. +ALTER TABLE "Contact" ADD COLUMN IF NOT EXISTS source text; diff --git a/db/migrations/006_bookmark_kanban_position.sql b/db/migrations/006_bookmark_kanban_position.sql new file mode 100644 index 0000000..c0dfd45 --- /dev/null +++ b/db/migrations/006_bookmark_kanban_position.sql @@ -0,0 +1,8 @@ +-- 006_bookmark_kanban_position.sql — persisted card ordering on kanban +-- boards. Every card move normalizes the target column's positions so a +-- drag inside a column survives reloads and concurrent moves stay +-- deterministic. Legacy rows default to 0 and keep their existing +-- updatedAt-descending order until the column is first arranged. +ALTER TABLE "Bookmark" ADD COLUMN IF NOT EXISTS "kanbanPosition" integer NOT NULL DEFAULT 0; +CREATE INDEX IF NOT EXISTS "Bookmark_ownerId_kanbanColumnId_kanbanPosition_idx" + ON "Bookmark"("ownerId","kanbanColumnId","kanbanPosition",id); diff --git a/db/migrations/007_page_search_index.sql b/db/migrations/007_page_search_index.sql new file mode 100644 index 0000000..6bb4fea --- /dev/null +++ b/db/migrations/007_page_search_index.sql @@ -0,0 +1,10 @@ +-- 007_page_search_index.sql — make page search index-backed and literal. +-- +-- The page list searched title/content with an unescaped ILIKE pattern: "%" +-- and "_" behaved as wildcards and every request scanned the owner's pages. +-- The handler now escapes those characters; these trigram indexes let +-- Postgres serve the substring predicate without a sequential scan. +-- pg_trgm is a trusted extension, so the database owner can enable it. +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE INDEX IF NOT EXISTS "Page_title_trgm_idx" ON "Page" USING gin (title gin_trgm_ops); +CREATE INDEX IF NOT EXISTS "Page_content_trgm_idx" ON "Page" USING gin (content gin_trgm_ops); diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..f7db726 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,351 @@ +-- computer database schema +-- Derived from computer_porting_guide.md section 2 and prisma/schema.prisma. +-- IDs are text (CUID/UUID). CASCADE / SetNull per the guide. + +-- Run against an EMPTY database. Never drops or recreates a database. +BEGIN; + +-- Trigram indexes back the page search substring predicate (review PERF-2). +-- pg_trgm is a trusted extension, so the database owner can enable it. +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- 2.1 legacy enum +CREATE TYPE "KanbanStatus" AS ENUM ('INBOX','TODO','IN_PROGRESS','DONE','ARCHIVED'); + +-- 2.2 User +CREATE TABLE "User" ( + id text PRIMARY KEY, + name text, + email text UNIQUE, + "emailVerified" timestamptz, + image text, + password text, + role text NOT NULL DEFAULT 'USER' CHECK (role IN ('USER','ADMIN')), + "tokenVersion" integer NOT NULL DEFAULT 1, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() +); + +-- 2.3 Account, Session, VerificationToken +CREATE TABLE "Account" ( + id text PRIMARY KEY, + "userId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + type text NOT NULL, + provider text NOT NULL, + "providerAccountId" text NOT NULL, + "refresh_token" text, + "access_token" text, + "expires_at" int, + "token_type" text, + scope text, + "id_token" text, + "session_state" text, + UNIQUE(provider, "providerAccountId") +); +CREATE INDEX "Account_userId_idx" ON "Account"("userId"); + +CREATE TABLE "Session" ( + id text PRIMARY KEY, + "sessionToken" text NOT NULL UNIQUE, + "userId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + expires timestamptz NOT NULL +); +CREATE INDEX "Session_userId_idx" ON "Session"("userId"); + +CREATE TABLE "VerificationToken" ( + identifier text NOT NULL, + token text NOT NULL UNIQUE, + expires timestamptz NOT NULL, + UNIQUE(identifier, token) +); + +-- 2.4 Bookmark +CREATE TABLE "Bookmark" ( + id text PRIMARY KEY, + url text NOT NULL, + title text, + description text, + favicon text, + "ogImage" text, + "ogTitle" text, + "ogDescription" text, + notes text, + "dueDate" timestamptz, + "alertAt" timestamptz, + "alertSent" boolean NOT NULL DEFAULT false, + "kanbanStatus" "KanbanStatus" NOT NULL DEFAULT 'INBOX', + "kanbanColumnId" text, + -- Board ordering within a column. Server-normalized on every card move; + -- legacy rows default to 0 and fall back to updatedAt-desc ordering. + "kanbanPosition" integer NOT NULL DEFAULT 0, + "imapWatchEnabled" boolean NOT NULL DEFAULT false, + "imapQuery" text, + "isPublic" boolean NOT NULL DEFAULT false, + "ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + "contactId" text, + -- Optimistic concurrency revision (review CR-04): every owner-driven PUT + -- (fields and tag replacement) requires the expected version and bumps it; + -- the column-deletion move bumps it too. Sharing and server-driven flags + -- (alertSent) are outside the contract — see API.md. + version integer NOT NULL DEFAULT 0, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX "Bookmark_ownerId_idx" ON "Bookmark"("ownerId"); +CREATE INDEX "Bookmark_ownerId_updatedAt_id_idx" ON "Bookmark"("ownerId","updatedAt" DESC,id DESC); +CREATE INDEX "Bookmark_contactId_idx" ON "Bookmark"("contactId"); +CREATE INDEX "Bookmark_kanbanStatus_idx" ON "Bookmark"("kanbanStatus"); +CREATE INDEX "Bookmark_kanbanColumnId_idx" ON "Bookmark"("kanbanColumnId"); +CREATE INDEX "Bookmark_ownerId_kanbanColumnId_kanbanPosition_idx" ON "Bookmark"("ownerId","kanbanColumnId","kanbanPosition",id); +CREATE INDEX "Bookmark_dueDate_idx" ON "Bookmark"("dueDate"); +CREATE INDEX "Bookmark_alertAt_idx" ON "Bookmark"("alertAt"); + +-- 2.5 Tag +CREATE TABLE "Tag" ( + id text PRIMARY KEY, + name text NOT NULL, + color text NOT NULL DEFAULT '#6366f1', + "ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now(), + UNIQUE(name, "ownerId") +); +CREATE INDEX "Tag_ownerId_idx" ON "Tag"("ownerId"); + +-- 2.6 BookmarkTag +CREATE TABLE "BookmarkTag" ( + "bookmarkId" text NOT NULL REFERENCES "Bookmark"(id) ON DELETE CASCADE, + "tagId" text NOT NULL REFERENCES "Tag"(id) ON DELETE CASCADE, + PRIMARY KEY ("bookmarkId", "tagId") +); +CREATE INDEX "BookmarkTag_tagId_idx" ON "BookmarkTag"("tagId"); + +-- 2.7 BookmarkShare +CREATE TABLE "BookmarkShare" ( + "bookmarkId" text NOT NULL REFERENCES "Bookmark"(id) ON DELETE CASCADE, + "userId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + "createdAt" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("bookmarkId", "userId") +); +CREATE INDEX "BookmarkShare_userId_idx" ON "BookmarkShare"("userId"); + +-- 2.8 TagShare +CREATE TABLE "TagShare" ( + "tagId" text NOT NULL REFERENCES "Tag"(id) ON DELETE CASCADE, + "userId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + "createdAt" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("tagId", "userId") +); +CREATE INDEX "TagShare_userId_idx" ON "TagShare"("userId"); + +-- 2.9 Contact +CREATE TABLE "Contact" ( + id text PRIMARY KEY, + uid text NOT NULL, + "displayName" text NOT NULL, + email text, + phone text, + "cardDavUrl" text, + -- CardDAV provenance (review CR-07): the address-book URL this contact was + -- imported from. Deletion reconciliation is scoped to it, so manually + -- created contacts (NULL) and other address books' imports are never + -- touched by a sync. + source text, + "userId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now(), + UNIQUE(uid, "userId") +); +CREATE INDEX "Contact_userId_idx" ON "Contact"("userId"); +ALTER TABLE "Bookmark" ADD CONSTRAINT "Bookmark_contactId_fkey" + FOREIGN KEY ("contactId") REFERENCES "Contact"(id) ON DELETE SET NULL; + +-- 2.10 ImapConfig +CREATE TABLE "ImapConfig" ( + id text PRIMARY KEY, + host text NOT NULL, + port int NOT NULL DEFAULT 993, + tls boolean NOT NULL DEFAULT true, + username text NOT NULL, + password text NOT NULL, + folder text NOT NULL DEFAULT 'INBOX', + "userId" text UNIQUE NOT NULL REFERENCES "User"(id) ON DELETE CASCADE +); + +-- 2.11 KanbanColumn +CREATE TABLE "KanbanColumn" ( + id text PRIMARY KEY, + label text NOT NULL, + color text NOT NULL DEFAULT '#64748b', + position int NOT NULL DEFAULT 0, + "key" text, + "userId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + "createdAt" timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX "KanbanColumn_userId_position_idx" ON "KanbanColumn"("userId", position); + +-- Bookmark.kanbanColumnId -> KanbanColumn (SetNull) +ALTER TABLE "Bookmark" ADD CONSTRAINT "Bookmark_kanbanColumnId_fkey" + FOREIGN KEY ("kanbanColumnId") REFERENCES "KanbanColumn"(id) ON DELETE SET NULL; + +-- 2.12 Page +CREATE TABLE "Page" ( + id text PRIMARY KEY, + title text NOT NULL, + content text NOT NULL DEFAULT '', + icon text, + pinned boolean NOT NULL DEFAULT false, + position int NOT NULL DEFAULT 0, + -- Optimistic concurrency revision (review DATA-01): content saves bump it; + -- a stale base version in PATCH is rejected with 409. + version integer NOT NULL DEFAULT 1, + "ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX "Page_ownerId_position_idx" ON "Page"("ownerId", position); +-- Index-backed substring search over the two searchable fields. +CREATE INDEX "Page_title_trgm_idx" ON "Page" USING gin (title gin_trgm_ops); +CREATE INDEX "Page_content_trgm_idx" ON "Page" USING gin (content gin_trgm_ops); + +-- 2.13 PageBookmark +CREATE TABLE "PageBookmark" ( + "pageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE, + "bookmarkId" text NOT NULL REFERENCES "Bookmark"(id) ON DELETE CASCADE, + position int NOT NULL DEFAULT 0, + "createdAt" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("pageId", "bookmarkId") +); +CREATE INDEX "PageBookmark_bookmarkId_idx" ON "PageBookmark"("bookmarkId"); + +-- 2.14 SignalSource +CREATE TABLE "SignalSource" ( + id text PRIMARY KEY, + type text NOT NULL, + config jsonb NOT NULL DEFAULT '{}', + enabled boolean NOT NULL DEFAULT true, + "lastCheckedAt" timestamptz, + "lastStatus" text, + "bookmarkId" text NOT NULL REFERENCES "Bookmark"(id) ON DELETE CASCADE, + "ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX "SignalSource_bookmarkId_idx" ON "SignalSource"("bookmarkId"); +CREATE INDEX "SignalSource_ownerId_idx" ON "SignalSource"("ownerId"); + +-- 2.14b SignalCheckpoint (review SYNC-02): durable per-source mailbox +-- ingestion state. A poll processes only UIDs newer than lastUid while +-- uidValidity matches; the checkpoint advances only after the batch's +-- signals are durably inserted. A uidValidity change resets ingestion. +CREATE TABLE "SignalCheckpoint" ( + "sourceId" text PRIMARY KEY REFERENCES "SignalSource"(id) ON DELETE CASCADE, + "uidValidity" bigint NOT NULL, + "lastUid" bigint NOT NULL, + "updatedAt" timestamptz NOT NULL DEFAULT now() +); + +-- 2.15 Signal +CREATE TABLE "Signal" ( + id text PRIMARY KEY, + "sourceType" text NOT NULL, + title text NOT NULL, + summary text, + url text, + "externalId" text, + "occurredAt" timestamptz NOT NULL DEFAULT now(), + read boolean NOT NULL DEFAULT false, + "sourceId" text, + "bookmarkId" text NOT NULL REFERENCES "Bookmark"(id) ON DELETE CASCADE, + "ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + "createdAt" timestamptz NOT NULL DEFAULT now(), + UNIQUE("sourceId", "externalId") +); +CREATE INDEX "Signal_bookmarkId_occurredAt_idx" ON "Signal"("bookmarkId", "occurredAt"); +CREATE INDEX "Signal_ownerId_occurredAt_idx" ON "Signal"("ownerId", "occurredAt"); +ALTER TABLE "Signal" ADD CONSTRAINT "Signal_sourceId_fkey" + FOREIGN KEY ("sourceId") REFERENCES "SignalSource"(id) ON DELETE SET NULL; + +-- 2.16 AppSetting: server-owned singleton settings rows. 'adminBootstrap' +-- records that initial provisioning completed (review AUTH-06), so the +-- absence of a qualifying administrator never re-opens first-signup +-- promotion after a demotion or removal. +CREATE TABLE "AppSetting" ( + key text PRIMARY KEY, + value jsonb NOT NULL DEFAULT '{}', + "updatedAt" timestamptz NOT NULL DEFAULT now() +); + +-- 2.16 SharedDate +CREATE TABLE "SharedDate" ( + id text PRIMARY KEY, + date date NOT NULL, + note text, + "creatorId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + "recipientId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + "createdAt" timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX "SharedDate_recipientId_idx" ON "SharedDate"("recipientId"); +CREATE INDEX "SharedDate_creatorId_idx" ON "SharedDate"("creatorId"); +CREATE INDEX "SharedDate_date_idx" ON "SharedDate"(date); +CREATE INDEX "SharedDate_creatorId_recipientId_date_idx" ON "SharedDate"("creatorId","recipientId",date); +CREATE INDEX "Bookmark_ownerId_alertAt_unsent_idx" ON "Bookmark"("ownerId","alertAt") WHERE NOT "alertSent"; + +-- 2.17 Collections (Notion-like upgrades phase 2; see 002_collections.sql for +-- the design decisions). Contextual workflow state: the same bookmark can +-- live in many collections with independent property values; membership +-- never broadens access and never owns the bookmark. +CREATE TABLE "Collection" ( + id text PRIMARY KEY, + name text NOT NULL, + description text, + icon text, + "ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + version integer NOT NULL DEFAULT 1, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX "Collection_ownerId_idx" ON "Collection"("ownerId"); + +CREATE TABLE "CollectionProperty" ( + id text PRIMARY KEY, + "collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE, + name text NOT NULL, + type text NOT NULL CHECK (type IN ('TEXT','NUMBER','SELECT','MULTI_SELECT','DATE','CHECKBOX','URL','RELATION')), + position integer NOT NULL DEFAULT 0, + config jsonb NOT NULL DEFAULT '{}', + version integer NOT NULL DEFAULT 1, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now(), + UNIQUE ("collectionId", name) +); + +CREATE TABLE "CollectionItem" ( + id text PRIMARY KEY, + "collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE, + "bookmarkId" text NOT NULL REFERENCES "Bookmark"(id) ON DELETE CASCADE, + "propertyValues" jsonb NOT NULL DEFAULT '{}', + position integer NOT NULL DEFAULT 0, + version integer NOT NULL DEFAULT 1, + "addedAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now(), + UNIQUE ("collectionId", "bookmarkId") +); +CREATE INDEX "CollectionItem_bookmarkId_idx" ON "CollectionItem"("bookmarkId"); + +CREATE TABLE "CollectionView" ( + id text PRIMARY KEY, + "collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE, + name text NOT NULL, + type text NOT NULL DEFAULT 'TABLE' CHECK (type IN ('TABLE','BOARD','CALENDAR','LIST')), + position integer NOT NULL DEFAULT 0, + filters jsonb NOT NULL DEFAULT '{"operator":"AND","clauses":[]}', + sorts jsonb NOT NULL DEFAULT '[]', + "groupByPropertyId" text REFERENCES "CollectionProperty"(id) ON DELETE SET NULL, + "displaySettings" jsonb NOT NULL DEFAULT '{}', + version integer NOT NULL DEFAULT 1, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX "CollectionView_collectionId_idx" ON "CollectionView"("collectionId"); +COMMIT; diff --git a/deploy/nginx/computer.conf.template b/deploy/nginx/computer.conf.template new file mode 100644 index 0000000..5bcb6e5 --- /dev/null +++ b/deploy/nginx/computer.conf.template @@ -0,0 +1,50 @@ +# nginx reverse proxy template for the computer stack. +# scripts/server-deploy.sh renders __DOMAIN__ and __WEB_PORT__ (the server's +# WEB_HOST_PORT binding) into the installed site config. The Go API is reached +# only through web, as in local dev. TLS certificates come from certbot +# (Let's Encrypt); see scripts/deploy.sh. + +server { + listen 80; + listen [::]:80; + server_name __DOMAIN__; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name __DOMAIN__; + + ssl_certificate /etc/letsencrypt/live/__DOMAIN__/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/__DOMAIN__/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1h; + ssl_prefer_server_ciphers off; + + # Security headers (review CFG-2): HSTS, frame protection. + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # CSP is deliberately NOT set here (review CR-10): the Next app emits a + # per-response nonce-based Content-Security-Policy from web/middleware.ts, + # which is what allows the App Router's inline bootstrap scripts to run. + # A second nginx-level CSP would intersect with it and strip the nonce, + # breaking hydration. nginx only passes the app's header through. + + location / { + proxy_pass http://127.0.0.1:__WEB_PORT__; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # WebSocket upgrade (harmless when unused). + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 75s; + } +} diff --git a/docker-compose.full.yml b/docker-compose.full.yml new file mode 100644 index 0000000..90d243e --- /dev/null +++ b/docker-compose.full.yml @@ -0,0 +1,3 @@ +# Compatibility alias: there is now only one complete stack definition. +include: + - docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9694a15 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,139 @@ +name: computer +services: + db: + image: postgres:16.15-alpine + restart: unless-stopped + environment: + POSTGRES_DB: computer + POSTGRES_USER: computer + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + volumes: + - pgdata:/var/lib/postgresql/data + - ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro + healthcheck: + test: [CMD-SHELL, pg_isready -h 127.0.0.1 -U computer -d computer] + interval: 3s + timeout: 3s + retries: 20 + networks: [backend] + + migrate: + image: postgres:16.15-alpine + restart: "no" + depends_on: + db: {condition: service_healthy} + environment: + PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + DB_HOST: db + DB_USER: computer + DB_NAME: computer + volumes: + - ./db:/db:ro + command: [sh, /db/migrate.sh] + networks: [backend] + + app: + build: . + restart: unless-stopped + depends_on: + migrate: {condition: service_completed_successfully} + environment: + DATABASE_URL: postgres://computer:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@db:5432/computer?sslmode=disable + AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env} + NEXTAUTH_SECRET: ${AUTH_SECRET} + # Optional dedicated key for stored integration credentials (review + # OPS-01): rotating AUTH_SECRET no longer invalidates IMAP passwords + # when ENCRYPTION_KEY is set. Back it up with the database. + ENCRYPTION_KEY: ${ENCRYPTION_KEY:-} + NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} + ADMIN_EMAILS: ${ADMIN_EMAILS:-} + COOKIE_SECURE: ${COOKIE_SECURE:-} + OIDC_ISSUER: ${OIDC_ISSUER:-} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} + ports: ["127.0.0.1:${APP_HOST_PORT:-8080}:8080"] + healthcheck: + test: [CMD, /computer, --healthcheck] + interval: 3s + timeout: 4s + retries: 20 + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + networks: [backend] + + web: + build: ./web + restart: unless-stopped + depends_on: + app: {condition: service_healthy} + environment: + API_BASE_URL: http://app:8080 + AUTH_SECRET: ${AUTH_SECRET} + NEXTAUTH_SECRET: ${AUTH_SECRET} + NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} + AUTH_TRUST_HOST: "true" + OIDC_ISSUER: ${OIDC_ISSUER:-} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} + ports: ["127.0.0.1:${WEB_HOST_PORT:-3000}:3000"] + read_only: true + tmpfs: [/tmp] + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + healthcheck: + # busybox wget from the alpine base; /login renders without a session. + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/login"] + interval: 10s + timeout: 4s + retries: 3 + start_period: 20s + networks: [backend] + + tests: + profiles: [test] + # Debian-based (not alpine): -race requires cgo, which needs gcc. + # Keep the Go version in lockstep with the app Dockerfile. + image: golang:1.26.1 + depends_on: + db: {condition: service_healthy} + working_dir: /src + # Mount only what `go test ./internal/...` needs — notably NOT the repo + # root, so the real .env (AUTH_SECRET, POSTGRES_PASSWORD) never reaches + # the test process; tests set their own secrets via t.Setenv. + volumes: + - ./go.mod:/src/go.mod:ro + - ./go.sum:/src/go.sum:ro + - ./internal:/src/internal:ro + - ./db:/src/db:ro + tmpfs: ["/tmp:exec"] + environment: + TEST_DATABASE_URL: postgres://computer:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@db:5432/computer?sslmode=disable + GOCACHE: /tmp/go-cache + GOMODCACHE: /tmp/go-mod + command: [go, test, -race, -count=1, -v, ./internal/...] + networks: [backend] + + # Fails when db/schema.sql drifts from the migration chain (review MIG-03). + converge: + profiles: [test] + image: postgres:16.15-alpine + restart: "no" + depends_on: + db: {condition: service_healthy} + environment: + PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + DB_HOST: db + DB_USER: computer + volumes: + - ./db:/db:ro + command: [sh, /db/check-convergence.sh] + networks: [backend] + +volumes: + pgdata: + name: ${POSTGRES_VOLUME:-computer_pgdata} + external: ${POSTGRES_VOLUME_EXTERNAL:-false} +networks: + backend: + driver: bridge diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6137775 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module github.com/andrew/go-computer + +go 1.26.0 + +require ( + github.com/emersion/go-imap v1.2.1 + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/jackc/pgx/v5 v5.11.0 + golang.org/x/crypto v0.58.0 + golang.org/x/net v0.61.0 +) + +require ( + github.com/emersion/go-message v0.15.0 // indirect + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect + github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/text v0.43.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bd2b457 --- /dev/null +++ b/go.sum @@ -0,0 +1,43 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= +github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= +github.com/emersion/go-message v0.15.0 h1:urgKGqt2JAc9NFJcgncQcohHdiYb803YTH9OQwHBHIY= +github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..aebe215 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,20 @@ +package auth + +type SigninRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} +type SignupRequest struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` +} + +// SignupResponse is deliberately value-identical for every accepted +// registration attempt (review CR-03): a populated internal identifier used +// to distinguish a fresh account from a duplicate/reserved address, which is +// an unauthenticated existence oracle. Ownership is established by logging +// in; /api/me then reports the id. +type SignupResponse struct { + OK bool `json:"ok"` +} diff --git a/internal/auth/bcrypt.go b/internal/auth/bcrypt.go new file mode 100644 index 0000000..616094f --- /dev/null +++ b/internal/auth/bcrypt.go @@ -0,0 +1,19 @@ +package auth + +import ( + "golang.org/x/crypto/bcrypt" +) + +// bcryptHash hashes a password with cost 12 (porting guide 3.1). +func bcryptHashImpl(password string) (string, error) { + h, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return "", err + } + return string(h), nil +} + +// bcryptCheck compares a password against a bcrypt hash. +func bcryptCheckImpl(password, hash string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go new file mode 100644 index 0000000..9003d56 --- /dev/null +++ b/internal/auth/handlers.go @@ -0,0 +1,263 @@ +package auth + +import ( + "encoding/json" + "errors" + "github.com/andrew/go-computer/internal/config" + "github.com/andrew/go-computer/internal/db" + "github.com/jackc/pgx/v5/pgconn" + "log" + "net/http" + "net/mail" + "net/url" + "os" + "strings" + "time" +) + +const bcryptCost = 12 + +// Signup field caps (review "cap input sizes"); the request-body limit alone +// is far too coarse for values copied into every user projection. +const ( + maxSignupEmailLen = 254 + maxSignupNameLen = 120 +) + +// dummyPasswordHash is bcrypt-cost-12 hash of a value no user can supply. +// Login compares against it for unknown accounts so timing does not reveal +// which emails are registered. +const dummyPasswordHash = "$2a$12$OeKCcVovKAAb7gKAhWqA..yGR3eoqGQAKzSrWwLryJgAro0Vc60WW" + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} +func failure(w http.ResponseWriter, status int, s string) { + writeJSON(w, status, map[string]string{"error": s}) +} +func decode(w http.ResponseWriter, r *http.Request, v any) bool { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + if json.NewDecoder(r.Body).Decode(v) != nil { + failure(w, 400, "Invalid JSON") + return false + } + return true +} +func HandleSignup(w http.ResponseWriter, r *http.Request) { + if !checkAuthRequest(w, r) { + return + } + if os.Getenv("SIGNUP_ENABLED") == "0" { + failure(w, 403, "Registration is disabled") + return + } + var req SignupRequest + if !decode(w, r, &req) { + return + } + req.Email = strings.ToLower(strings.TrimSpace(req.Email)) + if req.Email == "" || req.Password == "" { + failure(w, 400, "Email and password are required") + return + } + // Per-field caps (review "cap input sizes"): the 1 MiB body limit alone + // lets a name or address bloat every user projection and share picker. + if len(req.Email) > maxSignupEmailLen { + failure(w, 400, "Invalid email") + return + } + if len([]rune(req.Name)) > maxSignupNameLen { + failure(w, 400, "Name must be at most 120 characters") + return + } + if a, e := mail.ParseAddress(req.Email); e != nil || a.Address != req.Email { + failure(w, 400, "Invalid email") + return + } + if len(req.Password) < 8 { + failure(w, 400, "Password must be at least 8 characters") + return + } + if len(req.Password) > 72 { + failure(w, 400, "Password must be at most 72 bytes") + return + } + repo := db.NewUserRepo(db.FromContext(r.Context())) + // Account enumeration (review SEC-4, CR-03): a distinct 409 — or a + // distinct response BODY — for a registered address is an unauthenticated + // existence oracle. Duplicate registrations (and claims of + // operator-reserved addresses) are logged for the operator and answered + // with the exact same 201 {"ok":true} body as a real creation; the + // internal user id never leaves the server before authentication + // establishes ownership (/api/me). The subsequent login then either + // succeeds (the caller is the owner) or fails on credentials like any + // wrong password. + var existing bool + if _, e := repo.FindByEmail(r.Context(), req.Email); e == nil { + existing = true + } else if !errors.Is(e, db.ErrNotFound) { + failure(w, 500, "Database unavailable") + return + } + if config.IsAdminEmail(req.Email) { + log.Printf("signup: rejected claim of operator-reserved address %s", req.Email) + existing = true + } + if existing { + log.Printf("signup: duplicate registration attempt") + // Same bcrypt cost as real registrations, so timing does not + // re-open the existence oracle. + _, _ = bcryptHashImpl(req.Password) + writeJSON(w, 201, SignupResponse{true}) + return + } + hash, e := bcryptHashImpl(req.Password) + if e != nil { + failure(w, 500, "Unable to hash password") + return + } + if req.Name == "" { + req.Name = strings.SplitN(req.Email, "@", 2)[0] + } + if _, e := repo.Create(r.Context(), &db.NewUserInput{ID: db.NewID(), Name: req.Name, Email: req.Email, Password: hash, Role: "USER"}); e != nil { + var pg *pgconn.PgError + if errors.As(e, &pg) && pg.Code == "23505" { + // Racing creation: still indistinguishable. + writeJSON(w, 201, SignupResponse{true}) + return + } + failure(w, 500, "Unable to create user") + return + } + writeJSON(w, 201, SignupResponse{true}) +} + +// checkAuthRequest enforces the JSON Content-Type and same-origin guards on +// the unauthenticated auth endpoints. Applied to login and signup alike +// (review SEC-4): both are reachable directly on the Go API when the Next +// proxy's own Origin checks are bypassed. +func checkAuthRequest(w http.ResponseWriter, r *http.Request) bool { + if strings.ToLower(strings.TrimSpace(strings.SplitN(r.Header.Get("Content-Type"), ";", 2)[0])) != "application/json" { + failure(w, 415, "Content-Type must be application/json") + return false + } + if origin := r.Header.Get("Origin"); origin != "" { + allowed, err := url.Parse(config.Load().NextAuthURL) + scheme := "http" + // Behind a TLS-terminating proxy r.TLS is nil; X-Forwarded-Proto only + // affects the scheme half — the host must still match Origin, so this + // grants a spoofer nothing they control end-to-end. + if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") { + scheme = "https" + } + if origin != scheme+"://"+r.Host && (err != nil || allowed.Host == "" || origin != allowed.Scheme+"://"+allowed.Host) { + failure(w, 403, "Invalid request origin") + return false + } + } + return true +} +func HandleLogin(w http.ResponseWriter, r *http.Request) { + if !checkAuthRequest(w, r) { + return + } + var req SigninRequest + if !decode(w, r, &req) { + return + } + email := strings.ToLower(strings.TrimSpace(req.Email)) + // Review SEC-1: the failure lockout applies to every peer, loopback + // included — the exemption previously removed all guessing protection + // from same-host deployments. + if retry, locked := loginFailures.locked(EffectiveClientIP(r)+"|"+email, time.Now()); locked { + tooManyRequests(w, retry) + return + } + u, e := db.NewUserRepo(db.FromContext(r.Context())).FindByEmail(r.Context(), email) + if e != nil && !errors.Is(e, db.ErrNotFound) { + failure(w, 500, "Database unavailable") + return + } + // Unknown or password-less accounts still pay a bcrypt comparison so + // response latency does not reveal which emails are registered. + ok := false + if e == nil && u.Password != nil { + ok = bcryptCheckImpl(req.Password, *u.Password) + } else { + bcryptCheckImpl(req.Password, dummyPasswordHash) + } + if !ok { + loginFailures.noteFailure(EffectiveClientIP(r)+"|"+email, time.Now()) + failure(w, 401, "Invalid credentials") + return + } + loginFailures.noteSuccess(EffectiveClientIP(r) + "|" + email) + if e = WriteSessionCookie(w, SessionUser{ID: u.ID, Role: u.Role, Ver: u.TokenVersion}, config.Load().Secret()); e != nil { + failure(w, 500, "Session unavailable") + return + } + // tokenVersion rides along so the Next server can embed it in its session + // JWT and in every assertion it signs (review SEC-02); a revocation bump + // then invalidates NextAuth-backed sessions on their next backend call. + writeJSON(w, 200, map[string]any{"ok": true, "user": identity(u), "tokenVersion": u.TokenVersion}) +} +func HandleAuth(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/auth/session": + su, e := ReadSession(r, config.Load().Secret()) + if e != nil { + writeJSON(w, 200, nil) + return + } + u, e := db.NewUserRepo(db.FromContext(r.Context())).FindByID(r.Context(), su.ID) + if e != nil { + // Session error classification (review follow-up): distinguish + // authentication absence from backend degradation, exactly like + // AuthMiddleware — an unreachable database must answer 503 so + // client retry logic and monitoring see the difference instead of + // a degraded backend masquerading as an ordinary logout. + if errors.Is(e, db.ErrNotFound) { + writeJSON(w, 200, nil) + } else { + failure(w, 503, "Service unavailable") + } + return + } + if su.Ver > 0 && su.Ver != u.TokenVersion { + writeJSON(w, 200, nil) + return + } + writeJSON(w, 200, map[string]any{"user": identity(u)}) + case "/api/auth/signout": + if r.Method != "POST" { + failure(w, 405, "Use POST to sign out") + return + } + // Revoke every cookie JWT issued for this user, not just this + // browser's copy: signout means "sign out everywhere". + if su, e := ReadSession(r, config.Load().Secret()); e == nil { + if e := db.NewUserRepo(db.FromContext(r.Context())).BumpTokenVersion(r.Context(), su.ID); e != nil { + writeJSON(w, 500, map[string]string{"error": "Unable to sign out"}) + return + } + } + ClearSessionCookie(w) + // Review SEC-6: sign-out ends impersonation too — otherwise the next + // admin login on that browser would silently resume view-as. + http.SetCookie(w, &http.Cookie{Name: ViewAsCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + writeJSON(w, 200, map[string]bool{"ok": true}) + default: + failure(w, 404, "Unknown authentication endpoint") + } +} +func HandleMe(w http.ResponseWriter, r *http.Request) { + s, _ := SessionFromContext(r.Context()) + var viewing any + if s.ID != s.RealID { + viewing = map[string]any{"id": s.ID, "name": s.Name, "email": s.Email} + } + writeJSON(w, 200, map[string]any{"user": s.RealUser, "effectiveUser": map[string]any{"id": s.ID, "name": s.Name, "email": s.Email, "role": s.Role}, "isAdmin": s.RealRole == "ADMIN", "viewingAs": viewing}) +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..8bbdc6b --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,137 @@ +package auth + +import ( + "context" + "errors" + "github.com/andrew/go-computer/internal/config" + "github.com/andrew/go-computer/internal/db" + "github.com/andrew/go-computer/internal/models" + "log" + "net/http" + "sync" +) + +const ViewAsCookie = "computer_view_as" + +type SessionUser struct { + ID, Role, RealID, RealRole string + Name, Email *string + RealUser map[string]any + // Ver is the tokenVersion embedded in the credential (0 = no claim: + // pre-revocation legacy cookies only — assertions must carry it), checked + // against the user row so signout revokes cookie sessions AND NextAuth + // assertions alike (review SEC-02). + Ver int +} + +func identity(u *models.User) map[string]any { + return map[string]any{"id": u.ID, "name": u.Name, "email": u.Email, "image": u.Image, "role": u.Role} +} +func AuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s, e := ReadSession(r, config.Load().Secret()) + if e != nil { + failure(w, 401, "Unauthorized") + return + } + repo := db.NewUserRepo(db.FromContext(r.Context())) + u, e := repo.FindByID(r.Context(), s.ID) + if e != nil { + // Review CORR-03: a missing user is a credential problem (401), + // but backend degradation must not masquerade as one — surface it + // as 503 so client retry logic and monitoring see the difference. + if errors.Is(e, db.ErrNotFound) { + failure(w, 401, "Unauthorized") + } else { + log.Printf("auth: user lookup failed for session %s: %v", s.ID, e) + failure(w, 503, "Service unavailable") + } + return + } + // Revoked session: the credential's tokenVersion predates a signout + // bump. Applies to cookie sessions and NextAuth assertions alike. + if s.Ver > 0 && s.Ver != u.TokenVersion { + failure(w, 401, "Unauthorized") + return + } + // Promotion via ADMIN_EMAILS is persistent by design: removing an + // address from the env var later does NOT demote the account (use the + // Admin screen). Silent demotion on env drift would be worse than an + // extra explicit revocation step. + // + // Code review SEC-04 flagged that this re-promotes an account demoted + // via the Admin screen while its address remains in ADMIN_EMAILS. The + // env-driven promotion is a deliberate architectural decision here — + // demoting an operator account means editing ADMIN_EMAILS too; do not + // "fix" this silently. + if u.Role != "ADMIN" && u.Email != nil && u.EmailVerified != nil && config.IsAdminEmail(*u.Email) { + if e = repo.SetRole(r.Context(), u.ID, "ADMIN"); e != nil { + failure(w, 500, "Unable to resolve role") + return + } + u.Role = "ADMIN" + } + s.RealID, s.RealRole, s.RealUser = u.ID, u.Role, identity(u) + s.ID, s.Role, s.Name, s.Email = u.ID, u.Role, u.Name, u.Email + if u.Role == "ADMIN" { + if c, e := r.Cookie(ViewAsCookie); e == nil && c.Value != "" && c.Value != u.ID { + if target, e := repo.FindByID(r.Context(), c.Value); e == nil { + s.ID, s.Role, s.Name, s.Email = target.ID, target.Role, target.Name, target.Email + // Review SEC-6: impersonation is audited. Log at most once + // per admin/target/method combination per process so a + // polling UI does not flood the log. + auditViewAs(u.ID, target.ID, r.Method) + } + } + } + // Review SEC-6: view-as is read-only. Mutations while impersonating + // are attributed to the target user, so writes must come from the + // admin's own identity — exit view-as first. The view-as endpoint + // itself is exempt: it controls the impersonation session and must + // be writable to both start and stop the session. + if s.ID != s.RealID && r.Method != http.MethodGet && r.Method != http.MethodHead && r.URL.Path != "/api/admin/view-as" { + failure(w, 403, "Read-only while viewing as another user") + return + } + next.ServeHTTP(w, r.WithContext(WithSession(r.Context(), s))) + }) +} + +type sessionKey struct{} + +// auditViewAs logs each distinct (admin, target, method) impersonation +// observation once per process (review SEC-6). Ids only — never names, +// emails, or request payloads. +var ( + auditViewAsMu sync.Mutex + auditViewAsLog = map[string]bool{} +) + +func auditViewAs(adminID, targetID, method string) { + key := adminID + "|" + targetID + "|" + method + auditViewAsMu.Lock() + defer auditViewAsMu.Unlock() + if auditViewAsLog[key] { + return + } + auditViewAsLog[key] = true + log.Printf("audit: admin %s viewing as %s (%s)", adminID, targetID, method) +} + +func WithSession(ctx context.Context, s *SessionUser) context.Context { + return context.WithValue(ctx, sessionKey{}, s) +} +func SessionFromContext(ctx context.Context) (*SessionUser, bool) { + s, ok := ctx.Value(sessionKey{}).(*SessionUser) + return s, ok +} +func RequireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s, ok := SessionFromContext(r.Context()) + if !ok || s.RealRole != "ADMIN" { + failure(w, 403, "Forbidden") + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/auth/ratelimit.go b/internal/auth/ratelimit.go new file mode 100644 index 0000000..71e08d9 --- /dev/null +++ b/internal/auth/ratelimit.go @@ -0,0 +1,302 @@ +// Abuse protection for the unauthenticated auth endpoints: a fixed-window +// counter per IP plus consecutive-failure lockout per (IP, email) on login. +// +// Review SEC-1: the login endpoint and its failure lockout apply to EVERY +// peer, including loopback. In same-host non-Docker deployments the Next +// server reaches Go over localhost, and exempting that path removed all +// throttling from real browser logins (unlimited online password guessing). +// Signup keeps the loopback exemption: it exists for same-host consumers of +// the 127.0.0.1 binding, and a forwarded identity never earns it (SEC-2). +// +// Review AUTH-RATE: keying strictly on the direct peer made every browser +// behind the Next proxy share one bucket (in Compose, Go's peer is always +// the web container). The trusted Next server therefore forwards the browser +// IP it observed, signed with HMAC-SHA256(secret, ip|unix_ts) under the +// shared auth secret (web/lib/api.ts, web/auth.ts), with a 60s replay +// window. Go only accepts the forwarded identity when the signature and +// timestamp verify, so hostile clients cannot forge it; unsigned or invalid +// values fall back to the direct peer. Next forwards only X-Real-IP (never +// X-Forwarded-For) and only when TRUSTED_PROXY=1 pins the proxy that +// overwrites that header — the shipped nginx template does. + +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "github.com/andrew/go-computer/internal/config" + "net" + "net/http" + "sort" + "strconv" + "sync" + "time" +) + +const ( + loginIPLimit = 20 // logins per IP per window + loginIPWindow = time.Minute // + signupIPLimit = 10 // signups per IP per window + signupIPWindow = time.Hour // + failureThreshold = 5 // consecutive failures before lockout + failureLockout = 15 * time.Minute + failureWindow = time.Hour // failures older than this are forgotten + + // limiterMaxEntries is the soft cap per store. Review SEC-03: the failure + // tracker previously never evicted sub-threshold entries (zero until), so + // cycling unique emails grew the map without bound. Every mutator now + // sweeps once the cap is exceeded. + limiterMaxEntries = 4096 +) + +type counter struct { + count int + reset time.Time + until time.Time // lockout expiry (failure tracker only) +} + +type limiterStore struct { + mu sync.Mutex + m map[string]*counter +} + +func newLimiterStore() *limiterStore { return &limiterStore{m: map[string]*counter{}} } + +// sweep bounds memory (review SEC-03). Callers hold s.mu. Pass 1 drops +// entries whose window — and lockout, for the failure tracker — have expired. +// If a burst of still-live keys (e.g. unique-email login failures inside the +// failure window) keeps the store oversized, pass 2 evicts the oldest-tracked +// non-locked entries until the cap holds again; active lockouts are left +// alone so an eviction can never shorten a lockout. +func (s *limiterStore) sweep(now time.Time) { + if len(s.m) <= limiterMaxEntries { + return + } + for k, c := range s.m { + if now.After(c.reset) && (c.until.IsZero() || now.After(c.until)) { + delete(s.m, k) + } + } + if len(s.m) <= limiterMaxEntries { + return + } + keys := make([]string, 0, len(s.m)) + for k, c := range s.m { + if c.until.IsZero() || now.After(c.until) { + keys = append(keys, k) + } + } + sort.Slice(keys, func(i, j int) bool { return s.m[keys[i]].reset.Before(s.m[keys[j]].reset) }) + for _, k := range keys[:min(len(keys), len(s.m)-limiterMaxEntries)] { + delete(s.m, k) + } +} + +// allow reports whether key is within limit for the window starting at first +// use. Expiry returns the remaining time until the window resets so callers +// can send an accurate Retry-After header (review ERR-3). +func (s *limiterStore) allow(key string, limit int, window time.Duration, now time.Time) (bool, time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + s.sweep(now) + c := s.m[key] + if c == nil || now.After(c.reset) { + s.m[key] = &counter{count: 1, reset: now.Add(window)} + return true, 0 + } + c.count++ + retry := c.reset.Sub(now) + if retry < 0 { + retry = 0 + } + return c.count <= limit, retry.Truncate(time.Second) + time.Second +} + +func (s *limiterStore) locked(key string, now time.Time) (time.Duration, bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.sweep(now) + c := s.m[key] + if c == nil || c.until.IsZero() { + return 0, false + } + if now.After(c.until) { + delete(s.m, key) + return 0, false + } + // Consistent clock (review SEC-03): derive the retry delay from the + // supplied now rather than mixing in time.Until. + return c.until.Sub(now).Truncate(time.Second) + time.Second, true +} + +func (s *limiterStore) noteFailure(key string, now time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + s.sweep(now) + c := s.m[key] + if c == nil || now.After(c.reset) { + c = &counter{reset: now.Add(failureWindow)} + s.m[key] = c + } + c.count++ + if c.count >= failureThreshold { + c.until = now.Add(failureLockout) + } +} + +func (s *limiterStore) noteSuccess(key string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.m, key) +} + +var ( + loginIPs = newLimiterStore() + signupIPs = newLimiterStore() + loginFailures = newLimiterStore() +) + +// ResetRateLimits clears all limiter state. Tests only: the stores are +// process-global, and integration harnesses reset them per test server so +// sequential tests do not inherit each other's buckets (the login endpoint +// has no loopback exemption since review SEC-1). +func ResetRateLimits() { + for _, s := range []*limiterStore{loginIPs, signupIPs, loginFailures} { + s.mu.Lock() + s.m = map[string]*counter{} + s.mu.Unlock() + } +} + +// resetRateLimits clears all limiter state. Tests only. +func resetRateLimits() { ResetRateLimits() } + +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// Headers carrying the proxy-attested browser IP (review AUTH-RATE). The +// signature is HMAC-SHA256(secret, ip|unix_ts) in hex, computed by the +// trusted Next server; anything else falls back to the direct peer address. +// The timestamp bounds replay of a captured header pair to ~60s (review +// SEC-2). Next only forwards an IP when TRUSTED_PROXY=1 pins the boundary +// that overwrites X-Real-IP (the shipped nginx template does). +const ( + headerClientIP = "X-Computer-Client-Ip" + headerClientTs = "X-Computer-Client-Ts" + headerClientSig = "X-Computer-Client-Sig" + // forwardSkew bounds clock skew and replay of the signed identity. + forwardSkew = 60 * time.Second +) + +func SignClientIP(secret, ip string, ts int64) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(ip + "|" + strconv.FormatInt(ts, 10))) + return hex.EncodeToString(mac.Sum(nil)) +} + +func signedForwardIP(r *http.Request) (string, bool) { + ip := r.Header.Get(headerClientIP) + sig := r.Header.Get(headerClientSig) + tsRaw := r.Header.Get(headerClientTs) + if ip == "" || sig == "" || tsRaw == "" || net.ParseIP(ip) == nil { + return "", false + } + ts, err := strconv.ParseInt(tsRaw, 10, 64) + if err != nil { + return "", false + } + now := time.Now().Unix() + if ts > now+int64(forwardSkew/time.Second) || ts < now-int64(forwardSkew/time.Second) { + return "", false + } + secret := config.Load().Secret() + if secret == "" { + return "", false + } + if !hmac.Equal([]byte(SignClientIP(secret, ip, ts)), []byte(sig)) { + return "", false + } + return ip, true +} + +// EffectiveClientIP is the identity rate limiting buckets on: the signed +// forwarded browser IP when the trusted proxy attested one, else the direct +// peer. Never trust arbitrary forwarded headers here. +func EffectiveClientIP(r *http.Request) string { + if ip, ok := signedForwardIP(r); ok { + return ip + } + return clientIP(r) +} + +// isLoopback reports whether the DIRECT peer is localhost. A forwarded +// identity — even a signed loopback one — never earns the exemption (review +// SEC-2): the exemption exists for same-host consumers of the 127.0.0.1 +// binding, not for anything arriving through a proxy. Plain X-Forwarded-For +// is client-controlled and never trusted. +func isLoopback(r *http.Request) bool { + if _, forwarded := signedForwardIP(r); forwarded { + return false + } + ip := net.ParseIP(clientIP(r)) + return ip != nil && ip.IsLoopback() +} + +func tooManyRequests(w http.ResponseWriter, retry time.Duration) { + w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds()))) + failure(w, http.StatusTooManyRequests, "Too many attempts, slow down") +} + +// RateLimitLogin caps login attempts per effective client IP for every peer +// (review SEC-1: loopback is NOT exempt on the login endpoint). +func RateLimitLogin(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Review ERR-3: Retry-After carries the remaining window time, not + // the full window. + if ok, retry := loginIPs.allow(EffectiveClientIP(r), loginIPLimit, loginIPWindow, time.Now()); !ok { + tooManyRequests(w, retry) + return + } + next(w, r) + } +} + +// RateLimitSignup caps account creation per effective client IP for +// non-loopback peers. +func RateLimitSignup(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !isLoopback(r) { + if ok, retry := signupIPs.allow(EffectiveClientIP(r), signupIPLimit, signupIPWindow, time.Now()); !ok { + tooManyRequests(w, retry) + return + } + } + next(w, r) + } +} + +// NewLimiterStore creates a new in-memory rate limiter store. Exported so +// callers (e.g. public endpoints) can have independent buckets. +func NewLimiterStore() *limiterStore { return newLimiterStore() } + +// Limiter wraps a handler with a simple per-IP fixed-window rate limit. +// Uses the direct peer IP (no forwarded headers) and does not exempt +// loopback — public endpoints apply to every peer. +func Limiter(s *limiterStore, limit int, window time.Duration) func(http.HandlerFunc) http.HandlerFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r) + if ok, retry := s.allow(ip, limit, window, time.Now()); !ok { + tooManyRequests(w, retry) + return + } + next(w, r) + } + } +} diff --git a/internal/auth/ratelimit_test.go b/internal/auth/ratelimit_test.go new file mode 100644 index 0000000..884b98d --- /dev/null +++ b/internal/auth/ratelimit_test.go @@ -0,0 +1,245 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" +) + +func TestLimiterStoreWindow(t *testing.T) { + s := newLimiterStore() + now := time.Now() + for i := 0; i < 5; i++ { + if ok, _ := s.allow("ip", 5, time.Minute, now.Add(time.Duration(i)*time.Second)); !ok { + t.Fatalf("request %d within limit rejected", i+1) + } + } + // Review ERR-3: the rejection carries the remaining window time. + ok, retry := s.allow("ip", 5, time.Minute, now.Add(10*time.Second)) + if ok { + t.Fatal("request over limit accepted") + } + if retry <= 0 || retry > time.Minute { + t.Fatalf("retry-after %v outside the remaining window", retry) + } + if ok, _ := s.allow("ip", 5, time.Minute, now.Add(61*time.Second)); !ok { + t.Fatal("request after window reset rejected") + } + if ok, _ := s.allow("other", 5, time.Minute, now); !ok { + t.Fatal("separate key shares the limit") + } +} + +func TestFailureLockout(t *testing.T) { + s := newLimiterStore() + now := time.Now() + for i := 0; i < failureThreshold-1; i++ { + s.noteFailure("ip|user", now.Add(time.Duration(i)*time.Second)) + } + if _, locked := s.locked("ip|user", now.Add(time.Minute)); locked { + t.Fatal("locked below threshold") + } + s.noteFailure("ip|user", now.Add(time.Minute)) + retry, locked := s.locked("ip|user", now.Add(2*time.Minute)) + if !locked || retry <= 0 { + t.Fatalf("not locked after %d failures: %v %v", failureThreshold, retry, locked) + } + s.noteSuccess("ip|user") + if _, locked := s.locked("ip|user", now.Add(2*time.Minute)); locked { + t.Fatal("successful login did not clear the lockout") + } + if _, locked := s.locked("ip|user", now.Add(failureLockout+time.Hour)); locked { + t.Fatal("lockout did not expire") + } +} + +// Review SEC-03: sub-threshold failure entries (zero until) must not +// accumulate without bound when an attacker cycles unique emails. +func TestFailureTrackerEviction(t *testing.T) { + s := newLimiterStore() + now := time.Now() + // Entries tracked two hours ago are fully expired (failureWindow passed). + old := now.Add(-2 * time.Hour) + for i := 0; i < limiterMaxEntries+500; i++ { + s.noteFailure(fmt.Sprintf("ip|user%d", i), old) + } + if len(s.m) > limiterMaxEntries+1 { + t.Fatalf("live-key burst exceeded the soft cap: %d entries", len(s.m)) + } + // A later call sweeps the fully expired burst. + s.noteFailure("ip|fresh", now) + if len(s.m) != 1 { + t.Fatalf("expired failure entries not evicted: %d remain", len(s.m)) + } + // Active lockouts survive eviction from an oversized store. + s2 := newLimiterStore() + for i := 0; i < failureThreshold; i++ { + s2.noteFailure("ip|locked", now.Add(time.Duration(i)*time.Second)) + } + for i := 0; i < limiterMaxEntries+500; i++ { + s2.noteFailure(fmt.Sprintf("ip|user%d", i), now) + } + if _, locked := s2.locked("ip|locked", now.Add(time.Minute)); !locked { + t.Fatal("active lockout evicted by the memory sweep") + } +} + +func dummyHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } + +func TestRateLimitMiddleware(t *testing.T) { + resetRateLimits() + t.Cleanup(resetRateLimits) + + // Review SEC-1: loopback is NOT exempt on the login endpoint — same-host + // deployments must keep throttling for real browser logins. + loop := httptest.NewRequest("POST", "/api/auth/login", nil) + loop.RemoteAddr = "127.0.0.1:54321" + var loopLast *httptest.ResponseRecorder + for i := 0; i < loginIPLimit+1; i++ { + loopLast = httptest.NewRecorder() + RateLimitLogin(dummyHandler)(loopLast, loop) + } + if loopLast.Code != http.StatusTooManyRequests { + t.Fatalf("loopback login exempt: got %d", loopLast.Code) + } + // Signup keeps the loopback exemption for host-local consumers. + resetRateLimits() + var loopSignup *httptest.ResponseRecorder + for i := 0; i < signupIPLimit*2; i++ { + loopSignup = httptest.NewRecorder() + RateLimitSignup(dummyHandler)(loopSignup, loop) + } + if loopSignup.Code != 204 { + t.Fatalf("loopback signup limited with %d", loopSignup.Code) + } + resetRateLimits() + + // Non-loopback peers get 429 after the window limit, with Retry-After. + remote := httptest.NewRequest("POST", "/api/auth/login", nil) + remote.RemoteAddr = "203.0.113.7:1234" + var last *httptest.ResponseRecorder + for i := 0; i < loginIPLimit+1; i++ { + last = httptest.NewRecorder() + RateLimitLogin(dummyHandler)(last, remote) + } + if last.Code != http.StatusTooManyRequests { + t.Fatalf("expected 429 after limit, got %d", last.Code) + } + if last.Header().Get("Retry-After") == "" { + t.Fatal("429 without Retry-After header") + } + + // Signup limiter counts independently. + resetRateLimits() + var signupLast *httptest.ResponseRecorder + for i := 0; i < signupIPLimit+1; i++ { + signupLast = httptest.NewRecorder() + RateLimitSignup(dummyHandler)(signupLast, remote) + } + if signupLast.Code != http.StatusTooManyRequests { + t.Fatalf("signup: expected 429 after limit, got %d", signupLast.Code) + } + w := httptest.NewRecorder() + RateLimitLogin(dummyHandler)(w, remote) + if w.Code != 204 { + t.Fatalf("signup exhaustion must not limit login: %d", w.Code) + } +} + +// Review AUTH-RATE: Go accepts a forwarded client IP only when the trusted +// Next proxy signed it with the shared secret; forged headers fall back to +// the direct peer, so one browser cannot evade or poison another's bucket. +func TestSignedForwardedClientIP(t *testing.T) { + t.Setenv("NEXTAUTH_SECRET", "") + t.Setenv("AUTH_SECRET", "ratelimit-forward-secret") + ts := time.Now().Unix() + sign := func(ip string, when int64) (string, string) { + mac := hmac.New(sha256.New, []byte("ratelimit-forward-secret")) + mac.Write([]byte(ip + "|" + strconv.FormatInt(when, 10))) + return strconv.FormatInt(when, 10), hex.EncodeToString(mac.Sum(nil)) + } + r := httptest.NewRequest("POST", "/api/auth/login", nil) + r.RemoteAddr = "203.0.113.9:5555" + if got := EffectiveClientIP(r); got != "203.0.113.9" { + t.Fatalf("unsigned request: got %s", got) + } + hts, sig := sign("198.51.100.7", ts) + r.Header.Set(headerClientIP, "198.51.100.7") + r.Header.Set(headerClientTs, hts) + r.Header.Set(headerClientSig, sig) + if got := EffectiveClientIP(r); got != "198.51.100.7" { + t.Fatalf("signed forwarded IP ignored: got %s", got) + } + _, badSig := sign("198.51.100.8", ts) + r.Header.Set(headerClientSig, badSig) + if got := EffectiveClientIP(r); got != "203.0.113.9" { + t.Fatalf("forged signature accepted: got %s", got) + } + goodTs, goodSig := sign("198.51.100.7", ts) + r.Header.Set(headerClientSig, goodSig) + r.Header.Set(headerClientTs, goodTs) + r.Header.Set(headerClientIP, "198.51.100.7; DROP TABLE users") + if got := EffectiveClientIP(r); got != "203.0.113.9" { + t.Fatalf("non-IP forwarded value accepted: got %s", got) + } + // Review SEC-2: a forwarded identity never earns the loopback exemption, + // even when it attests 127.0.0.1; and stale timestamps fall back to the + // direct peer. + loopTs, loopSig := sign("127.0.0.1", ts) + r.Header.Set(headerClientIP, "127.0.0.1") + r.Header.Set(headerClientTs, loopTs) + r.Header.Set(headerClientSig, loopSig) + if isLoopback(r) { + t.Fatal("signed loopback forwarded IP must not earn the exemption") + } + staleTs, staleSig := sign("198.51.100.7", ts-3600) + r.Header.Set(headerClientIP, "198.51.100.7") + r.Header.Set(headerClientTs, staleTs) + r.Header.Set(headerClientSig, staleSig) + if got := EffectiveClientIP(r); got != "203.0.113.9" { + t.Fatalf("stale signature accepted: got %s", got) + } +} + +func TestForwardedIPsGetSeparateBuckets(t *testing.T) { + t.Setenv("NEXTAUTH_SECRET", "") + t.Setenv("AUTH_SECRET", "ratelimit-forward-secret") + resetRateLimits() + ts := time.Now().Unix() + sign := func(ip string) (string, string) { + mac := hmac.New(sha256.New, []byte("ratelimit-forward-secret")) + mac.Write([]byte(ip + "|" + strconv.FormatInt(ts, 10))) + return strconv.FormatInt(ts, 10), hex.EncodeToString(mac.Sum(nil)) + } + handler := RateLimitLogin(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) + do := func(ip string) int { + r := httptest.NewRequest("POST", "/api/auth/login", nil) + r.RemoteAddr = "10.0.0.5:1234" // shared proxy peer + hts, sig := sign(ip) + r.Header.Set(headerClientIP, ip) + r.Header.Set(headerClientTs, hts) + r.Header.Set(headerClientSig, sig) + w := httptest.NewRecorder() + handler(w, r) + return w.Code + } + for i := 0; i < loginIPLimit; i++ { + if code := do("198.51.100.1"); code != 200 { + t.Fatalf("browser 1 request %d: %d", i+1, code) + } + } + if code := do("198.51.100.1"); code != http.StatusTooManyRequests { + t.Fatalf("browser 1 over limit: %d", code) + } + // An unrelated browser behind the same proxy keeps its own budget. + if code := do("198.51.100.2"); code != 200 { + t.Fatalf("browser 2 blocked by browser 1: %d", code) + } + resetRateLimits() +} diff --git a/internal/auth/sessions.go b/internal/auth/sessions.go new file mode 100644 index 0000000..61f27a8 --- /dev/null +++ b/internal/auth/sessions.go @@ -0,0 +1,180 @@ +package auth + +import ( + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/andrew/go-computer/internal/config" + "github.com/golang-jwt/jwt/v5" +) + +// SessionStrategy mirrors the original NextAuth setup: a stateless signed JWT +// cookie. The jwt callback stores token.id = user.id and token.provider; the +// session callback copies token.id to session.user.id (porting guide 3.1). +const ( + sessionCookieName = "computer_session" + // time.Duration is nanoseconds, so multiply by time.Second explicitly. + sessionMaxAge = 30 * 24 * 60 * 60 * time.Second // 30 days + + // Token types bind each token to its channel: the trusted Next server + // signs short-lived "assertion" tokens for the Bearer header; Go signs + // 30-day "session" tokens for its own cookie. Neither is accepted on the + // other channel, so a long-lived cookie JWT can never be replayed as a + // Bearer credential (and vice versa). + TokenTypeSession = "session" + TokenTypeAssertion = "assertion" + // maxAssertionLifetime is the server-side cap for Bearer assertions; the + // Next server signs 60-second tokens. + maxAssertionLifetime = 120 * time.Second +) + +// SessionClaims is the JWT payload. +type SessionClaims struct { + UserID string `json:"uid"` + Role string `json:"role"` + Type string `json:"typ,omitempty"` + // Ver mirrors User.tokenVersion (≥1). Required on assertions (review + // SEC-02: a tokenVersion bump must revoke NextAuth-backed sessions too, + // not just Go cookie sessions); pre-revocation legacy cookies omit it and + // skip the revocation check. + Ver int `json:"ver,omitempty"` + jwt.RegisteredClaims +} + +// SignSession issues a signed JWT cookie value for the user. +func SignSession(u SessionUser, secret string) (string, error) { + if secret == "" { + return "", fmt.Errorf("auth: empty session secret") + } + now := time.Now() + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, SessionClaims{ + UserID: u.ID, + Role: u.Role, + Type: TokenTypeSession, + Ver: u.Ver, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(sessionMaxAge)), + Issuer: "computer", + }, + }) + return tok.SignedString([]byte(secret)) +} + +// VerifySession decodes a JWT cookie value into the session user. It checks +// signature, algorithm, issuer and expiry — but NOT the channel binding; use +// ReadSession for request handling. +func VerifySession(token, secret string) (*SessionUser, error) { + claims, err := verifyClaims(token, secret) + if err != nil { + return nil, err + } + return &SessionUser{ID: claims.UserID, Role: claims.Role, Ver: claims.Ver}, nil +} + +func verifyClaims(token, secret string) (*SessionClaims, error) { + if secret == "" { + return nil, fmt.Errorf("auth: empty session secret") + } + tok, err := jwt.ParseWithClaims(token, &SessionClaims{}, func(t *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer("computer"), jwt.WithExpirationRequired()) + if err != nil { + return nil, err + } + claims, ok := tok.Claims.(*SessionClaims) + if !ok || !tok.Valid || claims.UserID == "" { + return nil, errors.New("auth: invalid token") + } + return claims, nil +} + +// ReadSession pulls the session credential from the request and verifies it, +// enforcing the channel binding: Bearer requires a short-lived assertion; the +// cookie requires a session token (tokens issued before typ existed, i.e. no +// typ claim, remain valid on the cookie channel only). +func ReadSession(r *http.Request, secret string) (*SessionUser, error) { + if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { + claims, err := verifyClaims(strings.TrimPrefix(h, "Bearer "), secret) + if err != nil { + return nil, err + } + if claims.Type != TokenTypeAssertion { + return nil, errors.New("auth: bearer token is not an assertion") + } + if claims.IssuedAt == nil || claims.ExpiresAt == nil || claims.ExpiresAt.Sub(claims.IssuedAt.Time) > maxAssertionLifetime { + return nil, errors.New("auth: assertion lifetime too long") + } + // Review SEC-02: assertions must carry the session's tokenVersion so a + // revocation bump ("sign out everywhere") invalidates NextAuth-backed + // browser sessions on their next backend call, instead of leaving them + // minting valid assertions until the NextAuth JWT expires. + if claims.Ver < 1 { + return nil, errors.New("auth: assertion missing token version") + } + return &SessionUser{ID: claims.UserID, Role: claims.Role, Ver: claims.Ver}, nil + } + c, err := r.Cookie(sessionCookieName) + if err != nil { + return nil, err + } + claims, err := verifyClaims(c.Value, secret) + if err != nil { + return nil, err + } + if claims.Type != "" && claims.Type != TokenTypeSession { + return nil, errors.New("auth: cookie token has wrong type") + } + // Review SEC-7: legacy session cookies without a `ver` claim (Ver == 0) + // were minted before revocation was implemented and would bypass the + // tokenVersion check entirely ("sign out everywhere" would not affect + // them until expiry). Treat a missing/zero ver as revoked so users must + // re-authenticate. + if claims.Ver < 1 { + return nil, errors.New("auth: session lacks required version claim") + } + return &SessionUser{ID: claims.UserID, Role: claims.Role, Ver: claims.Ver}, nil +} + +// WriteSessionCookie sets the signed session cookie on the response. +func WriteSessionCookie(w http.ResponseWriter, u SessionUser, secret string) error { + tok, err := SignSession(u, secret) + if err != nil { + return err + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: tok, + Path: "/", + MaxAge: int(sessionMaxAge.Seconds()), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: isSecure(), + }) + return nil +} + +// ClearSessionCookie expires the session cookie. +func ClearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: isSecure(), + }) +} + +// isSecure derives the cookie Secure flag from configuration: explicit +// COOKIE_SECURE wins, otherwise an https NEXTAUTH_URL implies secure. +func isSecure() bool { + return config.Load().CookieSecure() +} + +// ErrUnauthorized is returned when there is no valid session. +var ErrUnauthorized = errors.New("auth: unauthorized") diff --git a/internal/auth/sessions_test.go b/internal/auth/sessions_test.go new file mode 100644 index 0000000..d78cd7f --- /dev/null +++ b/internal/auth/sessions_test.go @@ -0,0 +1,113 @@ +package auth + +import ( + "github.com/golang-jwt/jwt/v5" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestSessionLifetimeAndVerification(t *testing.T) { + token, e := SignSession(SessionUser{ID: "alice"}, "test-secret") + if e != nil { + t.Fatal(e) + } + s, e := VerifySession(token, "test-secret") + if e != nil || s.ID != "alice" { + t.Fatalf("session: %v %v", s, e) + } + var claims SessionClaims + _, _, e = jwt.NewParser().ParseUnverified(token, &claims) + if e != nil { + t.Fatal(e) + } + if d := claims.ExpiresAt.Sub(claims.IssuedAt.Time); d != 30*24*time.Hour { + t.Fatalf("token lifetime: %v", d) + } + if _, e = VerifySession(token, "wrong-secret"); e == nil { + t.Fatal("accepted wrong secret") + } + expired := jwt.NewWithClaims(jwt.SigningMethodHS256, SessionClaims{UserID: "alice", RegisteredClaims: jwt.RegisteredClaims{Issuer: "computer", ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour))}}) + v, _ := expired.SignedString([]byte("test-secret")) + if _, e = VerifySession(v, "test-secret"); e == nil { + t.Fatal("accepted expired token") + } + token256 := jwt.NewWithClaims(jwt.SigningMethodHS384, SessionClaims{UserID: "alice", RegisteredClaims: jwt.RegisteredClaims{Issuer: "computer", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))}}) + v, _ = token256.SignedString([]byte("test-secret")) + if _, e = VerifySession(v, "test-secret"); e == nil { + t.Fatal("accepted unexpected algorithm") + } +} + +func signTestToken(t *testing.T, typ string, ttl time.Duration, ver int) string { + t.Helper() + now := time.Now() + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, SessionClaims{ + UserID: "alice", Type: typ, Ver: ver, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "computer", + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + }, + }) + v, e := tok.SignedString([]byte("test-secret")) + if e != nil { + t.Fatal(e) + } + return v +} + +// The Bearer channel accepts only short-lived assertions carrying a token +// version (review SEC-02); the cookie channel accepts only session tokens (or +// legacy tokens issued before typ existed). A 30-day cookie JWT must never +// work as a Bearer credential. +func TestTokenChannelBinding(t *testing.T) { + assertion := signTestToken(t, TokenTypeAssertion, 60*time.Second, 3) + versionlessAssertion := signTestToken(t, TokenTypeAssertion, 60*time.Second, 0) + longAssertion := signTestToken(t, TokenTypeAssertion, time.Hour, 3) + session, e := SignSession(SessionUser{ID: "alice"}, "test-secret") + if e != nil { + t.Fatal(e) + } + legacy := signTestToken(t, "", time.Hour, 0) + // Review SEC-7: cookies now require Ver >= 1. Create a versioned session + // cookie for the channel-binding test. + versionedSession := signTestToken(t, TokenTypeSession, time.Hour, 3) + + bearer := func(token string) (*SessionUser, error) { + r := httptest.NewRequest("GET", "/api/me", nil) + r.Header.Set("Authorization", "Bearer "+token) + return ReadSession(r, "test-secret") + } + cookie := func(token string) (*SessionUser, error) { + r := httptest.NewRequest("GET", "/api/me", nil) + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token}) + return ReadSession(r, "test-secret") + } + + if s, e := bearer(assertion); e != nil || s.ID != "alice" || s.Ver != 3 { + t.Fatalf("valid assertion rejected or lost its version: %v %v", s, e) + } + if _, e := bearer(versionlessAssertion); e == nil { + t.Fatal("assertion without token version accepted (revocation bypass)") + } + if _, e := bearer(session); e == nil { + t.Fatal("30-day session token accepted as Bearer assertion") + } + if _, e := bearer(longAssertion); e == nil { + t.Fatal("over-long assertion accepted (exp-iat cap not enforced)") + } + if _, e := bearer(legacy); e == nil { + t.Fatal("typ-less token accepted as Bearer assertion") + } + if s, e := cookie(versionedSession); e != nil || s.ID != "alice" { + t.Fatalf("valid session cookie rejected: %v %v", s, e) + } + if s, e := cookie(legacy); e == nil { + t.Fatalf("legacy typ-less cookie with Ver=0 must be rejected: %v %v", s, e) + } + if _, e := cookie(assertion); e == nil { + t.Fatal("assertion accepted as cookie session") + } +} diff --git a/internal/auth/signup_validation_test.go b/internal/auth/signup_validation_test.go new file mode 100644 index 0000000..7861827 --- /dev/null +++ b/internal/auth/signup_validation_test.go @@ -0,0 +1,38 @@ +package auth + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "strings" + "testing" +) + +// Signup input validation must reject before any database access, so these +// run without a DB context. +func TestSignupValidation(t *testing.T) { + cases := []struct { + name string + body map[string]any + }{ + {"empty", map[string]any{}}, + {"missing password", map[string]any{"email": "ok@computer.test"}}, + {"invalid email", map[string]any{"email": "not-an-email", "password": "long-enough-password"}}, + {"email with display name", map[string]any{"email": "Name ", "password": "long-enough-password"}}, + {"short password", map[string]any{"email": "ok@computer.test", "password": "short"}}, + {"7 boundary password", map[string]any{"email": "ok@computer.test", "password": "1234567"}}, + {"overlong password", map[string]any{"email": "ok@computer.test", "password": strings.Repeat("a", 73)}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + raw, _ := json.Marshal(tc.body) + req := httptest.NewRequest("POST", "/api/signup", bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + HandleSignup(w, req) + if w.Code != 400 { + t.Fatalf("wanted 400, got %d: %s", w.Code, w.Body.String()) + } + }) + } +} diff --git a/internal/authguard/authguard.go b/internal/authguard/authguard.go new file mode 100644 index 0000000..775a9e7 --- /dev/null +++ b/internal/authguard/authguard.go @@ -0,0 +1,4 @@ +// Package authguard owns administrative handlers. Session resolution lives in +// auth.AuthMiddleware: real identity governs administration; effective identity +// governs all per-user data queries. +package authguard diff --git a/internal/authguard/handlers.go b/internal/authguard/handlers.go new file mode 100644 index 0000000..0a27372 --- /dev/null +++ b/internal/authguard/handlers.go @@ -0,0 +1,88 @@ +package authguard + +import ( + "encoding/json" + "github.com/andrew/go-computer/internal/auth" + "github.com/andrew/go-computer/internal/config" + "github.com/andrew/go-computer/internal/db" + "github.com/andrew/go-computer/internal/handlers" + "log" + "net/http" + "time" +) + +func HandleAdminListUsers(w http.ResponseWriter, r *http.Request) { + // COALESCE(email,'') (review COR-8): NOT LIKE evaluates to NULL for the + // NULL emails OIDC-sourced accounts can carry, which hid them from the + // admin list entirely. + v, e := db.Many(r.Context(), db.FromContext(r.Context()), `SELECT jsonb_build_object('id',u.id,'name',u.name,'email',u.email,'image',u.image,'role',u.role,'createdAt',u."createdAt",'_count',jsonb_build_object('bookmarks',(SELECT count(*) FROM "Bookmark" b WHERE b."ownerId"=u.id),'pages',(SELECT count(*) FROM "Page" p WHERE p."ownerId"=u.id),'tags',(SELECT count(*) FROM "Tag" t WHERE t."ownerId"=u.id))) FROM "User" u WHERE COALESCE(email,'') NOT LIKE '%@example.com' ORDER BY "createdAt"`) + if e != nil { + handlers.WriteJSON(w, 500, handlers.Error{Error: "Database unavailable"}) + return + } + handlers.WriteJSON(w, 200, v) +} +func HandleAdminPatchUser(w http.ResponseWriter, r *http.Request) { + var b struct { + UserID string `json:"userId"` + Role string `json:"role"` + } + if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&b) != nil || (b.Role != "USER" && b.Role != "ADMIN") { + handlers.WriteJSON(w, 400, handlers.Error{Error: "Invalid role"}) + return + } + s, _ := auth.SessionFromContext(r.Context()) + if b.UserID == s.RealID && b.Role != "ADMIN" { + handlers.WriteJSON(w, 400, handlers.Error{Error: "You cannot demote yourself"}) + return + } + d := db.FromContext(r.Context()) + repo := db.NewUserRepo(d) + target, e := repo.FindByID(r.Context(), b.UserID) + if e != nil { + handlers.WriteJSON(w, 404, handlers.Error{Error: "Not found"}) + return + } + // Review SEC-11: an installation must keep at least one administrator. + // Self-demotion is already refused; this closes the "demote the last + // other admin" path. + if b.Role == "USER" && target.Role == "ADMIN" { + var admins int + if e := d.QueryRowContext(r.Context(), `SELECT count(*) FROM "User" WHERE role='ADMIN' AND COALESCE(email,'') NOT LIKE '%@example.com'`).Scan(&admins); e != nil { + handlers.WriteJSON(w, 500, handlers.Error{Error: "Unable to change role"}) + return + } + if admins <= 1 { + handlers.WriteJSON(w, 400, handlers.Error{Error: "At least one administrator must remain"}) + return + } + } + if e := repo.SetRole(r.Context(), b.UserID, b.Role); e != nil { + handlers.WriteJSON(w, 500, handlers.Error{Error: "Unable to change role"}) + return + } + // Review SEC-11: role changes are audited. Ids and roles only. + log.Printf("audit: admin %s changed role of %s: %s -> %s", s.RealID, b.UserID, target.Role, b.Role) + handlers.WriteJSON(w, 200, handlers.OK{OK: true}) +} +func HandleAdminViewAs(w http.ResponseWriter, r *http.Request) { + var b struct { + UserID *string `json:"userId"` + } + if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&b) != nil { + handlers.WriteJSON(w, 400, handlers.Error{Error: "Invalid JSON"}) + return + } + s, _ := auth.SessionFromContext(r.Context()) + c := http.Cookie{Name: auth.ViewAsCookie, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: config.Load().CookieSecure(), MaxAge: -1} + if b.UserID != nil && *b.UserID != "" && *b.UserID != s.RealID { + if _, e := db.NewUserRepo(db.FromContext(r.Context())).FindByID(r.Context(), *b.UserID); e != nil { + handlers.WriteJSON(w, 404, handlers.Error{Error: "Not found"}) + return + } + c.Value = *b.UserID + c.MaxAge = int((8 * time.Hour).Seconds()) + } + http.SetCookie(w, &c) + handlers.WriteJSON(w, 200, handlers.OK{OK: true}) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ed2487b --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,113 @@ +package config + +import ( + "net/url" + "os" + "strings" +) + +// DBURL returns the database connection string, preferring DATABASE_URL and +// falling back to DB_URL (used by the Makefile). +func DBURL() string { + if v := os.Getenv("DATABASE_URL"); v != "" { + return v + } + return os.Getenv("DB_URL") +} + +// Config holds the runtime configuration, loaded from environment variables. +// See porting guide section 7. +type Config struct { + DatabaseURL string + NextAuthSecret string // == AUTH_SECRET; signs sessions and derives IMAP key + AuthSecret string + NextAuthURL string + OIDCIssuer string + OIDCClientID string + OIDCClientSecret string + AdminEmails []string +} + +// Load reads configuration from the environment. Placeholders keep OIDC off. +func Load() *Config { + return &Config{ + DatabaseURL: DBURL(), + NextAuthSecret: os.Getenv("NEXTAUTH_SECRET"), + AuthSecret: os.Getenv("AUTH_SECRET"), + NextAuthURL: os.Getenv("NEXTAUTH_URL"), + OIDCIssuer: os.Getenv("OIDC_ISSUER"), + OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), + OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), + AdminEmails: AdminEmails(), + } +} + +// Secret returns the signing secret, preferring NEXTAUTH_SECRET then AUTH_SECRET. +// The original code falls back to a hard-coded string; a port must fail instead. +func (c *Config) Secret() string { + if c.NextAuthSecret != "" { + return c.NextAuthSecret + } + return c.AuthSecret +} + +// EncryptionSecret returns the key material for at-rest encryption of +// integration credentials (review OPS-01). A dedicated ENCRYPTION_KEY lets +// operators rotate AUTH_SECRET (sessions) without invalidating stored IMAP +// passwords. Without it the auth secret remains the key, matching the +// original derivation, and stored rows keep decrypting via the legacy +// fallback in signals.LoadMailbox. +func (c *Config) EncryptionSecret() string { + if v := os.Getenv("ENCRYPTION_KEY"); v != "" { + return v + } + return c.Secret() +} + +// CookieSecure reports whether session cookies must carry the Secure flag. +// An explicit COOKIE_SECURE setting wins; otherwise the NEXTAUTH_URL scheme +// decides, so TLS deployments are secure by default without knowing about +// the extra variable. +func (c *Config) CookieSecure() bool { + if v := os.Getenv("COOKIE_SECURE"); v != "" { + return v == "1" + } + u, err := url.Parse(strings.TrimSpace(c.NextAuthURL)) + return err == nil && u.Scheme == "https" +} + +// AdminEmails splits env ADMIN_EMAILS on commas, trims, lowercases. +func AdminEmails() []string { + s := os.Getenv("ADMIN_EMAILS") + if s == "" { + return nil + } + var out []string + for _, email := range strings.Split(s, ",") { + if email = strings.ToLower(strings.TrimSpace(email)); email != "" { + out = append(out, email) + } + } + return out +} + +// IsAdminEmail reports whether email is listed in ADMIN_EMAILS. +func IsAdminEmail(email string) bool { + e := strings.ToLower(strings.TrimSpace(email)) + for _, a := range AdminEmails() { + if strings.ToLower(a) == e { + return true + } + } + return false +} + +// AllowBootstrapSignup reports whether the first self-service signup on a +// fresh installation may claim administrator (review SEC-5). Default is on +// so the documented "sign up, then sign in" onboarding keeps working; +// exposed deployments set ALLOW_BOOTSTRAP_SIGNUP=0 and provision operators +// via ADMIN_EMAILS/seed instead, so an attacker scanning for new +// installations cannot claim the instance. +func AllowBootstrapSignup() bool { + return os.Getenv("ALLOW_BOOTSTRAP_SIGNUP") != "0" +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..7fe484d --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,50 @@ +package config + +import "testing" + +func TestCookieSecureDerivation(t *testing.T) { + t.Setenv("COOKIE_SECURE", "") + https := &Config{NextAuthURL: "https://app.example.com"} + if !https.CookieSecure() { + t.Fatal("https NEXTAUTH_URL must imply Secure cookies") + } + local := &Config{NextAuthURL: "http://localhost:3000"} + if local.CookieSecure() { + t.Fatal("http NEXTAUTH_URL must not set Secure") + } + if (&Config{}).CookieSecure() { + t.Fatal("unset NEXTAUTH_URL must not set Secure") + } + t.Setenv("COOKIE_SECURE", "1") + if !local.CookieSecure() { + t.Fatal("explicit COOKIE_SECURE=1 must win over http URL") + } + t.Setenv("COOKIE_SECURE", "0") + if https.CookieSecure() { + t.Fatal("explicit COOKIE_SECURE=0 must win over https URL") + } +} + +func TestSecretPrecedence(t *testing.T) { + c := &Config{NextAuthSecret: "next", AuthSecret: "auth"} + if c.Secret() != "next" { + t.Fatal("NEXTAUTH_SECRET must win") + } + if (&Config{AuthSecret: "auth"}).Secret() != "auth" { + t.Fatal("AUTH_SECRET fallback broken") + } +} + +// Review OPS-01: a dedicated ENCRYPTION_KEY decouples integration-credential +// encryption from the session signing secret. +func TestEncryptionSecretPrecedence(t *testing.T) { + c := &Config{AuthSecret: "auth"} + t.Setenv("ENCRYPTION_KEY", "") + if c.EncryptionSecret() != "auth" { + t.Fatal("without ENCRYPTION_KEY the auth secret must remain the key") + } + t.Setenv("ENCRYPTION_KEY", "dedicated-key") + if c.EncryptionSecret() != "dedicated-key" { + t.Fatal("ENCRYPTION_KEY must win over the auth secret") + } +} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..41918fb --- /dev/null +++ b/internal/crypto/crypto.go @@ -0,0 +1,155 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" +) + +// Encryption of IMAP passwords. Porting guide 3.5. +// +// Ciphertext format (review SEC-9, layout corrected per review CR-20 — +// gcm.Seal emits ciphertext followed by tag): +// v1: base64( version(1) || IV(12) || ciphertext || tag(16) ) +// legacy (pre-HKDF): base64( IV(12) || ciphertext || tag(16) ) — no version +// byte; derived with raw SHA-256 for backward compatibility during rotation. +// +// The key is now derived via HKDF-SHA256 with a fixed context string. +// Changing the secret makes saved IMAP passwords unreadable unless the +// legacy fallback is used (handled by Decrypt). +// +// Review CR-20: a legacy record starts with a RANDOM IV byte, which can +// coincidentally equal versionByte — the format cannot be sniffed from the +// first byte alone. Decrypt therefore treats an apparent version byte as a +// hint only: the v1 decode is attempted first and, when GCM authentication +// does not succeed, the WHOLE record is retried as legacy. Only successful +// authenticated decryption ever selects a format/key. + +const ( + algorithm = "aes-256-gcm" + ivLength = 12 + tagLength = 16 + versionByte = 1 + legacyVersion = 0 + hkdfContext = "computer/imap-password/v1" +) + +// Key derives the 32-byte AES key from the secret using HKDF-SHA256. +// An empty secret is a programming error: Encrypt/Decrypt guard it, and +// main fails startup without a configured secret. +func Key(secret string) []byte { + if secret == "" { + panic("crypto: empty secret") + } + k, err := hkdf.Key(sha256.New, []byte(secret), nil, hkdfContext, 32) + if err != nil { + panic("hkdf: " + err.Error()) + } + return k +} + +// LegacyKey derives the 32-byte AES key via raw SHA-256 (pre-HKDF). +// Used by Decrypt when encountering a legacy ciphertext (no version byte). +func LegacyKey(secret string) []byte { + if secret == "" { + panic("crypto: empty secret") + } + h := sha256.Sum256([]byte(secret)) + return h[:] +} + +// Encrypt returns base64( version(1) || IV(12) || ciphertext || tag(16) ). +// Never returns an error path that loses data; the caller must ensure the +// secret is set. +func Encrypt(text, secret string) (string, error) { + if secret == "" { + return "", errors.New("missing encryption secret") + } + key := Key(secret) + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + iv := make([]byte, ivLength) + if _, err := rand.Read(iv); err != nil { + return "", err + } + seal := gcm.Seal(nil, iv, []byte(text), nil) + // gcm output is ciphertext || tag + ciphertext := seal[:len(seal)-tagLength] + tag := seal[len(seal)-tagLength:] + out := make([]byte, 0, 1+ivLength+len(ciphertext)+tagLength) + out = append(out, versionByte) + out = append(out, iv...) + out = append(out, ciphertext...) + out = append(out, tag...) + return base64.StdEncoding.EncodeToString(out), nil +} + +// Decrypt reverses Encrypt. Returns an error on malformed input. +// Supports both v1 (HKDF) and legacy (raw SHA-256) ciphertexts for +// transparent rotation. Review CR-20: the leading-byte check is only a hint — +// a legacy IV byte can collide with versionByte, so a failed v1 decode falls +// back to an authenticated legacy decode of the whole record instead of +// declaring the password unreadable. +func Decrypt(data, secret string) (string, error) { + if secret == "" { + return "", errors.New("missing encryption secret") + } + raw, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return "", err + } + if len(raw) >= 1 && raw[0] == versionByte { + if plain, e := openGCM(raw[1:], Key(secret)); e == nil { + return plain, nil + } + // Fall through: either a tampered/miskeyed v1 record (the legacy + // retry will fail authentication too) or a legacy record whose random + // IV starts with the version byte. + } + return openGCM(raw, LegacyKey(secret)) +} + +// openGCM decodes one record body: IV(12) || ciphertext || tag(16). +func openGCM(raw, key []byte) (string, error) { + if len(raw) < ivLength+tagLength { + return "", errShortInputError + } + iv := raw[:ivLength] + // gcm.Seal returns ciphertext || tag, so the tag is the LAST 16 bytes + tag := raw[len(raw)-tagLength:] + ciphertext := raw[ivLength : len(raw)-tagLength] + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + // gcm expects ciphertext || tag + sealed := append([]byte(nil), ciphertext...) + sealed = append(sealed, tag...) + plain, err := gcm.Open(nil, iv, sealed, nil) + if err != nil { + return "", err + } + return string(plain), nil +} + +// errShortInputError is returned when the decoded input is too short to contain +// the IV and auth tag. +var errShortInputError = errShortInput{} + +type errShortInput struct{} + +func (errShortInput) Error() string { return "crypto: input too short" } diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go new file mode 100644 index 0000000..f130271 --- /dev/null +++ b/internal/crypto/crypto_test.go @@ -0,0 +1,137 @@ +package crypto + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "testing" +) + +func TestPasswordEncryption(t *testing.T) { + cipher, e := Encrypt("mailbox-password", "stable-secret") + if e != nil { + t.Fatal(e) + } + p, e := Decrypt(cipher, "stable-secret") + if e != nil || p != "mailbox-password" { + t.Fatal(p, e) + } + raw, _ := base64.StdEncoding.DecodeString(cipher) + // v1 format: version(1) + IV(12) + ciphertext + tag(16) (review CR-20 + // corrected the layout comment: gcm.Seal emits ciphertext || tag). + if len(raw) != 1+12+len(p)+16 { + t.Fatalf("incompatible v1 IV/ciphertext/tag layout: got %d", len(raw)) + } + if raw[0] != versionByte { + t.Fatal("ciphertext missing version byte") + } + raw[len(raw)-1] ^= 1 + if _, e = Decrypt(base64.StdEncoding.EncodeToString(raw), "stable-secret"); e == nil { + t.Fatal("accepted tampered ciphertext") + } + if _, e = Decrypt(cipher, "changed-secret"); e == nil { + t.Fatal("accepted wrong secret") + } + if _, e = Encrypt("value", ""); e == nil { + t.Fatal("accepted empty secret") + } + if _, e = Decrypt("", "stable-secret"); e == nil { + t.Fatal("accepted empty input") + } + // Too-short bodies are rejected on both format paths. + short := base64.StdEncoding.EncodeToString([]byte{versionByte, 1, 2, 3}) + if _, e = Decrypt(short, "stable-secret"); e == nil { + t.Fatal("accepted short versioned input") + } + shortLegacy := base64.StdEncoding.EncodeToString([]byte{9, 9, 9}) + if _, e = Decrypt(shortLegacy, "stable-secret"); e == nil { + t.Fatal("accepted short legacy input") + } +} + +// legacyEncrypt builds a pre-HKDF ciphertext: base64( IV || ciphertext || tag ) +// under the raw SHA-256 key, with full control over the IV (review CR-20 asks +// for deterministic fixtures around the version-byte collision). +func legacyEncrypt(t *testing.T, plain, secret string, iv []byte) string { + t.Helper() + key := sha256.Sum256([]byte(secret)) + block, e := aes.NewCipher(key[:]) + if e != nil { + t.Fatal(e) + } + gcm, e := cipher.NewGCM(block) + if e != nil { + t.Fatal(e) + } + seal := gcm.Seal(nil, iv, []byte(plain), nil) + return base64.StdEncoding.EncodeToString(append(append([]byte(nil), iv...), seal...)) +} + +// TestLegacyCiphertextCompatibility verifies Decrypt can read ciphertexts +// created before HKDF/versioning (review SEC-9). +func TestLegacyCiphertextCompatibility(t *testing.T) { + secret := "legacy-secret" + iv := make([]byte, ivLength) + rand.Read(iv) + legacyCipher := legacyEncrypt(t, "legacy-password", secret, iv) + + // Decrypt with the new Decrypt (legacy format via LegacyKey/raw SHA-256). + p, e := Decrypt(legacyCipher, secret) + if e != nil || p != "legacy-password" { + t.Fatalf("failed to decrypt legacy ciphertext: %v %v", p, e) + } + + // Re-encrypting with the same secret produces v1 format (HKDF + version) + v1Cipher, e := Encrypt(p, secret) + if e != nil { + t.Fatal(e) + } + raw, _ := base64.StdEncoding.DecodeString(v1Cipher) + if raw[0] != versionByte { + t.Fatal("re-encrypted ciphertext missing version byte") + } +} + +// TestLegacyVersionByteCollision pins review CR-20: a legacy record whose +// random IV starts with the version byte must still decrypt. The old +// first-byte sniff stripped the byte and selected the v1 key, rendering such +// stored credentials permanently unreadable. +func TestLegacyVersionByteCollision(t *testing.T) { + secret := "legacy-secret" + iv := make([]byte, ivLength) + rand.Read(iv) + iv[0] = versionByte // force the collision, deterministically + legacyCipher := legacyEncrypt(t, "collision-password", secret, iv) + + p, e := Decrypt(legacyCipher, secret) + if e != nil || p != "collision-password" { + t.Fatalf("legacy ciphertext colliding with the version byte failed to decrypt: %v %v", p, e) + } + + // Tampering must still fail authentication on the fallback path. + raw, _ := base64.StdEncoding.DecodeString(legacyCipher) + raw[len(raw)-1] ^= 1 + if _, e = Decrypt(base64.StdEncoding.EncodeToString(raw), secret); e == nil { + t.Fatal("accepted tampered colliding legacy ciphertext") + } + // A wrong secret never authenticates, collision or not. + if _, e = Decrypt(legacyCipher, "other-secret"); e == nil { + t.Fatal("accepted wrong secret for legacy ciphertext") + } + // Sanity: the fixture really starts with the version byte. + if !bytes.HasPrefix(mustDecode(t, legacyCipher), []byte{versionByte}) { + t.Fatal("fixture does not exercise the collision") + } +} + +func mustDecode(t *testing.T, s string) []byte { + t.Helper() + raw, e := base64.StdEncoding.DecodeString(s) + if e != nil { + t.Fatal(e) + } + return raw +} diff --git a/internal/db/bookmarks.go b/internal/db/bookmarks.go new file mode 100644 index 0000000..2782585 --- /dev/null +++ b/internal/db/bookmarks.go @@ -0,0 +1,44 @@ +package db + +// PostgreSQL builds the camelCase JSON shape consumed by the existing UI. +// The password-bearing User and ImapConfig rows are never embedded. +// +// Review PRIV-02: bookmark projections are permission-specific. Permission to +// read a bookmark (direct share, shared tag, or shared date) does not imply +// permission to read the owner's address-book entry, enumerate the other +// recipients, or see the owner's operational metadata (alerts, mailbox watch, +// board placement). BookmarkSharedView is an allowlist: new columns are NOT +// exposed to shared readers without a deliberate addition there. + +// BookmarkOwnerView is the full projection, for the owner only. It embeds the +// attached contact record and the recipient list with names and emails — the +// share panel renders those for the owner. +const BookmarkOwnerView = `to_jsonb(b) || jsonb_build_object( + 'tags',COALESCE((SELECT jsonb_agg(to_jsonb(bt)||jsonb_build_object('tag',to_jsonb(t))) FROM "BookmarkTag" bt JOIN "Tag" t ON t.id=bt."tagId" WHERE bt."bookmarkId"=b.id),'[]'::jsonb), + 'contact',(SELECT to_jsonb(c) FROM "Contact" c WHERE c.id=b."contactId"), + 'sharedWith',COALESCE((SELECT jsonb_agg(to_jsonb(s)||jsonb_build_object('user',jsonb_build_object('id',u.id,'name',u.name,'email',u.email))) FROM "BookmarkShare" s JOIN "User" u ON u.id=s."userId" WHERE s."bookmarkId"=b.id),'[]'::jsonb), + 'owner',(SELECT jsonb_build_object('id',u.id,'name',u.name,'email',u.email,'image',u.image) FROM "User" u WHERE u.id=b."ownerId"))` + +// BookmarkSharedView is the narrowed projection for authenticated non-owner +// readers: the link itself, its presentation metadata, the content the owner +// attached for readers (description, notes, due date, tags), and enough owner +// identity to attribute the share inside the workspace. No contact record, no +// recipient list, no alerts, no mailbox-watch settings, no board placement. +const BookmarkSharedView = `jsonb_build_object( + 'id',b.id,'url',b.url,'title',b.title,'description',b.description,'notes',b.notes, + 'favicon',b.favicon,'ogImage',b."ogImage",'ogTitle',b."ogTitle",'ogDescription',b."ogDescription", + 'dueDate',b."dueDate",'isPublic',b."isPublic",'kanbanStatus',b."kanbanStatus", + 'ownerId',b."ownerId",'createdAt',b."createdAt",'updatedAt',b."updatedAt", + 'tags',COALESCE((SELECT jsonb_agg(to_jsonb(bt)||jsonb_build_object('tag',to_jsonb(t))) FROM "BookmarkTag" bt JOIN "Tag" t ON t.id=bt."tagId" WHERE bt."bookmarkId"=b.id),'[]'::jsonb), + 'owner',(SELECT jsonb_build_object('id',u.id,'name',u.name) FROM "User" u WHERE u.id=b."ownerId"))` + +// BookmarkViewerView picks the projection by ownership. Like BookmarkVisible +// it binds the requesting user as $2, so it drops into the same queries. +const BookmarkViewerView = `CASE WHEN b."ownerId"=$2 THEN ` + BookmarkOwnerView + ` ELSE ` + BookmarkSharedView + ` END` + +// BookmarkVisible binds the requesting user as $2. Tag sharing is group-wide +// by product decision: any recipient of a tag sees every bookmark carrying +// it, including bookmarks other recipients added. The share UI says so. +const BookmarkVisible = `(b."ownerId"=$2 OR EXISTS(SELECT 1 FROM "BookmarkShare" s WHERE s."bookmarkId"=b.id AND s."userId"=$2) + OR EXISTS(SELECT 1 FROM "BookmarkTag" bt JOIN "TagShare" ts ON ts."tagId"=bt."tagId" WHERE bt."bookmarkId"=b.id AND ts."userId"=$2) + OR EXISTS(SELECT 1 FROM "SharedDate" sd WHERE sd."creatorId"=b."ownerId" AND sd."recipientId"=$2 AND (b."dueDate" AT TIME ZONE 'UTC')::date=sd.date))` diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..68a8360 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,153 @@ +package db + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/andrew/go-computer/internal/config" + _ "github.com/jackc/pgx/v5/stdlib" +) + +type DB struct{ *sql.DB } +type Object = map[string]any + +var ErrNotFound = errors.New("not found") + +func New(cfg *config.Config) (*DB, error) { + d, err := sql.Open("pgx", cfg.DatabaseURL) + if err != nil { + return nil, err + } + d.SetMaxOpenConns(20) + d.SetMaxIdleConns(5) + d.SetConnMaxLifetime(time.Hour) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err = d.PingContext(ctx); err != nil { + d.Close() + return nil, fmt.Errorf("database: %w", err) + } + return &DB{d}, nil +} +func (d *DB) Close() error { return d.DB.Close() } + +type contextKey struct{} + +// FromContext returns the request-scoped database. A missing value is a +// wiring bug (route mounted without withDB) and must fail loudly here rather +// than nil-panic at the first query. +func FromContext(ctx context.Context) *DB { + d, ok := ctx.Value(contextKey{}).(*DB) + if !ok || d == nil { + panic("db: no database in request context (route missing withDB middleware)") + } + return d +} +func NewContext(ctx context.Context, d *DB) context.Context { + return context.WithValue(ctx, contextKey{}, d) +} +func NewID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +} + +// Queryer is also implemented by sql.Tx, so mutations and relation updates +// can be committed atomically without a second connection pool. +type Queryer interface { + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + +func One(ctx context.Context, q Queryer, query string, args ...any) (Object, error) { + var raw []byte + err := q.QueryRowContext(ctx, query, args...).Scan(&raw) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + var v Object + err = json.Unmarshal(raw, &v) + return v, err +} +func Many(ctx context.Context, q Queryer, query string, args ...any) ([]Object, error) { + rows, err := q.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Object{} + for rows.Next() { + var raw []byte + var v Object + if err = rows.Scan(&raw); err != nil { + return nil, err + } + if err = json.Unmarshal(raw, &v); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} +func Quote(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` } + +// Table and column names originate in server-owned allowlists, never request +// parameters. Every value is a PostgreSQL bind parameter. +func Insert(ctx context.Context, q Queryer, table string, data Object) (Object, error) { + keys := []string{} + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + cols, ps := []string{}, []string{} + args := []any{} + for i, k := range keys { + cols = append(cols, Quote(k)) + ps = append(ps, fmt.Sprintf("$%d", i+1)) + args = append(args, data[k]) + } + return One(ctx, q, `INSERT INTO `+Quote(table)+` (`+strings.Join(cols, ",")+`) VALUES (`+strings.Join(ps, ",")+`) RETURNING to_jsonb(`+Quote(table)+`)`, args...) +} +func Update(ctx context.Context, q Queryer, table, id, ownerCol, owner string, data Object) (Object, error) { + keys := []string{} + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + sets, args := []string{}, []any{} + for i, k := range keys { + sets = append(sets, Quote(k)+fmt.Sprintf("=$%d", i+1)) + args = append(args, data[k]) + } + args = append(args, id, owner) + where := fmt.Sprintf(`id=$%d AND %s=$%d`, len(args)-1, Quote(ownerCol), len(args)) + if len(sets) == 0 { + return One(ctx, q, `SELECT to_jsonb(t) FROM `+Quote(table)+` t WHERE `+where, args...) + } + return One(ctx, q, `UPDATE `+Quote(table)+` SET `+strings.Join(sets, ",")+` WHERE `+where+` RETURNING to_jsonb(`+Quote(table)+`)`, args...) +} +func Transaction(ctx context.Context, d *DB, fn func(*sql.Tx) error) error { + tx, err := d.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if err = fn(tx); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/db/users.go b/internal/db/users.go new file mode 100644 index 0000000..9586468 --- /dev/null +++ b/internal/db/users.go @@ -0,0 +1,97 @@ +package db + +import ( + "context" + "database/sql" + "encoding/json" + "github.com/andrew/go-computer/internal/config" + "github.com/andrew/go-computer/internal/models" +) + +type UserRepo struct{ db *DB } + +func NewUserRepo(d *DB) *UserRepo { return &UserRepo{d} } +func (r *UserRepo) find(ctx context.Context, field, value string) (*models.User, error) { + o, err := One(ctx, r.db, `SELECT to_jsonb(u) FROM "User" u WHERE `+Quote(field)+`=$1`, value) + if err != nil { + return nil, err + } + raw, _ := json.Marshal(o) + var row struct { + ID string `json:"id"` + Name *string `json:"name"` + Email *string `json:"email"` + EmailVerified *string `json:"emailVerified"` + Image *string `json:"image"` + Password *string `json:"password"` + Role string `json:"role"` + TokenVersion int `json:"tokenVersion"` + } + if err = json.Unmarshal(raw, &row); err != nil { + return nil, err + } + return &models.User{ID: row.ID, Name: row.Name, Email: row.Email, EmailVerified: row.EmailVerified, Image: row.Image, Password: row.Password, Role: row.Role, TokenVersion: row.TokenVersion}, nil +} +func (r *UserRepo) FindByEmail(ctx context.Context, e string) (*models.User, error) { + return r.find(ctx, "email", e) +} +func (r *UserRepo) FindByID(ctx context.Context, id string) (*models.User, error) { + return r.find(ctx, "id", id) +} +func (r *UserRepo) SetRole(ctx context.Context, id, role string) error { + _, err := r.db.ExecContext(ctx, `UPDATE "User" SET role=$1,"updatedAt"=now() WHERE id=$2`, role, id) + return err +} + +// BumpTokenVersion invalidates every cookie JWT issued for the user so far. +func (r *UserRepo) BumpTokenVersion(ctx context.Context, id string) error { + _, err := r.db.ExecContext(ctx, `UPDATE "User" SET "tokenVersion"="tokenVersion"+1,"updatedAt"=now() WHERE id=$1`, id) + return err +} + +type NewUserInput struct{ ID, Name, Email, Password, Role string } + +// A transaction-scoped lock serializes first-admin selection with signup. +// +// Review AUTH-06: initial provisioning is a one-shot lifecycle event, +// distinct from administrator recovery. Once bootstrap completion is +// recorded in AppSetting, the mere absence of a qualifying administrator — +// e.g. after demoting or removing the last one — never re-opens +// administrator assignment through signup. Recovery is operator-driven +// (ADMIN_EMAILS + make seed, or promotion by an existing admin). +func (r *UserRepo) Create(ctx context.Context, u *NewUserInput) (string, error) { + err := Transaction(ctx, r.db, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(927438)`); err != nil { + return err + } + var bootstrapDone bool + if err := tx.QueryRowContext(ctx, `SELECT COALESCE((SELECT value->>'completed' FROM "AppSetting" WHERE key='adminBootstrap'),'false')::boolean`).Scan(&bootstrapDone); err != nil { + return err + } + var admins int + if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM "User" WHERE role='ADMIN' AND COALESCE(email,'') NOT LIKE '%@example.com'`).Scan(&admins); err != nil { + return err + } + role := u.Role + if role == "" { + role = "USER" + } + promote := config.AllowBootstrapSignup() && !bootstrapDone && admins == 0 && len(config.AdminEmails()) == 0 && !isTestEmail(u.Email) && role == "USER" + if promote { + role = "ADMIN" + } + // Record bootstrap completion as soon as the installation has (or was + // just given) a real administrator. A test-address signup with no + // admins present never claims the bootstrap. + if !bootstrapDone && (role == "ADMIN" || admins > 0) { + if _, err := tx.ExecContext(ctx, `INSERT INTO "AppSetting"(key,value) VALUES('adminBootstrap',jsonb_build_object('completed',true)) ON CONFLICT(key) DO UPDATE SET value=EXCLUDED.value,"updatedAt"=now()`); err != nil { + return err + } + } + _, err := Insert(ctx, tx, "User", Object{"id": u.ID, "name": u.Name, "email": u.Email, "password": u.Password, "role": role}) + return err + }) + return u.ID, err +} +func isTestEmail(e string) bool { return len(e) >= 12 && e[len(e)-12:] == "@example.com" } +func IsAdminEmail(email string) bool { return config.IsAdminEmail(email) } diff --git a/internal/handlers/bookmarks.go b/internal/handlers/bookmarks.go new file mode 100644 index 0000000..e932e95 --- /dev/null +++ b/internal/handlers/bookmarks.go @@ -0,0 +1,438 @@ +package handlers + +import ( + "context" + "database/sql" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/andrew/go-computer/internal/db" + "github.com/andrew/go-computer/internal/markdown" + "github.com/andrew/go-computer/internal/preview" + "net/http" + "strconv" + "strings" + "time" +) + +func bookmark(r *http.Request, id string, public bool) (object, error) { + if public { + // Review PRIV-04: the public projection is an explicit allowlist and + // carries NO owner attribution — the README promises owner identity + // stays private, and API, share page, and docs must agree. Notes and + // dueDate ARE published by product decision; the share panel states + // exactly what a public link exposes before the owner toggles it. + return db.One(r.Context(), database(r), `SELECT jsonb_build_object( + 'id',b.id,'url',b.url,'title',b.title,'description',b.description,'notes',b.notes, + 'favicon',b.favicon,'ogImage',b."ogImage",'ogTitle',b."ogTitle",'ogDescription',b."ogDescription", + 'dueDate',b."dueDate",'isPublic',b."isPublic", + 'tags',COALESCE((SELECT jsonb_agg(jsonb_build_object('tag',jsonb_build_object('id',t.id,'name',t.name,'color',t.color))) FROM "BookmarkTag" bt JOIN "Tag" t ON t.id=bt."tagId" WHERE bt."bookmarkId"=b.id),'[]'::jsonb)) + FROM "Bookmark" b WHERE b.id=$1 AND b."isPublic"`, id) + } + // Review PRIV-02: shared readers get the narrowed allowlist projection; + // only the owner receives the full row with contact and recipient list. + where := `b.id=$1 AND ` + db.BookmarkVisible + uid := user(r) + return db.One(r.Context(), database(r), `SELECT `+db.BookmarkViewerView+` || jsonb_build_object('pageLinks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('page',jsonb_build_object('id',p.id,'title',p.title))) FROM "PageBookmark" pb JOIN "Page" p ON p.id=pb."pageId" WHERE pb."bookmarkId"=b.id AND p."ownerId"=$2),'[]'::jsonb)) FROM "Bookmark" b WHERE `+where, id, uid) +} +func HandleBookmarksList(w http.ResponseWriter, r *http.Request) { + args := []any{nil, user(r)} + sp := r.URL.Query() + limit := 200 + if raw := sp.Get("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || n > 200 { + respond(w, 0, nil, bad("Limit must be between 1 and 200")) + return + } + limit = n + } + where := `b."ownerId"=$2` + if sp.Get("shared") == "true" { + where = db.BookmarkVisible + ` AND b."ownerId"<>$2` + } + add := func(s string, v any) { args = append(args, v); where += " AND " + fmt.Sprintf(s, len(args)) } + if s := sp.Get("search"); s != "" { + // Escape LIKE wildcards (review PERF-2): an unescaped query pattern + // matched everything and defeated the search's selectivity. + args = append(args, "%"+likeEscaper.Replace(s)+"%") + n := len(args) + where += fmt.Sprintf(` AND (b.title ILIKE $%d ESCAPE '\' OR b.url ILIKE $%d ESCAPE '\' OR b.notes ILIKE $%d ESCAPE '\' OR EXISTS(SELECT 1 FROM "BookmarkTag" bt JOIN "Tag" t ON t.id=bt."tagId" WHERE bt."bookmarkId"=b.id AND t.name ILIKE $%d ESCAPE '\'))`, n, n, n, n) + } + if s := sp.Get("tagId"); s != "" { + add(`EXISTS(SELECT 1 FROM "BookmarkTag" bt WHERE bt."bookmarkId"=b.id AND bt."tagId"=$%d)`, s) + if sp.Get("shared") != "true" { + where = strings.Replace(where, `b."ownerId"=$2`, db.BookmarkVisible, 1) + } + } + if s := sp.Get("contactId"); s != "" { + add(`b."contactId"=$%d`, s) + } + if s := sp.Get("kanban"); s != "" { + if !validStatus(s) { + respond(w, 0, nil, bad("Invalid status")) + return + } + add(`b."kanbanStatus"=$%d`, s) + } + if s := sp.Get("date"); s != "" { + v, e := dateValue(s) + if e != nil { + respond(w, 0, nil, e) + return + } + // Pin both sides to UTC: bare ::date casts use the Postgres session + // timezone, which is not pinned and would shift "due on date X". + add(`(b."dueDate" AT TIME ZONE 'UTC')::date=($%d::timestamptz AT TIME ZONE 'UTC')::date`, v) + // Guarded exactly like tagId (review COR-7): re-injecting the + // visibility predicate into a list already widened by it would nest + // BookmarkVisible inside itself. + if sp.Get("shared") != "true" && !strings.Contains(where, db.BookmarkVisible) { + where = strings.Replace(where, `b."ownerId"=$2`, db.BookmarkVisible, 1) + } + } + // Port artifact: db.BookmarkVisible binds the user as $2, so $1 stays a + // permanently-nil placeholder and this predicate is always true. Removing + // it would require renumbering BookmarkVisible's parameters everywhere. + where += ` AND $1::text IS NULL` + if raw := sp.Get("cursor"); raw != "" { + var cursor struct { + UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + } + data, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil || json.Unmarshal(data, &cursor) != nil || cursor.UpdatedAt.IsZero() || cursor.ID == "" { + respond(w, 0, nil, bad("Invalid cursor")) + return + } + args = append(args, cursor.UpdatedAt, cursor.ID) + where += fmt.Sprintf(` AND (b."updatedAt",b.id)<($%d,$%d)`, len(args)-1, len(args)) + } + args = append(args, limit+1) + v, e := db.Many(r.Context(), database(r), `SELECT `+db.BookmarkViewerView+` FROM "Bookmark" b WHERE `+where+fmt.Sprintf(` ORDER BY b."updatedAt" DESC,b.id DESC LIMIT $%d`, len(args)), args...) + if e == nil && len(v) > limit { + v = v[:limit] + last := v[len(v)-1] + cursor, _ := json.Marshal(object{"updatedAt": last["updatedAt"], "id": last["id"]}) + w.Header().Set("X-Next-Cursor", base64.RawURLEncoding.EncodeToString(cursor)) + } + respond(w, 200, v, e) +} +func validStatus(s string) bool { + return s == "INBOX" || s == "TODO" || s == "IN_PROGRESS" || s == "DONE" || s == "ARCHIVED" +} +func bookmarkFields(b object) (object, error) { + d := fields(b, "url title description notes dueDate alertAt kanbanStatus kanbanColumnId contactId isPublic imapWatchEnabled imapQuery") + if e := stringFields(d, "title description notes kanbanColumnId contactId imapQuery", "url kanbanStatus"); e != nil { + return nil, e + } + for _, l := range []struct { + key string + max int + }{ + {"url", maxBookmarkURLLen}, + {"title", maxBookmarkTitleLen}, + {"description", maxBookmarkDescriptionLen}, + {"notes", maxBookmarkNotesLen}, + {"imapQuery", maxBookmarkQueryLen}, + } { + if e := limitText(d, l.key, l.max); e != nil { + return nil, e + } + } + if e := boolFields(d, "isPublic imapWatchEnabled"); e != nil { + return nil, e + } + if v, ok := d["url"]; ok && !validURL(v.(string)) { + return nil, bad("A valid HTTP(S) URL is required") + } + if s, ok := d["kanbanStatus"]; ok && !validStatus(s.(string)) { + return nil, bad("Invalid status") + } + for _, k := range []string{"dueDate", "alertAt"} { + if v, ok := d[k]; ok { + v, e := dateValue(v) + if e != nil { + return nil, e + } + d[k] = v + if k == "alertAt" { + d["alertSent"] = false + } + } + } + for _, k := range []string{"contactId", "kanbanColumnId"} { + if d[k] == "" { + d[k] = nil + } + } + return d, nil +} +func checkRelations(r *http.Request, tx *sql.Tx, d object, tags []string) error { + for k, table := range map[string]string{"contactId": "Contact", "kanbanColumnId": "KanbanColumn"} { + if v, ok := d[k]; ok && v != nil { + if _, e := owned(r, tx, table, v.(string), "userId"); e != nil { + return bad("Invalid " + k) + } + } + } + // A tag shared WITH you may be attached to your own bookmarks. Note the + // group semantics this implies (by design, see db.BookmarkVisible): the + // bookmark then becomes visible to every recipient of that tag. + for _, tag := range tags { + if _, e := db.One(r.Context(), tx, `SELECT to_jsonb(t) FROM "Tag" t WHERE id=$1 AND ("ownerId"=$2 OR EXISTS(SELECT 1 FROM "TagShare" s WHERE s."tagId"=t.id AND s."userId"=$2))`, tag, user(r)); e != nil { + return bad("Invalid tag") + } + } + return nil +} +func setTags(r *http.Request, tx *sql.Tx, id string, tags []string) error { + if _, e := tx.ExecContext(r.Context(), `DELETE FROM "BookmarkTag" WHERE "bookmarkId"=$1`, id); e != nil { + return e + } + for _, t := range tags { + if _, e := tx.ExecContext(r.Context(), `INSERT INTO "BookmarkTag"("bookmarkId","tagId") VALUES($1,$2)`, id, t); e != nil { + return e + } + } + return nil +} + +// addPreview fetches og/favicon metadata for the bookmark's URL through the +// shared preview budget (review CR-06): bookmark mutations and the dedicated +// preview route draw from the SAME per-identity rate and global concurrency +// limits, so saves cannot bypass the route's throttle. Preview work is +// decorative — when the budget is exhausted the bookmark still persists, +// just without fresh og metadata. +func addPreview(ctx context.Context, key string, d object, create bool) { + p := preview.FetchPreview(ctx, key, str(d, "url")) + d["favicon"], d["ogImage"], d["ogTitle"], d["ogDescription"] = p.Favicon, p.OgImage, p.OgTitle, p.OgDescription + if create { + if d["title"] == nil { + if p.OgTitle != nil { + d["title"] = *p.OgTitle + } else { + d["title"] = d["url"] + } + } + if d["description"] == nil { + d["description"] = p.OgDescription + } + } +} +func HandleBookmarksCreate(w http.ResponseWriter, r *http.Request) { + b, ok := read(w, r) + if !ok { + return + } + d, e := bookmarkFields(b) + if e != nil { + respond(w, 0, nil, e) + return + } + if str(d, "url") == "" { + respond(w, 0, nil, bad("URL required")) + return + } + tags, e := ids(b, "tagIds") + if e != nil { + respond(w, 0, nil, e) + return + } + id := db.NewID() + d["id"], d["ownerId"] = id, user(r) + addPreview(r.Context(), previewKey(r), d, true) + e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + // One locking protocol for board mutations (review CR-05): column + // deletion holds lockUser while it snapshots the affected bookmarks, + // moves them and drops the column. Bookmark writes that validate and + // assign kanbanColumnId take the SAME lock first, so a concurrent + // create can no longer land on a column the deletion already counted + // as moved (or escape its move set). Lock order is always user row → + // bookmarks/columns, matching every other lockUser transaction. + if e := lockUser(r, tx); e != nil { + return e + } + if e := checkRelations(r, tx, d, tags); e != nil { + return e + } + row, e := db.Insert(r.Context(), tx, "Bookmark", d) + if e != nil { + return e + } + if e = syncLegacyWatch(r, tx, row); e != nil { + return e + } + return setTags(r, tx, id, tags) + }) + if e != nil { + respond(w, 0, nil, e) + return + } + v, e := bookmark(r, id, false) + respond(w, 201, v, e) +} +func HandleBookmarksGet(w http.ResponseWriter, r *http.Request) { + v, e := bookmark(r, r.PathValue("id"), false) + respond(w, 200, v, e) +} +func HandleBookmarksUpdate(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + existing, e := owned(r, database(r), "Bookmark", id, "ownerId") + if e != nil { + respond(w, 0, nil, e) + return + } + b, ok := read(w, r) + if !ok { + return + } + d, e := bookmarkFields(b) + if e != nil { + respond(w, 0, nil, e) + return + } + // Optimistic concurrency (review CR-04): the PUT is the owner-driven + // mutation surface (fields AND tag replacement), and concurrency is not + // optional on it — the same DATA-05 rule pages enforce. The client sends + // the version it edited from; a stale base is a 409 and the row is left + // untouched, so overlapping edits can no longer silently overwrite each + // other. Sharing (POST /share) and server-driven flags (alertSent) are + // deliberately outside this contract; the kanban column-deletion move + // bumps the version itself. + baseVersion, hasVersion := 0, false + if v, present := b["version"]; present { + f, isNum := v.(float64) + if !isNum || f < 0 || f != float64(int64(f)) { + respond(w, 0, nil, bad("version must be a non-negative integer")) + return + } + baseVersion, hasVersion = int(f), true + } + if !hasVersion { + respond(w, 0, nil, bad("version is required when updating a bookmark; refetch it and retry")) + return + } + tags, e := ids(b, "tagIds") + if e != nil { + respond(w, 0, nil, e) + return + } + if s := str(d, "url"); s != "" && s != str(existing, "url") { + addPreview(r.Context(), previewKey(r), d, false) + } + d["updatedAt"] = time.Now().UTC() + d["version"] = baseVersion + 1 + e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + // Review CR-05: same board-mutation locking protocol as create and + // column deletion — placement is validated (checkRelations) and + // written under the user lock, so a column deletion can neither race + // this assignment nor move a bookmark this write just placed. + if e := lockUser(r, tx); e != nil { + return e + } + // Re-read under the lock: the version check must see the latest + // committed state, not the pre-transaction snapshot. + cur, e := owned(r, tx, "Bookmark", id, "ownerId") + if e != nil { + return e + } + if stored, _ := cur["version"].(float64); int(stored) != baseVersion { + return conflict("Bookmark was modified elsewhere; reload to see the latest version") + } + if e := checkRelations(r, tx, d, tags); e != nil { + return e + } + row, e := db.Update(r.Context(), tx, "Bookmark", id, "ownerId", user(r), d) + if e != nil { + return e + } + if e = syncLegacyWatch(r, tx, row); e != nil { + return e + } + if _, ok := b["tagIds"]; ok { + return setTags(r, tx, id, tags) + } + return nil + }) + if e != nil { + respond(w, 0, nil, e) + return + } + v, e := bookmark(r, id, false) + respond(w, 200, v, e) +} +func HandleBookmarksDelete(w http.ResponseWriter, r *http.Request) { + e := remove(r, "Bookmark", r.PathValue("id"), "ownerId") + respond(w, 200, OK{true}, e) +} + +// The original mailbox-watch switch now drives an actual email signal source. +// Modern watchers added in the Signals panel are managed independently. +func syncLegacyWatch(r *http.Request, tx *sql.Tx, b object) error { + enabled, _ := b["imapWatchEnabled"].(bool) + raw, _ := json.Marshal(object{"subject": str(b, "imapQuery"), "legacy": true}) + res, e := tx.ExecContext(r.Context(), `UPDATE "SignalSource" SET config=$1::json,enabled=$2,"updatedAt"=now() WHERE "bookmarkId"=$3 AND "ownerId"=$4 AND config->>'legacy'='true'`, string(raw), enabled, b["id"], user(r)) + if e != nil { + return e + } + n, _ := res.RowsAffected() + if n == 0 && enabled { + _, e = db.Insert(r.Context(), tx, "SignalSource", object{"id": db.NewID(), "type": "email", "config": string(raw), "enabled": true, "bookmarkId": b["id"], "ownerId": user(r)}) + } + return e +} +func HandleBookmarksShare(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if _, e := owned(r, database(r), "Bookmark", id, "ownerId"); e != nil { + respond(w, 0, nil, e) + return + } + b, ok := read(w, r) + if !ok { + return + } + list, e := ids(b, "userIds") + if e != nil { + respond(w, 0, nil, e) + return + } + if e = boolFields(b, "isPublic"); e != nil { + respond(w, 0, nil, e) + return + } + e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + if e := shareRows(r, tx, "BookmarkShare", "bookmarkId", id, list); e != nil { + return e + } + if _, ok := b["isPublic"]; ok { + _, e := db.Update(r.Context(), tx, "Bookmark", id, "ownerId", user(r), object{"isPublic": b["isPublic"], "updatedAt": time.Now().UTC()}) + return e + } + return nil + }) + respond(w, 200, OK{true}, e) +} +func HandleBookmarksMarkdown(w http.ResponseWriter, r *http.Request) { + // bookmark() already applies the permission-specific projection (review + // PRIV-02), so shared readers export only fields they may read. + v, e := bookmark(r, r.PathValue("id"), false) + if e != nil { + respond(w, 0, nil, e) + return + } + markdown.Send(w, markdown.BookmarkDocument(v), str(v, "title")) +} + +// previewKey is the shared-budget identity for preview work (review CR-06): +// per user, across every entry point (route + mutations). +func previewKey(r *http.Request) string { return "user:" + user(r) } + +func HandlePreview(w http.ResponseWriter, r *http.Request) { + u := r.URL.Query().Get("url") + if !validURL(u) { + respond(w, 0, nil, bad("A valid HTTP(S) URL is required")) + return + } + respond(w, 200, preview.FetchPreview(r.Context(), previewKey(r), u), nil) +} diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go new file mode 100644 index 0000000..54aae0d --- /dev/null +++ b/internal/handlers/collections.go @@ -0,0 +1,1493 @@ +package handlers + +import ( + "database/sql" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/andrew/go-computer/internal/db" + "math" + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +// Collections (Notion-like upgrades phase 2, N-01/N-02/N-03). +// +// Locked semantics: +// - Membership is contextual: items reference bookmarks without owning +// them; removing an item or a whole collection never touches bookmarks. +// - Membership never broadens access: every item surface re-checks +// db.BookmarkVisible, and items whose bookmark was revoked behave as +// not-found (N-08). Creating an item requires current visibility. +// - Every mutating PATCH requires the expected `version` (400 missing, +// 409 stale) — the DATA-05 lesson applied from day one. Indirect +// mutations advance revisions too (review CR-09): reorder bumps every +// affected item; property deletion bumps the items whose value it +// dropped and the views whose definition it changed. Deletions are +// idempotent removals and take no revision — they never write state +// computed from a client snapshot. +// - Views are queries: filters/sorts/grouping are stored definitions, +// validated against registered properties with fixed operator allowlists; +// values are bound parameters, never interpolated SQL. +// - Property types are immutable after creation and RELATION is rejected +// until cross-collection access is designed (draft §6 open issue). + +var ( + collectionPropTypes = map[string]bool{ + "TEXT": true, "NUMBER": true, "SELECT": true, "MULTI_SELECT": true, + "DATE": true, "CHECKBOX": true, "URL": true, + // RELATION exists in the schema CHECK but is API-rejected for now. + } + collectionViewTypes = map[string]bool{"TABLE": true, "BOARD": true, "CALENDAR": true, "LIST": true} + collectionGroupTypes = map[string]bool{"SELECT": true, "MULTI_SELECT": true, "CHECKBOX": true, "TEXT": true} + collectionSortTypes = map[string]bool{"TEXT": true, "URL": true, "NUMBER": true, "SELECT": true, "DATE": true, "CHECKBOX": true} + // Filter operator allowlist per property type. + collectionFilterOps = map[string]map[string]bool{ + "TEXT": {"eq": true, "ne": true, "contains": true, "empty": true, "notEmpty": true}, + "URL": {"eq": true, "ne": true, "contains": true, "empty": true, "notEmpty": true}, + "NUMBER": {"eq": true, "ne": true, "gt": true, "gte": true, "lt": true, "lte": true, "empty": true, "notEmpty": true}, + "SELECT": {"eq": true, "ne": true, "empty": true, "notEmpty": true}, + "MULTI_SELECT": {"contains": true, "empty": true, "notEmpty": true}, + "DATE": {"eq": true, "ne": true, "gt": true, "gte": true, "lt": true, "lte": true, "empty": true, "notEmpty": true}, + "CHECKBOX": {"eq": true, "ne": true, "empty": true, "notEmpty": true}, + } + dateOnlyPattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + // Fixed-width so lexicographic comparison equals chronological order. + dateTimeStorageFormat = "2006-01-02T15:04:05.000000000Z07:00" + likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) +) + +func collectionOwned(r *http.Request, q db.Queryer, id string) (object, error) { + return db.One(r.Context(), q, `SELECT to_jsonb(c) FROM "Collection" c WHERE c.id=$1 AND c."ownerId"=$2`, id, user(r)) +} +func collectionProperties(r *http.Request, q db.Queryer, collectionID string) ([]object, error) { + return db.Many(r.Context(), q, `SELECT to_jsonb(p) FROM "CollectionProperty" p WHERE p."collectionId"=$1 ORDER BY p.position,p.id`, collectionID) +} +func collectionViews(r *http.Request, q db.Queryer, collectionID string) ([]object, error) { + return db.Many(r.Context(), q, `SELECT to_jsonb(v) FROM "CollectionView" v WHERE v."collectionId"=$1 ORDER BY v.position,v.id`, collectionID) +} +func propertyIndex(props []object) map[string]object { + byID := map[string]object{} + for _, p := range props { + byID[str(p, "id")] = p + } + return byID +} +func collectionDetail(r *http.Request, q db.Queryer, col object) (object, error) { + props, e := collectionProperties(r, q, str(col, "id")) + if e != nil { + return nil, e + } + views, e := collectionViews(r, q, str(col, "id")) + if e != nil { + return nil, e + } + // Same authorized-count shape as the list endpoint: items only counts + // bookmarks the caller can currently see (N-10 edge case). + counts, e := db.One(r.Context(), q, `SELECT jsonb_build_object( + 'items',(SELECT count(*) FROM "CollectionItem" ci JOIN "Bookmark" b ON b.id=ci."bookmarkId" WHERE ci."collectionId"=$1 AND `+db.BookmarkVisible+`), + 'properties',(SELECT count(*) FROM "CollectionProperty" p WHERE p."collectionId"=$1), + 'views',(SELECT count(*) FROM "CollectionView" v WHERE v."collectionId"=$1))`, str(col, "id"), user(r)) + if e != nil { + return nil, e + } + out := object{} + for k, v := range col { + out[k] = v + } + out["properties"], out["views"], out["_count"] = props, views, counts + return out, nil +} + +// patchVersion enforces the DATA-05 rule on every mutating collection PATCH: +// the base version is required, never optional. +func patchVersion(b object) (int, error) { + v, ok := b["version"] + if !ok { + return 0, bad("version is required (optimistic concurrency); send the value from GET") + } + f, isNum := v.(float64) + if !isNum || f < 0 || f != float64(int64(f)) { + return 0, bad("version must be a non-negative integer") + } + return int(f), nil +} +func intField(b object, k string) (int, bool, error) { + v, ok := b[k] + if !ok { + return 0, false, nil + } + f, isNum := v.(float64) + if !isNum || f < 0 || f != float64(int64(f)) { + return 0, false, bad(k + " must be a non-negative integer") + } + return int(f), true, nil +} +func trimLimit(v string, max int) string { + r := []rune(v) + if len(r) > max { + r = r[:max] + } + return string(r) +} + +func collectionData(b object) (object, error) { + d := fields(b, "name description icon") + if e := stringFields(d, "description icon", "name"); e != nil { + return nil, e + } + if v, ok := d["name"]; ok { + s := strings.TrimSpace(v.(string)) + if s == "" { + return nil, bad("Name required") + } + d["name"] = trimLimit(s, 120) + } + if v, ok := d["description"]; ok && v != nil { + d["description"] = trimLimit(v.(string), 2000) + } + if v, ok := d["icon"]; ok && v != nil { + d["icon"] = trimLimit(v.(string), 64) + } + return d, nil +} + +func HandleCollectionsList(w http.ResponseWriter, r *http.Request) { + // Item counts are authorized counts (N-10 edge case): a bookmark the + // caller can no longer see never inflates them. Collections are + // owner-scoped in phase 2, so $1 (owner) and $2 (BookmarkVisible) match. + v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(c)||jsonb_build_object('_count',jsonb_build_object( + 'items',(SELECT count(*) FROM "CollectionItem" ci JOIN "Bookmark" b ON b.id=ci."bookmarkId" WHERE ci."collectionId"=c.id AND `+db.BookmarkVisible+`), + 'properties',(SELECT count(*) FROM "CollectionProperty" p WHERE p."collectionId"=c.id), + 'views',(SELECT count(*) FROM "CollectionView" v WHERE v."collectionId"=c.id))) + FROM "Collection" c WHERE c."ownerId"=$1 ORDER BY c."createdAt",c.id`, user(r), user(r)) + respond(w, 200, v, e) +} + +func HandleCollectionsCreate(w http.ResponseWriter, r *http.Request) { + b, ok := read(w, r) + if !ok { + return + } + d, e := collectionData(b) + if e != nil { + respond(w, 0, nil, e) + return + } + if _, ok := d["name"]; !ok { + respond(w, 0, nil, bad("Name required")) + return + } + d["id"], d["ownerId"] = db.NewID(), user(r) + var out object + e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + if e := lockUser(r, tx); e != nil { + return e + } + col, e := db.Insert(r.Context(), tx, "Collection", d) + if e != nil { + return e + } + // Every collection starts with a default table view (draft §7). + if _, e := db.Insert(r.Context(), tx, "CollectionView", object{ + "id": db.NewID(), "collectionId": str(col, "id"), "name": "Table", "type": "TABLE", "position": 0, + }); e != nil { + return e + } + out, e = collectionDetail(r, tx, col) + return e + }) + respond(w, 201, out, e) +} + +func HandleCollectionsGet(w http.ResponseWriter, r *http.Request) { + col, e := collectionOwned(r, database(r), r.PathValue("id")) + if e != nil { + respond(w, 0, nil, e) + return + } + out, e := collectionDetail(r, database(r), col) + respond(w, 200, out, e) +} + +func HandleCollectionsUpdate(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + b, ok := read(w, r) + if !ok { + return + } + base, e := patchVersion(b) + if e != nil { + respond(w, 0, nil, e) + return + } + d, e := collectionData(b) + if e != nil { + respond(w, 0, nil, e) + return + } + d["updatedAt"] = time.Now().UTC() + var out object + e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + if e := lockUser(r, tx); e != nil { + return e + } + cur, e := collectionOwned(r, tx, id) + if e != nil { + return e + } + stored, _ := cur["version"].(float64) + if int(stored) != base { + return conflict("Collection was modified elsewhere; reload to see the latest version") + } + d["version"] = base + 1 + out, e = db.Update(r.Context(), tx, "Collection", id, "ownerId", user(r), d) + return e + }) + respond(w, 200, out, e) +} + +func HandleCollectionsDelete(w http.ResponseWriter, r *http.Request) { + // Memberships, properties and views cascade; bookmarks are never touched + // (N-01 acceptance criterion). + respond(w, 200, OK{true}, remove(r, "Collection", r.PathValue("id"), "ownerId")) +} + +// --- properties ----------------------------------------------------------- + +func optionList(config any) []object { + m, _ := config.(object) + arr, _ := m["options"].([]any) + out := make([]object, 0, len(arr)) + for _, o := range arr { + if opt, ok := o.(object); ok { + out = append(out, opt) + } + } + return out +} +func optionIDs(prop object) map[string]bool { + ids := map[string]bool{} + for _, o := range optionList(prop["config"]) { + if s := str(o, "id"); s != "" { + ids[s] = true + } + } + return ids +} + +// validatePropertyConfig validates and normalizes config for a property type. +// existing is the current stored option set (nil on create): incoming options +// may reference existing ids (rename/recolor, identity preserved) or omit ids +// (server assigns a stable one). +func validatePropertyConfig(typ string, raw any, existing []object) (object, []string, error) { + switch typ { + case "SELECT", "MULTI_SELECT": + m, ok := raw.(object) + if raw != nil && !ok { + return nil, nil, bad("config must be an object") + } + if m == nil { + m = object{} + } + arr, hasOptions := m["options"].([]any) + if raw != nil && !hasOptions { + return nil, nil, bad("SELECT properties require config.options") + } + if len(arr) > 100 { + return nil, nil, bad("Too many options (max 100)") + } + byID := map[string]object{} + for _, o := range existing { + byID[str(o, "id")] = o + } + options, seen := []object{}, map[string]bool{} + for _, entry := range arr { + o, ok := entry.(object) + if !ok { + return nil, nil, bad("Options must be objects with a label") + } + label := strings.TrimSpace(str(o, "label")) + if label == "" { + return nil, nil, bad("Option label required") + } + opt := object{"label": trimLimit(label, 80)} + if c := str(o, "color"); c != "" { + if !colorPattern.MatchString(c) { + return nil, nil, bad("Option color must be #RRGGBB") + } + opt["color"] = c + } + if id := str(o, "id"); id != "" { + if _, ok := byID[id]; !ok { + return nil, nil, bad("Unknown option id; new options must omit id") + } + if seen[id] { + return nil, nil, bad("Duplicate option id") + } + seen[id] = true + opt["id"] = id + } else { + opt["id"] = db.NewID() + } + options = append(options, opt) + } + removed := []string{} + for id := range byID { + if !seen[id] { + removed = append(removed, id) + } + } + return object{"options": options}, removed, nil + case "DATE": + m, ok := raw.(object) + if raw != nil && !ok { + return nil, nil, bad("config must be an object") + } + mode := "date" + if m != nil { + if v, ok := m["mode"]; ok { + s, isStr := v.(string) + if !isStr || (s != "date" && s != "datetime") { + return nil, nil, bad("Date mode must be 'date' or 'datetime'") + } + mode = s + } + } + return object{"mode": mode}, nil, nil + default: + if m, ok := raw.(object); ok && raw != nil && len(m) > 0 { + return nil, nil, bad(typ + " properties take no config") + } + return object{}, nil, nil + } +} + +// normalizePropertyValue validates one value against its property definition +// and returns the canonical storage form (dates are fixed-width UTC strings, +// numbers JSON numbers, multi-selects deduplicated option-id arrays). +func normalizePropertyValue(prop object, v any) (any, error) { + name := str(prop, "name") + switch str(prop, "type") { + case "TEXT": + s, ok := v.(string) + if !ok { + return nil, bad(name + " must be text") + } + return trimLimit(s, 4000), nil + case "URL": + s, ok := v.(string) + if !ok || !validURL(s) { + return nil, bad(name + " must be a valid HTTP(S) URL") + } + return trimLimit(s, 2000), nil + case "NUMBER": + f, ok := v.(float64) + if !ok || math.IsNaN(f) || math.IsInf(f, 0) { + return nil, bad(name + " must be a number") + } + return f, nil + case "CHECKBOX": + b, ok := v.(bool) + if !ok { + return nil, bad(name + " must be a boolean") + } + return b, nil + case "SELECT": + s, ok := v.(string) + if !ok || !optionIDs(prop)[s] { + return nil, bad(name + " must be one of its options") + } + return s, nil + case "MULTI_SELECT": + arr, ok := v.([]any) + if !ok || len(arr) > 50 { + return nil, bad(name + " must be an array of at most 50 options") + } + ids := optionIDs(prop) + out, seen := make([]string, 0, len(arr)), map[string]bool{} + for _, e := range arr { + s, ok := e.(string) + if !ok || !ids[s] { + return nil, bad(name + " must contain only its options") + } + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + return out, nil + case "DATE": + s, ok := v.(string) + if !ok { + return nil, bad(name + " must be an ISO date string") + } + mode := "date" + if cfg, ok := prop["config"].(object); ok { + if m, ok := cfg["mode"].(string); ok { + mode = m + } + } + if mode == "date" { + if !dateOnlyPattern.MatchString(s) { + return nil, bad(name + " must be YYYY-MM-DD (date-only values never shift across timezones)") + } + if _, e := time.Parse("2006-01-02", s); e != nil { + return nil, bad(name + " must be a real calendar date") + } + return s, nil + } + for _, layout := range []string{time.RFC3339Nano, "2006-01-02T15:04", "2006-01-02T15:04:05", "2006-01-02"} { + if t, e := time.Parse(layout, s); e == nil { + return t.UTC().Format(dateTimeStorageFormat), nil + } + } + return nil, bad(name + " must be an RFC3339 timestamp") + } + return nil, bad("Unsupported property type") +} + +// applyPropertyValues merges a request propertyValues object over the current +// stored values: unknown property ids are rejected, null removes a value +// (omitted keys are untouched — the repo-wide omitted-vs-null rule). +func applyPropertyValues(current object, props []object, raw any) (object, error) { + m, ok := raw.(object) + if !ok { + return nil, bad("propertyValues must be an object keyed by property id") + } + byID := propertyIndex(props) + out := object{} + for k, v := range current { + out[k] = v + } + for k, v := range m { + p, known := byID[k] + if !known { + return nil, bad("Unknown property id " + k) + } + if v == nil { + delete(out, k) + continue + } + nv, e := normalizePropertyValue(p, v) + if e != nil { + return nil, e + } + out[k] = nv + } + return out, nil +} + +func marshalValues(values object) (string, error) { + raw, e := json.Marshal(values) + if e != nil { + return "", e + } + return string(raw), nil +} + +func HandleCollectionPropertiesCreate(w http.ResponseWriter, r *http.Request) { + cid := r.PathValue("id") + b, ok := read(w, r) + if !ok { + return + } + name := strings.TrimSpace(str(b, "name")) + if name == "" { + respond(w, 0, nil, bad("Name required")) + return + } + typ := str(b, "type") + if typ == "RELATION" { + respond(w, 0, nil, bad("RELATION properties are not supported yet")) + return + } + if !collectionPropTypes[typ] { + respond(w, 0, nil, bad("Invalid property type")) + return + } + cfg, _, e := validatePropertyConfig(typ, b["config"], nil) + if e != nil { + respond(w, 0, nil, e) + return + } + raw, _ := json.Marshal(cfg) + var out object + e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + if e := lockUser(r, tx); e != nil { + return e + } + if _, e := collectionOwned(r, tx, cid); e != nil { + return e + } + var n int + if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "CollectionProperty" WHERE "collectionId"=$1`, cid).Scan(&n); e != nil { + return e + } + out, e = db.Insert(r.Context(), tx, "CollectionProperty", object{ + "id": db.NewID(), "collectionId": cid, "name": trimLimit(name, 80), + "type": typ, "position": n, "config": string(raw), + }) + return e + }) + respond(w, 201, out, e) +} + +func HandleCollectionPropertiesUpdate(w http.ResponseWriter, r *http.Request) { + cid, pid := r.PathValue("id"), r.PathValue("propertyId") + b, ok := read(w, r) + if !ok { + return + } + base, e := patchVersion(b) + if e != nil { + respond(w, 0, nil, e) + return + } + if _, ok := b["type"]; ok { + respond(w, 0, nil, bad("Property type cannot be changed; create a new property (conversion must never destroy values)")) + return + } + var out object + e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + if e := lockUser(r, tx); e != nil { + return e + } + if _, e := collectionOwned(r, tx, cid); e != nil { + return e + } + cur, e := db.One(r.Context(), tx, `SELECT to_jsonb(p) FROM "CollectionProperty" p WHERE p.id=$1 AND p."collectionId"=$2`, pid, cid) + if e != nil { + return e + } + stored, _ := cur["version"].(float64) + if int(stored) != base { + return conflict("Property was modified elsewhere; reload to see the latest version") + } + typ := str(cur, "type") + d := object{"version": base + 1, "updatedAt": time.Now().UTC()} + if v, ok := b["name"]; ok { + s, isStr := v.(string) + if !isStr || strings.TrimSpace(s) == "" { + return bad("Name must be non-empty text") + } + d["name"] = trimLimit(strings.TrimSpace(s), 80) + } + if pos, ok2, e2 := intField(b, "position"); e2 != nil { + return e2 + } else if ok2 { + d["position"] = pos + } + if v, ok := b["config"]; ok { + cfg, removed, e2 := validatePropertyConfig(typ, v, optionList(cur["config"])) + if e2 != nil { + return e2 + } + // Date mode flips would silently reinterpret every stored value. + if typ == "DATE" { + oldMode, newMode := "date", "date" + if c, ok := cur["config"].(object); ok { + if m, ok := c["mode"].(string); ok { + oldMode = m + } + } + if m, ok := cfg["mode"].(string); ok { + newMode = m + } + if oldMode != newMode { + return bad("Date mode cannot be changed; create a new property") + } + } + // Stated policy for option removal with existing values: refuse + // and report the count — clearing values is a deliberate act. + for _, oid := range removed { + var n int + q := `SELECT count(*) FROM "CollectionItem" WHERE "collectionId"=$1 AND "propertyValues"->>$2=$3` + if typ == "MULTI_SELECT" { + q = `SELECT count(*) FROM "CollectionItem" WHERE "collectionId"=$1 AND jsonb_exists("propertyValues"->$2,$3)` + } + if e3 := tx.QueryRowContext(r.Context(), q, cid, pid, oid).Scan(&n); e3 != nil { + return e3 + } + if n > 0 { + return bad(fmt.Sprintf("Option is still used by %d item(s); clear those values first", n)) + } + } + raw, _ := json.Marshal(cfg) + d["config"] = string(raw) + } + out, e = db.Update(r.Context(), tx, "CollectionProperty", pid, "collectionId", cid, d) + return e + }) + respond(w, 200, out, e) +} + +func HandleCollectionPropertiesDelete(w http.ResponseWriter, r *http.Request) { + cid, pid := r.PathValue("id"), r.PathValue("propertyId") + e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + if e := lockUser(r, tx); e != nil { + return e + } + if _, e := collectionOwned(r, tx, cid); e != nil { + return e + } + res, e := tx.ExecContext(r.Context(), `DELETE FROM "CollectionProperty" WHERE id=$1 AND "collectionId"=$2`, pid, cid) + if e != nil { + return e + } + if n, _ := res.RowsAffected(); n == 0 { + return db.ErrNotFound + } + // Drop the property's values everywhere and scrub it from saved view + // definitions, so no view keeps filtering/sorting on a dead id + // (groupByPropertyId clears itself via ON DELETE SET NULL). + // Review CR-09: these are indirect mutations of items and views, so + // their revisions advance too — items whose stored value was dropped + // and views whose filter/sort definition actually changed. Clients + // caching by version then see the structural change, and an in-flight + // item PATCH based on the pre-deletion property set gets a 409 + // instead of resurrecting a dropped value. + if _, e := tx.ExecContext(r.Context(), `UPDATE "CollectionItem" SET "propertyValues"="propertyValues"-$2,version=version+1,"updatedAt"=now() WHERE "collectionId"=$1 AND jsonb_exists("propertyValues",$2)`, cid, pid); e != nil { + return e + } + views, e := collectionViews(r, tx, cid) + if e != nil { + return e + } + for _, v := range views { + filters := scrubFilters(v["filters"], pid) + sorts := scrubSorts(v["sorts"], pid) + rawF, _ := json.Marshal(filters) + rawS, _ := json.Marshal(sorts) + changed := false + if v["filters"] != nil { + origF, _ := json.Marshal(v["filters"]) + changed = string(rawF) != string(origF) + } + if v["sorts"] != nil { + origS, _ := json.Marshal(v["sorts"]) + if string(rawS) != string(origS) { + changed = true + } + } + if !changed { + continue // view never referenced the property; leave its version alone + } + if _, e := tx.ExecContext(r.Context(), `UPDATE "CollectionView" SET filters=$1,sorts=$2,version=version+1,"updatedAt"=now() WHERE id=$3`, string(rawF), string(rawS), str(v, "id")); e != nil { + return e + } + } + return nil + }) + respond(w, 200, OK{true}, e) +} + +func scrubFilters(raw any, pid string) object { + out := object{"operator": "AND", "clauses": []any{}} + m, _ := raw.(object) + if op := str(m, "operator"); op == "OR" { + out["operator"] = "OR" + } + arr, _ := m["clauses"].([]any) + kept := []any{} + for _, c := range arr { + cl, _ := c.(object) + if str(cl, "propertyId") != pid { + kept = append(kept, cl) + } + } + out["clauses"] = kept + return out +} +func scrubSorts(raw any, pid string) []any { + arr, _ := raw.([]any) + kept := []any{} + for _, s := range arr { + so, _ := s.(object) + if str(so, "field") != pid { + kept = append(kept, so) + } + } + return kept +} + +// --- views ---------------------------------------------------------------- + +func validateFilters(props []object, raw any) (object, error) { + m, ok := raw.(object) + if !ok { + return nil, bad("filters must be an object") + } + operator := str(m, "operator") + if operator == "" { + operator = "AND" + } + if operator != "AND" && operator != "OR" { + return nil, bad("Filter operator must be AND or OR (nested groups are not supported)") + } + if v, present := m["clauses"]; present { + if _, ok := v.([]any); !ok && v != nil { + return nil, bad("Filter clauses must be an array") + } + } + arr, _ := m["clauses"].([]any) + if len(arr) > 20 { + return nil, bad("Too many filter clauses (max 20)") + } + byID := propertyIndex(props) + clauses := []any{} + for _, entry := range arr { + cl, ok := entry.(object) + if !ok { + return nil, bad("Filter clause must be an object") + } + pid, op := str(cl, "propertyId"), str(cl, "op") + p, known := byID[pid] + if !known { + return nil, bad("Filter references an unknown property") + } + typ := str(p, "type") + if !collectionFilterOps[typ][op] { + return nil, bad("Operator " + op + " is not allowed on " + typ + " properties") + } + out := object{"propertyId": pid, "op": op} + if op != "empty" && op != "notEmpty" { + v, present := cl["value"] + if !present || v == nil { + return nil, bad("Operator " + op + " requires a value") + } + switch { + case op == "contains" && typ == "MULTI_SELECT": + s, isStr := v.(string) + if !isStr || !optionIDs(p)[s] { + return nil, bad("contains on a multi-select requires one of its option ids") + } + out["value"] = s + case op == "contains": // TEXT/URL substring + s, isStr := v.(string) + if !isStr || s == "" { + return nil, bad("contains requires a non-empty string") + } + out["value"] = trimLimit(s, 2000) + default: + nv, e := normalizePropertyValue(p, v) + if e != nil { + return nil, e + } + out["value"] = nv + } + } + clauses = append(clauses, out) + } + return object{"operator": operator, "clauses": clauses}, nil +} + +func validateSorts(props []object, raw any) ([]any, error) { + arr, ok := raw.([]any) + if !ok { + return nil, bad("sorts must be an array") + } + if len(arr) > 3 { + return nil, bad("Too many sort keys (max 3)") + } + byID := propertyIndex(props) + out := []any{} + for _, entry := range arr { + s, ok := entry.(object) + if !ok { + return nil, bad("Sort entry must be an object") + } + field, dir := str(s, "field"), str(s, "dir") + if dir == "" { + dir = "asc" + } + if dir != "asc" && dir != "desc" { + return nil, bad("Sort dir must be asc or desc") + } + switch field { + case "position", "addedAt", "title": + default: + p, known := byID[field] + if !known { + return nil, bad("Sort references an unknown property") + } + if !collectionSortTypes[str(p, "type")] { + return nil, bad("Multi-select properties cannot be sorted on") + } + } + out = append(out, object{"field": field, "dir": dir}) + } + return out, nil +} + +func viewData(b object, props []object) (object, error) { + d := object{} + if v, ok := b["name"]; ok { + s, isStr := v.(string) + if !isStr || strings.TrimSpace(s) == "" { + return nil, bad("View name must be non-empty text") + } + d["name"] = trimLimit(strings.TrimSpace(s), 80) + } + if v, ok := b["type"]; ok { + s, isStr := v.(string) + if !isStr || !collectionViewTypes[s] { + return nil, bad("View type must be TABLE, BOARD, CALENDAR or LIST") + } + d["type"] = s + } + if v, ok := b["filters"]; ok { + f, e := validateFilters(props, v) + if e != nil { + return nil, e + } + raw, _ := json.Marshal(f) + d["filters"] = string(raw) + } + if v, ok := b["sorts"]; ok { + s, e := validateSorts(props, v) + if e != nil { + return nil, e + } + raw, _ := json.Marshal(s) + d["sorts"] = string(raw) + } + if v, ok := b["groupByPropertyId"]; ok { + if v == nil { + d["groupByPropertyId"] = nil + } else { + s, isStr := v.(string) + if !isStr { + return nil, bad("groupByPropertyId must be text or null") + } + found := false + for _, p := range props { + if str(p, "id") == s { + if !collectionGroupTypes[str(p, "type")] { + return nil, bad("Grouping is supported on select, multi-select, checkbox and text properties") + } + found = true + } + } + if !found { + return nil, bad("groupByPropertyId references an unknown property") + } + d["groupByPropertyId"] = s + } + } + if v, ok := b["displaySettings"]; ok { + if v == nil { + d["displaySettings"] = "{}" + } else { + m, isObj := v.(object) + if !isObj { + return nil, bad("displaySettings must be an object") + } + raw, e := json.Marshal(m) + if e != nil || len(raw) > 8<<10 { + return nil, bad("displaySettings too large (max 8KB)") + } + d["displaySettings"] = string(raw) + } + } + return d, nil +} + +func HandleCollectionViewsCreate(w http.ResponseWriter, r *http.Request) { + cid := r.PathValue("id") + b, ok := read(w, r) + if !ok { + return + } + var out object + e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + if e := lockUser(r, tx); e != nil { + return e + } + if _, e := collectionOwned(r, tx, cid); e != nil { + return e + } + props, e := collectionProperties(r, tx, cid) + if e != nil { + return e + } + d, e := viewData(b, props) + if e != nil { + return e + } + if _, ok := d["name"]; !ok { + return bad("View name required") + } + if _, ok := d["type"]; !ok { + d["type"] = "TABLE" + } + var n int + if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "CollectionView" WHERE "collectionId"=$1`, cid).Scan(&n); e != nil { + return e + } + d["id"], d["collectionId"], d["position"] = db.NewID(), cid, n + out, e = db.Insert(r.Context(), tx, "CollectionView", d) + return e + }) + respond(w, 201, out, e) +} + +func HandleCollectionViewsUpdate(w http.ResponseWriter, r *http.Request) { + cid, vid := r.PathValue("id"), r.PathValue("viewId") + b, ok := read(w, r) + if !ok { + return + } + base, e := patchVersion(b) + if e != nil { + respond(w, 0, nil, e) + return + } + var out object + e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + if e := lockUser(r, tx); e != nil { + return e + } + if _, e := collectionOwned(r, tx, cid); e != nil { + return e + } + cur, e := db.One(r.Context(), tx, `SELECT to_jsonb(v) FROM "CollectionView" v WHERE v.id=$1 AND v."collectionId"=$2`, vid, cid) + if e != nil { + return e + } + stored, _ := cur["version"].(float64) + if int(stored) != base { + return conflict("View was modified elsewhere; reload to see the latest version") + } + props, e := collectionProperties(r, tx, cid) + if e != nil { + return e + } + d, e := viewData(b, props) + if e != nil { + return e + } + if pos, ok2, e2 := intField(b, "position"); e2 != nil { + return e2 + } else if ok2 { + d["position"] = pos + } + d["version"], d["updatedAt"] = base+1, time.Now().UTC() + out, e = db.Update(r.Context(), tx, "CollectionView", vid, "collectionId", cid, d) + return e + }) + respond(w, 200, out, e) +} + +func HandleCollectionViewsDelete(w http.ResponseWriter, r *http.Request) { + cid, vid := r.PathValue("id"), r.PathValue("viewId") + res, e := database(r).ExecContext(r.Context(), `DELETE FROM "CollectionView" WHERE id=$1 AND "collectionId"=$2 AND "collectionId" IN (SELECT id FROM "Collection" WHERE "ownerId"=$3)`, vid, cid, user(r)) + if e == nil { + if n, _ := res.RowsAffected(); n == 0 { + e = db.ErrNotFound + } + } + respond(w, 200, OK{true}, e) +} + +// --- items ---------------------------------------------------------------- + +// collectionItemRow is the canonical item projection: the membership row plus +// the permission-appropriate bookmark view. Gated on BookmarkVisible so a +// revoked share makes the item not-found everywhere (N-08). +func collectionItemRow(r *http.Request, q db.Queryer, cid, itemID string) (object, error) { + return db.One(r.Context(), q, `SELECT to_jsonb(ci)||jsonb_build_object('bookmark',`+db.BookmarkViewerView+`) + FROM "CollectionItem" ci JOIN "Bookmark" b ON b.id=ci."bookmarkId" JOIN "Collection" c ON c.id=ci."collectionId" + WHERE ci.id=$1 AND ci."collectionId"=$3 AND c."ownerId"=$2 AND `+db.BookmarkVisible, itemID, user(r), cid) +} + +// filterClauseSQL renders one validated clause into a parameterized +// predicate. Property ids and values are always bind parameters — JSONB key +// access takes the key as $n, so no request data ever reaches SQL text. +func filterClauseSQL(byID map[string]object, cl object, args *[]any) (string, error) { + pid, op := str(cl, "propertyId"), str(cl, "op") + p, ok := byID[pid] + if !ok { + return "", bad("Filter references an unknown property") + } + if !collectionFilterOps[str(p, "type")][op] { + return "", bad("Operator " + op + " is not allowed on this property") + } + *args = append(*args, pid) + k := len(*args) + typ := str(p, "type") + missing := fmt.Sprintf(`(NOT jsonb_exists(ci."propertyValues",$%d) OR jsonb_typeof(ci."propertyValues"->$%d)='null')`, k, k) + present := fmt.Sprintf(`(jsonb_exists(ci."propertyValues",$%d) AND jsonb_typeof(ci."propertyValues"->$%d)<>'null')`, k, k) + switch op { + case "empty": + return missing, nil + case "notEmpty": + return present, nil + } + v, hasValue := cl["value"] + if !hasValue || v == nil { + return "", bad("Operator " + op + " requires a value") + } + var expr, cast string + switch typ { + case "NUMBER": + expr = fmt.Sprintf(`(CASE WHEN jsonb_typeof(ci."propertyValues"->$%d)='number' THEN (ci."propertyValues"->>$%d)::numeric END)`, k, k) + cast = "::numeric" + case "CHECKBOX": + expr = fmt.Sprintf(`(CASE WHEN jsonb_typeof(ci."propertyValues"->$%d)='boolean' THEN (ci."propertyValues"->>$%d)::boolean END)`, k, k) + cast = "::boolean" + default: // TEXT, URL, SELECT, DATE (canonical fixed-width strings), MULTI_SELECT containment + expr = fmt.Sprintf(`(CASE WHEN jsonb_typeof(ci."propertyValues"->$%d)='string' THEN ci."propertyValues"->>$%d END)`, k, k) + } + // Values bind as text and are cast per type; the formatter keeps numbers + // in fixed notation so ::numeric never sees exponent forms. + var vstr string + switch t := v.(type) { + case string: + vstr = t + case float64: + vstr = strconv.FormatFloat(t, 'f', -1, 64) + case bool: + vstr = strconv.FormatBool(t) + default: + return "", bad("Unsupported filter value") + } + if op == "contains" && typ != "MULTI_SELECT" { + // Stored filter values stay verbatim; LIKE escaping happens at query + // build time so user % and _ are matched literally. + vstr = "%" + likeEscaper.Replace(vstr) + "%" + } + *args = append(*args, vstr) + vn := len(*args) + switch op { + case "contains": + if typ == "MULTI_SELECT" { + return fmt.Sprintf(`ci."propertyValues"->$%d @> to_jsonb($%d::text)`, k, vn), nil + } + return fmt.Sprintf(`%s ILIKE $%d ESCAPE '\'`, expr, vn), nil + case "eq": + return fmt.Sprintf(`%s = $%d%s`, expr, vn, cast), nil + case "ne": + return fmt.Sprintf(`%s IS DISTINCT FROM $%d%s`, expr, vn, cast), nil + case "gt", "gte", "lt", "lte": + sym := map[string]string{"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[op] + return fmt.Sprintf(`%s %s $%d%s`, expr, sym, vn, cast), nil + } + return "", bad("Unsupported filter operator") +} + +// sortPlan describes the single server-side sort key honored by the items +// endpoint in phase 2 (first entry of view.sorts; extra keys are accepted +// and stored but pagination keys on the first — documented in API.md). +type sortPlan struct { + expr string // SQL expression, "" for the canonical (position,id) order + cast string // cursor parameter cast for expr + nullable bool + desc bool + // extract pulls the cursor value from a response item ("v" field). + extract func(item object) *string +} + +func cursorEncode(v *string, id string) string { + raw, _ := json.Marshal(object{"v": v, "id": id}) + return base64.RawURLEncoding.EncodeToString(raw) +} +func jsonbString(v any) *string { + switch t := v.(type) { + case string: + return &t + case float64: + s := strconv.FormatFloat(t, 'f', -1, 64) + return &s + case bool: + s := strconv.FormatBool(t) + return &s + } + return nil +} + +func buildSortPlan(byID map[string]object, sorts []any, args *[]any) (sortPlan, error) { + plan := sortPlan{ + expr: `ci.position`, cast: "::bigint", desc: false, + extract: func(item object) *string { return jsonbString(item["position"]) }, + } + if len(sorts) == 0 { + return plan, nil + } + first, _ := sorts[0].(object) + field, dir := str(first, "field"), str(first, "dir") + plan.desc = dir == "desc" + switch field { + case "", "position": + return plan, nil + case "addedAt": + plan.expr, plan.cast = `ci."addedAt"`, "::timestamptz" + plan.extract = func(item object) *string { return jsonbString(item["addedAt"]) } + return plan, nil + case "title": + plan.expr, plan.cast, plan.nullable = `b.title`, "", true + plan.extract = func(item object) *string { + bm, _ := item["bookmark"].(object) + return jsonbString(bm["title"]) + } + return plan, nil + } + p, ok := byID[field] + if !ok { + return plan, bad("Sort references an unknown property") + } + if !collectionSortTypes[str(p, "type")] { + return plan, bad("Multi-select properties cannot be sorted on") + } + *args = append(*args, field) + k := len(*args) + plan.nullable = true + switch str(p, "type") { + case "NUMBER": + plan.expr, plan.cast = fmt.Sprintf(`(CASE WHEN jsonb_typeof(ci."propertyValues"->$%d)='number' THEN (ci."propertyValues"->>$%d)::numeric END)`, k, k), "::numeric" + case "CHECKBOX": + plan.expr, plan.cast = fmt.Sprintf(`(CASE WHEN jsonb_typeof(ci."propertyValues"->$%d)='boolean' THEN (ci."propertyValues"->>$%d)::boolean END)`, k, k), "::boolean" + default: + plan.expr = fmt.Sprintf(`(CASE WHEN jsonb_typeof(ci."propertyValues"->$%d)='string' THEN ci."propertyValues"->>$%d END)`, k, k) + } + plan.extract = func(item object) *string { + pv, _ := item["propertyValues"].(object) + return jsonbString(pv[field]) + } + return plan, nil +} + +// keysetPredicate is the cursor condition matching the ORDER BY below. +// Deterministic tie-break: ci.id ASC on every plan (draft §5 edge case). +func (p sortPlan) keysetPredicate(vArg, idArg int) string { + v := fmt.Sprintf("$%d%s", vArg, p.cast) + id := fmt.Sprintf(`ci.id>$%d`, idArg) + dir := ">" + if p.desc { + dir = "<" + } + if !p.nullable { + return fmt.Sprintf(`(%s %s %s OR (%s = %s AND %s))`, p.expr, dir, v, p.expr, v, id) + } + if p.desc { + return fmt.Sprintf(`((%s IS NOT NULL AND ((%s = %s AND %s) OR %s %s %s OR %s IS NULL)) OR (%s IS NULL AND %s IS NULL AND %s))`, + v, p.expr, v, id, p.expr, dir, v, p.expr, v, p.expr, id) + } + return fmt.Sprintf(`((%s IS NOT NULL AND (%s %s %s OR (%s = %s AND %s) OR %s IS NULL)) OR (%s IS NULL AND %s IS NULL AND %s))`, + v, p.expr, dir, v, p.expr, v, id, p.expr, v, p.expr, id) +} + +func (p sortPlan) orderBy() string { + dir := "ASC" + if p.desc { + dir = "DESC" + } + if p.nullable { + return fmt.Sprintf(`%s %s NULLS LAST,ci.id ASC`, p.expr, dir) + } + return fmt.Sprintf(`%s %s,ci.id ASC`, p.expr, dir) +} + +func HandleCollectionItemsList(w http.ResponseWriter, r *http.Request) { + cid := r.PathValue("id") + sp := r.URL.Query() + limit := 200 + if raw := sp.Get("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || n > 200 { + respond(w, 0, nil, bad("Limit must be between 1 and 200")) + return + } + limit = n + } + // Ownership is the gate: a foreign collection is a 404, not an empty list. + if _, e := collectionOwned(r, database(r), cid); e != nil { + respond(w, 0, nil, e) + return + } + props, e := collectionProperties(r, database(r), cid) + if e != nil { + respond(w, 0, nil, e) + return + } + byID := propertyIndex(props) + // $1 stays a permanently-nil placeholder (BookmarkVisible binds $2), the + // same port artifact the bookmarks list documents. + args := []any{nil, user(r), cid} + where := `c."ownerId"=$2 AND ci."collectionId"=$3 AND ` + db.BookmarkVisible + ` AND $1::text IS NULL` + var sorts []any + if viewID := sp.Get("viewId"); viewID != "" { + view, e2 := db.One(r.Context(), database(r), `SELECT to_jsonb(v) FROM "CollectionView" v WHERE v.id=$1 AND v."collectionId"=$2`, viewID, cid) + if e2 != nil { + respond(w, 0, nil, bad("Unknown view for this collection")) + return + } + if filters, ok := view["filters"].(object); ok { + clauses, _ := filters["clauses"].([]any) + joiner := " AND " + if str(filters, "operator") == "OR" { + joiner = " OR " + } + parts := make([]string, 0, len(clauses)) + for _, raw := range clauses { + cl, _ := raw.(object) + part, e3 := filterClauseSQL(byID, cl, &args) + if e3 != nil { + respond(w, 0, nil, e3) + return + } + parts = append(parts, part) + } + if len(parts) > 0 { + where += " AND (" + strings.Join(parts, joiner) + ")" + } + } + if s, ok := view["sorts"].([]any); ok { + sorts = s + } + } + plan, e := buildSortPlan(byID, sorts, &args) + if e != nil { + respond(w, 0, nil, e) + return + } + if raw := sp.Get("cursor"); raw != "" { + var cur struct { + V *string `json:"v"` + ID string `json:"id"` + } + data, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil || json.Unmarshal(data, &cur) != nil || cur.ID == "" { + respond(w, 0, nil, bad("Invalid cursor")) + return + } + args = append(args, cur.ID) + idArg := len(args) + if cur.V != nil { + args = append(args, *cur.V) + where += " AND " + plan.keysetPredicate(len(args), idArg) + } else { + // NULL sort value: only NULL-valued rows with a greater id follow. + where += fmt.Sprintf(` AND (%s IS NULL AND ci.id>$%d)`, plan.expr, idArg) + } + } + args = append(args, limit+1) + v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(ci)||jsonb_build_object('bookmark',`+db.BookmarkViewerView+`) + FROM "CollectionItem" ci JOIN "Bookmark" b ON b.id=ci."bookmarkId" JOIN "Collection" c ON c.id=ci."collectionId" + WHERE `+where+fmt.Sprintf(` ORDER BY %s LIMIT $%d`, plan.orderBy(), len(args)), args...) + if e == nil && len(v) > limit { + v = v[:limit] + last := v[len(v)-1] + w.Header().Set("X-Next-Cursor", cursorEncode(plan.extract(last), str(last, "id"))) + } + respond(w, 200, v, e) +} + +func HandleCollectionItemsCreate(w http.ResponseWriter, r *http.Request) { + cid := r.PathValue("id") + b, ok := read(w, r) + if !ok { + return + } + bid := str(b, "bookmarkId") + if bid == "" { + respond(w, 0, nil, bad("bookmarkId required")) + return + } + var out object + e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + if e := lockUser(r, tx); e != nil { + return e + } + if _, e := collectionOwned(r, tx, cid); e != nil { + return e + } + // Membership must not become an access widening vector: only + // currently visible bookmarks can be added (decision #4). + var found string + err := tx.QueryRowContext(r.Context(), `SELECT b.id FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible, bid, user(r)).Scan(&found) + if err == sql.ErrNoRows { + return bad("Bookmark is not accessible") + } + if err != nil { + return err + } + props, e := collectionProperties(r, tx, cid) + if e != nil { + return e + } + values := object{} + if raw, ok := b["propertyValues"]; ok && raw != nil { + values, e = applyPropertyValues(object{}, props, raw) + if e != nil { + return e + } + } + encoded, e := marshalValues(values) + if e != nil { + return e + } + var n int + if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "CollectionItem" WHERE "collectionId"=$1`, cid).Scan(&n); e != nil { + return e + } + itemID := db.NewID() + if _, e := db.Insert(r.Context(), tx, "CollectionItem", object{ + "id": itemID, "collectionId": cid, "bookmarkId": bid, + "propertyValues": encoded, "position": n, + }); e != nil { + return e + } + out, e = collectionItemRow(r, tx, cid, itemID) + return e + }) + if e != nil { + respond(w, 0, nil, e) + return + } + respond(w, 201, out, nil) +} + +func HandleCollectionItemsUpdate(w http.ResponseWriter, r *http.Request) { + cid, itemID := r.PathValue("id"), r.PathValue("itemId") + b, ok := read(w, r) + if !ok { + return + } + base, e := patchVersion(b) + if e != nil { + respond(w, 0, nil, e) + return + } + var out object + e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + if e := lockUser(r, tx); e != nil { + return e + } + if _, e := collectionOwned(r, tx, cid); e != nil { + return e + } + cur, e := collectionItemRow(r, tx, cid, itemID) + if e != nil { + return e + } + stored, _ := cur["version"].(float64) + if int(stored) != base { + return conflict("Item was modified elsewhere; reload to see the latest version") + } + d := object{"version": base + 1, "updatedAt": time.Now().UTC()} + if raw, ok := b["propertyValues"]; ok { + current, _ := cur["propertyValues"].(object) + if current == nil { + current = object{} + } + props, e2 := collectionProperties(r, tx, cid) + if e2 != nil { + return e2 + } + values, e2 := applyPropertyValues(current, props, raw) + if e2 != nil { + return e2 + } + encoded, e2 := marshalValues(values) + if e2 != nil { + return e2 + } + d["propertyValues"] = encoded + } + if pos, ok2, e2 := intField(b, "position"); e2 != nil { + return e2 + } else if ok2 { + d["position"] = pos + } + if _, e := db.Update(r.Context(), tx, "CollectionItem", itemID, "collectionId", cid, d); e != nil { + return e + } + var e2 error + out, e2 = collectionItemRow(r, tx, cid, itemID) + return e2 + }) + if e != nil { + respond(w, 0, nil, e) + return + } + respond(w, 200, out, nil) +} + +func HandleCollectionItemsDelete(w http.ResponseWriter, r *http.Request) { + cid, itemID := r.PathValue("id"), r.PathValue("itemId") + res, e := database(r).ExecContext(r.Context(), `DELETE FROM "CollectionItem" ci WHERE ci.id=$1 AND ci."collectionId"=$3 + AND EXISTS(SELECT 1 FROM "Collection" c WHERE c.id=ci."collectionId" AND c."ownerId"=$2) + AND EXISTS(SELECT 1 FROM "Bookmark" b WHERE b.id=ci."bookmarkId" AND `+db.BookmarkVisible+`)`, itemID, user(r), cid) + if e == nil { + if n, _ := res.RowsAffected(); n == 0 { + e = db.ErrNotFound + } + } + // The bookmark itself is untouched — membership removal never deletes + // records (N-01). + respond(w, 200, OK{true}, e) +} + +func HandleCollectionItemsReorder(w http.ResponseWriter, r *http.Request) { + cid := r.PathValue("id") + b, ok := read(w, r) + if !ok { + return + } + order, e := ids(b, "order") + if e != nil { + respond(w, 0, nil, e) + return + } + e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + if e := lockUser(r, tx); e != nil { + return e + } + if _, e := collectionOwned(r, tx, cid); e != nil { + return e + } + // Count only items whose bookmark is still visible (review COR-2): + // a share revocation hides the membership row from the client, so + // the client can never include it in the order. Counting it here + // made every reorder permanently fail once any bookmark became + // invisible. Hidden memberships keep their rows (re-sharing restores + // them) and are placed after the supplied order below. + var count int + // BookmarkVisible binds the user as $2; collectionId is $1. + if e := tx.QueryRowContext(r.Context(), `SELECT count(*) FROM "CollectionItem" ci JOIN "Bookmark" b ON b.id=ci."bookmarkId" WHERE ci."collectionId"=$1 AND `+db.BookmarkVisible, cid, user(r)).Scan(&count); e != nil { + return e + } + if count != len(order) { + return bad("Order must contain every item exactly once") + } + // Review CR-09: the reorder advances every affected item's revision — + // ordering IS item state, so a client holding a pre-reorder version + // gets a 409 on its next PATCH instead of silently re-writing + // positions from a stale snapshot (revision-based cache invalidation + // sees the change too). + res, e := tx.ExecContext(r.Context(), `UPDATE "CollectionItem" AS ci SET position=t.ord-1,version=version+1,"updatedAt"=now() FROM unnest($1::text[]) WITH ORDINALITY AS t(iid,ord) WHERE ci.id=t.iid AND ci."collectionId"=$2`, order, cid) + if e != nil { + return e + } + if n, _ := res.RowsAffected(); int(n) != len(order) { + return bad("Invalid order") + } + // Hidden items (revoked bookmarks) sort after everything the client + // ordered, deterministically, so they do not collide with supplied + // positions. BookmarkVisible binds the user as $2; exclusion array is $3. + if _, e := tx.ExecContext(r.Context(), `UPDATE "CollectionItem" AS ci SET position=sub.pos,version=version+1,"updatedAt"=now() FROM ( + SELECT id, `+strconv.Itoa(len(order))+` + ROW_NUMBER() OVER (ORDER BY id) AS pos + FROM "CollectionItem" + WHERE "collectionId"=$1 AND id <> ALL($3::text[]) + AND NOT EXISTS(SELECT 1 FROM "Bookmark" b WHERE b.id="CollectionItem"."bookmarkId" AND `+db.BookmarkVisible+`) + ) sub WHERE ci.id=sub.id`, cid, user(r), order); e != nil { + return e + } + return nil + }) + respond(w, 200, OK{true}, e) +} diff --git a/internal/handlers/common.go b/internal/handlers/common.go new file mode 100644 index 0000000..cecea8c --- /dev/null +++ b/internal/handlers/common.go @@ -0,0 +1,264 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "github.com/andrew/go-computer/internal/auth" + "github.com/andrew/go-computer/internal/db" + "github.com/jackc/pgx/v5/pgconn" + "io" + "log" + "net/http" + "net/url" + "regexp" + "strings" + "time" + "unicode/utf8" +) + +type object = db.Object +type clientError struct{ message string } + +func (e clientError) Error() string { return e.message } +func bad(s string) error { return clientError{s} } + +// conflictError maps to 409: the request collides with newer server state +// (optimistic concurrency, review DATA-01), not with a unique constraint. +type conflictError struct{ message string } + +func (e conflictError) Error() string { return e.message } +func conflict(s string) error { return conflictError{s} } +func database(r *http.Request) *db.DB { return db.FromContext(r.Context()) } +func user(r *http.Request) string { s, _ := auth.SessionFromContext(r.Context()); return s.ID } +func read(w http.ResponseWriter, r *http.Request) (object, bool) { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + var b object + dec := json.NewDecoder(r.Body) + if dec.Decode(&b) != nil || b == nil { + writeJSON(w, 400, Error{"Invalid JSON object"}) + return nil, false + } + var extra any + if dec.Decode(&extra) != io.EOF { + writeJSON(w, 400, Error{"Unexpected data after JSON"}) + return nil, false + } + return b, true +} +func respond(w http.ResponseWriter, status int, v any, e error) { + if e != nil { + code, msg := 500, "Server error" + var ce clientError + var cfe conflictError + var pg *pgconn.PgError + switch { + case errors.Is(e, db.ErrNotFound): + code, msg = 404, "Not found" + case errors.As(e, &ce): + code, msg = 400, ce.message + case errors.As(e, &cfe): + code, msg = 409, cfe.message + case errors.As(e, &pg) && pg.Code == "23505": + code, msg = 409, "Already exists" + case errors.As(e, &pg) && (pg.Code == "23503" || pg.Code == "22P02"): + code, msg = 400, "Invalid related record or value" + default: + log.Printf("request: %v", e) + } + writeJSON(w, code, Error{msg}) + return + } + writeJSON(w, status, v) +} +func str(b object, k string) string { v, _ := b[k].(string); return v } +func ids(b object, k string) ([]string, error) { + v, ok := b[k] + if !ok { + return nil, nil + } + list, ok := v.([]any) + if !ok { + return nil, bad(k + " must be an array") + } + out := []string{} + seen := map[string]bool{} + for _, a := range list { + s, ok := a.(string) + if !ok || s == "" { + return nil, bad("Invalid " + k) + } + if !seen[s] { + out = append(out, s) + seen[s] = true + } else { + return nil, bad("Duplicate " + k) + } + } + return out, nil +} +func fields(b object, allowed string) object { + out := object{} + for _, k := range strings.Fields(allowed) { + if v, ok := b[k]; ok { + out[k] = v + } + } + return out +} +func owned(r *http.Request, q db.Queryer, table, id, col string) (object, error) { + return db.One(r.Context(), q, `SELECT to_jsonb(t) FROM `+db.Quote(table)+` t WHERE id=$1 AND `+db.Quote(col)+`=$2`, id, user(r)) +} +func remove(r *http.Request, table, id, col string) error { + res, e := database(r).ExecContext(r.Context(), `DELETE FROM `+db.Quote(table)+` WHERE id=$1 AND `+db.Quote(col)+`=$2`, id, user(r)) + if e != nil { + return e + } + n, _ := res.RowsAffected() + if n == 0 { + return db.ErrNotFound + } + return nil +} +func dateValue(v any) (any, error) { + if v == nil { + return nil, nil + } + s, ok := v.(string) + if !ok { + return nil, bad("Invalid date") + } + if s == "" { + return nil, nil + } + for _, layout := range []string{time.RFC3339Nano, "2006-01-02T15:04", "2006-01-02T15:04:05", "2006-01-02"} { + if t, e := time.Parse(layout, s); e == nil { + return t.UTC(), nil + } + } + return nil, bad("Invalid date") +} + +var colorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + +func validColor(b object) error { + if v, ok := b["color"]; ok { + if s, ok := v.(string); !ok || !colorPattern.MatchString(s) { + return bad("Color must be #RRGGBB") + } + } + return nil +} +func validURL(s string) bool { + u, e := url.Parse(s) + return e == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.User == nil +} +func stringFields(b object, nullable, required string) error { + for _, k := range strings.Fields(nullable) { + if v, ok := b[k]; ok && v != nil { + if _, ok := v.(string); !ok { + return bad(k + " must be text") + } + } + } + for _, k := range strings.Fields(required) { + if v, ok := b[k]; ok { + if _, ok := v.(string); !ok { + return bad(k + " must be text") + } + } + } + return nil +} + +// Per-field size limits (review "cap input sizes"). The 1 MiB request-body +// cap alone still lets a title, tag name or URL grow absurdly large, which +// bloats every list response and search index. These are deliberately +// generous product limits, not storage constraints; over-limit input is +// rejected with a 400 instead of being silently truncated so a client never +// believes it saved something it did not. +const ( + maxBookmarkURLLen = 2048 + maxBookmarkTitleLen = 500 + maxBookmarkDescriptionLen = 2000 + maxBookmarkNotesLen = 100000 + maxBookmarkQueryLen = 500 + maxPageContentLen = 512 << 10 + maxPageIconLen = 64 + maxTagNameLen = 80 + maxSharedDateNoteLen = 2000 + maxContactNameLen = 200 + maxContactEmailLen = 320 + maxContactPhoneLen = 64 + maxContactUIDLen = 255 + maxMailboxHostLen = 255 + maxMailboxUsernameLen = 320 + maxMailboxPasswordLen = 4096 + maxMailboxFolderLen = 255 +) + +// limitText rejects a text field longer than max runes. Missing and null +// fields are fine; a non-string value is reported by stringFields first. +func limitText(d object, key string, max int) error { + v, ok := d[key] + if !ok || v == nil { + return nil + } + s, ok := v.(string) + if !ok { + return bad(key + " must be text") + } + if utf8.RuneCountInString(s) > max { + return bad(fmt.Sprintf("%s must be at most %d characters", key, max)) + } + return nil +} + +func boolFields(b object, keys string) error { + for _, k := range strings.Fields(keys) { + if v, ok := b[k]; ok { + if _, ok := v.(bool); !ok { + return bad(k + " must be boolean") + } + } + } + return nil +} +func lockUser(r *http.Request, tx *sql.Tx) error { + _, e := tx.ExecContext(r.Context(), `SELECT id FROM "User" WHERE id=$1 FOR UPDATE`, user(r)) + return e +} +func reorder(r *http.Request, table, col string, order []string) error { + return db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + if e := lockUser(r, tx); e != nil { + return e + } + var count int + if e := tx.QueryRowContext(r.Context(), `SELECT count(*) FROM `+db.Quote(table)+` WHERE `+db.Quote(col)+`=$1`, user(r)).Scan(&count); e != nil { + return e + } + if count != len(order) { + return bad("Order must contain every record exactly once") + } + for i, id := range order { + res, e := tx.ExecContext(r.Context(), `UPDATE `+db.Quote(table)+` SET position=$1 WHERE id=$2 AND `+db.Quote(col)+`=$3`, i, id, user(r)) + if e != nil { + return e + } + n, _ := res.RowsAffected() + if n == 0 { + return bad("Invalid order") + } + } + return nil + }) +} +func shareRows(r *http.Request, tx *sql.Tx, table, key, id string, list []string) error { + for _, u := range list { + if _, e := tx.ExecContext(r.Context(), `INSERT INTO `+db.Quote(table)+` (`+db.Quote(key)+`,"userId") VALUES($1,$2) ON CONFLICT DO NOTHING`, id, u); e != nil { + return e + } + } + return nil +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..4d0d4df --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,74 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +// Common helpers shared by all handlers. Porting guide section 4. +// +// Every data endpoint follows the same pattern: resolve the effective user, +// return 401 if none, check ownership with a filtered lookup, return 404 if +// the record is not theirs, then act. +// +// All routes return JSON unless marked .md. Errors are {error: string}. + +// OK is the success response shape for mutations. +type OK struct { + OK bool `json:"ok"` +} + +// Error is the error response shape. +type Error struct { + Error string `json:"error"` +} + +// NotFound returns a 404 {error} response. +func NotFound(message string) Error { + if message == "" { + message = "not found" + } + return Error{Error: message} +} + +// Unauthorized returns a 401 {error} response. +func Unauthorized(message string) Error { + if message == "" { + message = "unauthorized" + } + return Error{Error: message} +} + +// Forbidden returns a 403 {error} response. +func Forbidden(message string) Error { + if message == "" { + message = "forbidden" + } + return Error{Error: message} +} + +// BadRequest returns a 400 {error} response. +func BadRequest(message string) Error { + if message == "" { + message = "bad request" + } + return Error{Error: message} +} + +// Conflict returns a 409 {error} response. +func Conflict(message string) Error { + return Error{Error: message} +} + +// WriteJSON writes v as JSON with the given status. +func WriteJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// writeJSON is the lowercase alias used by the stub handlers. +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + WriteJSON(w, status, v) +} diff --git a/internal/handlers/kanban.go b/internal/handlers/kanban.go new file mode 100644 index 0000000..769cbd7 --- /dev/null +++ b/internal/handlers/kanban.go @@ -0,0 +1,269 @@ +package handlers + +import ( + "database/sql" + "github.com/andrew/go-computer/internal/db" + "github.com/andrew/go-computer/internal/kanban" + "net/http" + "strings" +) + +func HandleKanbanColumnsList(w http.ResponseWriter, r *http.Request) { + v, e := kanban.GetColumns(r.Context(), database(r), user(r)) + respond(w, 200, v, e) +} +func columnData(b object) (object, error) { + d := fields(b, "label color") + if e := stringFields(d, "", "label color"); e != nil { + return nil, e + } + if _, ok := d["label"]; ok { + s := strings.TrimSpace(str(d, "label")) + if s == "" { + return nil, bad("Label required") + } + r := []rune(s) + if len(r) > 40 { + r = r[:40] + } + d["label"] = string(r) + } + return d, validColor(d) +} +func HandleKanbanColumnsCreate(w http.ResponseWriter, r *http.Request) { + b, ok := read(w, r) + if !ok { + return + } + d, e := columnData(b) + if e != nil { + respond(w, 0, nil, e) + return + } + if str(d, "label") == "" { + respond(w, 0, nil, bad("Label required")) + return + } + if _, ok := d["color"]; !ok { + d["color"] = "#8b5cf6" + } + d["id"], d["userId"] = db.NewID(), user(r) + var v object + e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + if e := lockUser(r, tx); e != nil { + return e + } + var n int + if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "KanbanColumn" WHERE "userId"=$1`, user(r)).Scan(&n); e != nil { + return e + } + d["position"] = n + var e error + v, e = db.Insert(r.Context(), tx, "KanbanColumn", d) + return e + }) + respond(w, 201, v, e) +} +func HandleKanbanColumnsReorder(w http.ResponseWriter, r *http.Request) { + b, ok := read(w, r) + if !ok { + return + } + order, e := ids(b, "order") + if e == nil { + e = reorder(r, "KanbanColumn", "userId", order) + } + if e != nil { + respond(w, 0, nil, e) + return + } + HandleKanbanColumnsList(w, r) +} +func HandleKanbanColumnsUpdate(w http.ResponseWriter, r *http.Request) { + b, ok := read(w, r) + if !ok { + return + } + d, e := columnData(b) + if e != nil { + respond(w, 0, nil, e) + return + } + v, e := db.Update(r.Context(), database(r), "KanbanColumn", r.PathValue("id"), "userId", user(r), d) + respond(w, 200, v, e) +} + +// HandleKanbanCardsMove moves or reorders one card on the board. It carries +// the same optimistic-concurrency version as a bookmark PUT (review CR-04): +// a card edited or moved elsewhere is a 409, and the board reloads instead of +// silently clobbering it. The target column's positions are normalized in the +// same transaction, so dragging inside a column survives reloads. +func HandleKanbanCardsMove(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + b, ok := read(w, r) + if !ok { + return + } + columnID := strings.TrimSpace(str(b, "columnId")) + if columnID == "" { + respond(w, 0, nil, bad("columnId is required")) + return + } + rawPosition, hasPosition := b["position"] + if !hasPosition { + respond(w, 0, nil, bad("position is required")) + return + } + f, ok := rawPosition.(float64) + if !ok || f < 0 || f != float64(int64(f)) { + respond(w, 0, nil, bad("position must be a non-negative integer")) + return + } + position := int(f) + baseVersion, hasVersion := 0, false + if v, present := b["version"]; present { + f, isNum := v.(float64) + if !isNum || f < 0 || f != float64(int64(f)) { + respond(w, 0, nil, bad("version must be a non-negative integer")) + return + } + baseVersion, hasVersion = int(f), true + } + if !hasVersion { + respond(w, 0, nil, bad("version is required when moving a card; refetch it and retry")) + return + } + // Ensure the default board exists before the transaction reads columns. + if _, e := kanban.GetColumns(r.Context(), database(r), user(r)); e != nil { + respond(w, 0, nil, e) + return + } + e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + if e := lockUser(r, tx); e != nil { + return e + } + cur, e := owned(r, tx, "Bookmark", id, "ownerId") + if e != nil { + return e + } + if stored, _ := cur["version"].(float64); int(stored) != baseVersion { + return conflict("Bookmark was modified elsewhere; reload to see the latest version") + } + if _, e := owned(r, tx, "KanbanColumn", columnID, "userId"); e != nil { + return bad("Invalid columnId") + } + cols, e := db.Many(r.Context(), tx, `SELECT to_jsonb(c) FROM "KanbanColumn" c WHERE "userId"=$1 ORDER BY position,id`, user(r)) + if e != nil { + return e + } + // Resolve legacy cards (kanbanStatus only) the same way the board does, + // then normalize every card in the target column to explicit + // membership plus a contiguous position. That makes the new order + // durable without requiring a schema backfill. + rows, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',b.id,'kanbanColumnId',b."kanbanColumnId",'kanbanStatus',b."kanbanStatus") FROM "Bookmark" b WHERE b."ownerId"=$1 ORDER BY b."kanbanPosition",b."updatedAt" DESC,b.id DESC`, user(r)) + if e != nil { + return e + } + target := make([]string, 0, len(rows)) + for _, row := range rows { + if str(row, "id") == id { + continue + } + if kanban.ResolveColumnID(row, cols) == columnID { + target = append(target, str(row, "id")) + } + } + if position > len(target) { + position = len(target) + } + target = append(target, "") + copy(target[position+1:], target[position:]) + target[position] = id + if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" AS b SET "kanbanColumnId"=$1,"kanbanPosition"=t.ord-1 FROM unnest($2::text[]) WITH ORDINALITY AS t(bid,ord) WHERE b.id=t.bid AND b."ownerId"=$3`, columnID, target, user(r)); e != nil { + return e + } + // Only the moved card's revision advances; reindexing its neighbours is + // board placement, not a content edit. + if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" SET version=version+1,"updatedAt"=now() WHERE id=$1 AND "ownerId"=$2`, id, user(r)); e != nil { + return e + } + return nil + }) + if e != nil { + respond(w, 0, nil, e) + return + } + v, e := bookmark(r, id, false) + respond(w, 200, v, e) +} + +func HandleKanbanColumnsDelete(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var dest string + var moved int + e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + // The user row is the single serialization point for board mutations + // (review CR-05): bookmark create/update take the same lock before + // validating/assigning kanbanColumnId, so this handler's snapshot of + // affected bookmarks, the move and the column delete cannot interleave + // with a competing assignment. + if e := lockUser(r, tx); e != nil { + return e + } + cols, e := db.Many(r.Context(), tx, `SELECT to_jsonb(c) FROM "KanbanColumn" c WHERE "userId"=$1 ORDER BY position,id`, user(r)) + if e != nil { + return e + } + found := false + remaining := []object{} + for _, c := range cols { + if str(c, "id") == id { + found = true + } else { + remaining = append(remaining, c) + } + } + if !found { + return db.ErrNotFound + } + if len(remaining) == 0 { + return bad("A board needs at least one column") + } + dest = str(remaining[0], "id") + // Only the three columns ResolveColumnID needs, and one set-based + // UPDATE for the whole move instead of a round trip per bookmark. + bms, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',b.id,'kanbanColumnId',b."kanbanColumnId",'kanbanStatus',b."kanbanStatus") FROM "Bookmark" b WHERE "ownerId"=$1`, user(r)) + if e != nil { + return e + } + moveIDs := make([]string, 0, len(bms)) + for _, b := range bms { + if kanban.ResolveColumnID(b, cols) == id { + moveIDs = append(moveIDs, str(b, "id")) + } + } + if len(moveIDs) > 0 { + // The move is a mutation covered by the bookmark concurrency + // contract (review CR-04): bumping version turns any client + // snapshot that still shows the old column into a 409 on its + // next save instead of letting it silently move the bookmark + // back. + if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" SET "kanbanColumnId"=$1,version=version+1,"updatedAt"=now() WHERE id=ANY($2::text[])`, dest, moveIDs); e != nil { + return e + } + moved = len(moveIDs) + } + if _, e := tx.ExecContext(r.Context(), `DELETE FROM "KanbanColumn" WHERE id=$1 AND "userId"=$2`, id, user(r)); e != nil { + return e + } + order := make([]string, len(remaining)) + for i, c := range remaining { + order[i] = str(c, "id") + } + if _, e := tx.ExecContext(r.Context(), `UPDATE "KanbanColumn" AS k SET position=t.ord-1 FROM unnest($1::text[]) WITH ORDINALITY AS t(cid,ord) WHERE k.id=t.cid`, order); e != nil { + return e + } + return nil + }) + respond(w, 200, object{"ok": true, "movedTo": dest, "moved": moved}, e) +} diff --git a/internal/handlers/misc.go b/internal/handlers/misc.go new file mode 100644 index 0000000..53f4a06 --- /dev/null +++ b/internal/handlers/misc.go @@ -0,0 +1,560 @@ +package handlers + +import ( + "context" + "database/sql" + "encoding/xml" + "errors" + "github.com/andrew/go-computer/internal/config" + crypt "github.com/andrew/go-computer/internal/crypto" + "github.com/andrew/go-computer/internal/db" + "github.com/andrew/go-computer/internal/netguard" + "github.com/andrew/go-computer/internal/signals" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// carddavClient dials through netguard so a user-supplied Nextcloud URL can +// never point the server at internal infrastructure (same guard as URL +// previews). The transport is shared to avoid leaking idle sockets per sync. +var carddavClient = &http.Client{ + Timeout: 20 * time.Second, + Transport: &http.Transport{DialContext: netguard.DialContext}, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, +} + +func HandleAlertsCheck(w http.ResponseWriter, r *http.Request) { + v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(b) FROM "Bookmark" b WHERE "ownerId"=$1 AND "alertAt"<=now()+interval '15 minutes' AND NOT "alertSent" ORDER BY "alertAt"`, user(r)) + respond(w, 200, v, e) +} +func HandleAlertsDismiss(w http.ResponseWriter, r *http.Request) { + _, e := db.Update(r.Context(), database(r), "Bookmark", r.PathValue("bookmarkId"), "ownerId", user(r), object{"alertSent": true, "updatedAt": time.Now().UTC()}) + respond(w, 200, OK{true}, e) +} +func HandleUsersSearch(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + if len([]rune(q)) < 2 { + respond(w, 200, []object{}, nil) + return + } + // The share picker needs names and IDs; substring matching on emails would + // let any user enumerate the whole user base, so emails must match exactly + // and are returned masked. The name pattern escapes LIKE wildcards + // (review SEC-3): without that, "%%" or "a%" enumerated every user + // 10 rows at a time. + v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object('id',id,'name',name,'email',CASE WHEN position('@' in email)=0 THEN NULL ELSE left(email,1)||'***'||substr(email, position('@' in email)) END,'image',image) FROM "User" WHERE id<>$1 AND (name ILIKE $2 ESCAPE '\' OR lower(email)=lower($3)) ORDER BY name LIMIT 10`, user(r), "%"+likeEscaper.Replace(q)+"%", q) + respond(w, 200, v, e) +} +func HandleSharedDatesList(w http.ResponseWriter, r *http.Request) { + v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(s)||jsonb_build_object('creator',jsonb_build_object('id',c.id,'name',c.name,'email',c.email),'recipient',jsonb_build_object('id',u.id,'name',u.name,'email',u.email)) FROM "SharedDate" s JOIN "User" c ON c.id=s."creatorId" JOIN "User" u ON u.id=s."recipientId" WHERE s."creatorId"=$1 OR s."recipientId"=$1 ORDER BY date`, user(r)) + respond(w, 200, v, e) +} +func HandleSharedDatesCreate(w http.ResponseWriter, r *http.Request) { + b, ok := read(w, r) + if !ok { + return + } + dt, e := dateValue(b["date"]) + if e != nil || dt == nil { + respond(w, 0, nil, bad("Date required")) + return + } + list, e := ids(b, "recipientIds") + if e != nil || len(list) == 0 { + respond(w, 0, nil, bad("Recipients required")) + return + } + if e = stringFields(b, "note", ""); e != nil { + respond(w, 0, nil, e) + return + } + if e = limitText(b, "note", maxSharedDateNoteLen); e != nil { + respond(w, 0, nil, e) + return + } + out := []object{} + e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + for _, id := range list { + v, e := db.Insert(r.Context(), tx, "SharedDate", object{"id": db.NewID(), "date": dt, "creatorId": user(r), "recipientId": id, "note": b["note"]}) + if e != nil { + return e + } + out = append(out, v) + } + return nil + }) + respond(w, 201, out, e) +} +func HandleContactsList(w http.ResponseWriter, r *http.Request) { + v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(c)||jsonb_build_object('_count',jsonb_build_object('bookmarks',(SELECT count(*) FROM "Bookmark" b WHERE b."contactId"=c.id AND b."ownerId"=$1))) FROM "Contact" c WHERE "userId"=$1 ORDER BY "displayName"`, user(r)) + respond(w, 200, v, e) +} + +type davResponse struct { + Responses []struct { + Href string `xml:"href"` + Propstats []struct { + Status string `xml:"status"` + Prop struct { + Data string `xml:"address-data"` + } `xml:"prop"` + } `xml:"propstat"` + } `xml:"response"` +} + +// resolveCardDAVTarget resolves the user-supplied cardDavPath against the +// configured base URL and revalidates the COMPLETE resolved destination +// (review CR-01): a same-host absolute path like "http://host/..." must not +// downgrade an https base, because Basic-auth credentials are attached to the +// resulting request. The resolved URL must keep the base's scheme and host +// and carry no embedded userinfo. The scheme check on the base URL itself +// (https unless the netguard fixture flag is set) happens at the caller, so +// pinning the resolved scheme to the base scheme enforces https end to end. +func resolveCardDAVTarget(base *url.URL, path string) (*url.URL, error) { + target, e := base.Parse(path) + if e != nil { + return nil, bad("Invalid CardDAV path") + } + if target.Host != base.Host || target.Scheme != base.Scheme || target.User != nil { + return nil, bad("CardDAV path must stay on the Nextcloud host and scheme") + } + return target, nil +} + +func HandleContactsSync(w http.ResponseWriter, r *http.Request) { + b, ok := read(w, r) + if !ok { + return + } + if !validURL(str(b, "nextcloudUrl")) || str(b, "username") == "" || str(b, "password") == "" { + respond(w, 0, nil, bad("Nextcloud URL and credentials required")) + return + } + base, _ := url.Parse(str(b, "nextcloudUrl")) + // CardDAV transport (review recheck): Basic-auth credentials must never + // travel plaintext. https is required unless the netguard escape hatch is + // set (loopback test fakes / deliberate LAN deployments — never + // production Compose). Redirects are not followed at all + // (ErrUseLastResponse), so a verified origin cannot be downgraded, and + // the path resolution below is pinned to the same host AND the same + // scheme (review CR-01: the final resolved destination is revalidated, + // not just the configured base URL). + if base.Scheme != "https" && !netguard.AllowPrivate() { + respond(w, 0, nil, bad("Nextcloud URL must use https")) + return + } + path := str(b, "cardDavPath") + if path == "" { + path = "/remote.php/dav/addressbooks/users/" + url.PathEscape(str(b, "username")) + "/contacts/" + } + target, e := resolveCardDAVTarget(base, path) + if e != nil { + respond(w, 0, nil, e) + return + } + payload := `` + req, e := http.NewRequestWithContext(r.Context(), "REPORT", target.String(), strings.NewReader(payload)) + if e != nil { + respond(w, 0, nil, bad("Invalid CardDAV URL")) + return + } + req.SetBasicAuth(str(b, "username"), str(b, "password")) + req.Header.Set("Depth", "1") + req.Header.Set("Content-Type", "application/xml") + res, e := carddavClient.Do(req) + if e != nil { + respond(w, 0, nil, bad("Could not connect to Nextcloud")) + return + } + defer res.Body.Close() + if res.StatusCode != 207 && res.StatusCode != 200 { + respond(w, 0, nil, bad("Nextcloud rejected the CardDAV request")) + return + } + var dav davResponse + if e = xml.NewDecoder(io.LimitReader(res.Body, 8<<20)).Decode(&dav); e != nil { + respond(w, 0, nil, bad("Invalid CardDAV response")) + return + } + synced := 0 + removed := 0 + seenUIDs := []string{} + // complete tracks whether the server answered with full data for every + // card. Deletion reconciliation (review CR-07) runs ONLY on a complete, + // non-empty snapshot: a failed, truncated or partially-withheld response + // must never be read as "these contacts were deleted remotely". + complete := true + e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + for _, entry := range dav.Responses { + gotData := false + for _, prop := range entry.Propstats { + if !strings.Contains(prop.Status, "200") { + continue + } + v := parseVCard(prop.Prop.Data) + uid, fn := vCardPick(v["UID"]), vCardPick(v["FN"]) + if uid == "" || fn == "" { + // A card we cannot identify stays untouched locally — and + // blocks reconciliation, since we cannot prove which + // stored contact (if any) it corresponds to. + complete = false + continue + } + // Contact data comes from a remote server, so apply the same + // size discipline as user input. A UID is an identity key and + // must never be truncated; skip an absurd one and keep the + // snapshot incomplete so reconciliation stays conservative. + if len([]rune(uid)) > maxContactUIDLen { + complete = false + continue + } + gotData = true + seenUIDs = append(seenUIDs, uid) + email := trimLimit(vCardPick(v["EMAIL"]), maxContactEmailLen) + phone := trimLimit(vCardPick(v["TEL"]), maxContactPhoneLen) + fn = trimLimit(fn, maxContactNameLen) + _, e := tx.ExecContext(r.Context(), `INSERT INTO "Contact"(id,uid,"displayName",email,phone,"cardDavUrl",source,"userId") VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(uid,"userId") DO UPDATE SET "displayName"=EXCLUDED."displayName",email=EXCLUDED.email,phone=EXCLUDED.phone,"cardDavUrl"=EXCLUDED."cardDavUrl",source=EXCLUDED.source,"updatedAt"=now()`, db.NewID(), uid, fn, nullable(email), nullable(phone), entry.Href, target.String(), user(r)) + if e != nil { + return e + } + synced++ + } + if !gotData { + complete = false + } + } + // Remote-deletion reconciliation (review CR-07). Policy: the sync + // MIRRORS the configured address book, scoped by provenance — only + // contacts previously imported from THIS source URL are candidates, + // manually created contacts (source NULL) and other address books' + // imports are never touched, and bookmarks referencing a removed + // contact keep working (contactId is ON DELETE SET NULL). Guards: + // a non-empty, fully-answered snapshot only; an empty or partial + // response preserves everything (a genuinely emptied address book + // stops mirroring deletions until it has at least one card — the + // deliberate safe-side tradeoff). + if complete && synced > 0 { + res, e := tx.ExecContext(r.Context(), `DELETE FROM "Contact" WHERE "userId"=$1 AND source=$2 AND NOT (uid = ANY($3::text[]))`, user(r), target.String(), seenUIDs) + if e != nil { + return e + } + n, _ := res.RowsAffected() + removed = int(n) + } + return nil + }) + if e != nil { + respond(w, 0, nil, e) + return + } + respond(w, 200, object{"synced": synced, "removed": removed}, nil) +} +func nullable(s string) any { + if s == "" { + return nil + } + return s +} + +// vCardValue is one parsed property occurrence: its parameters (TYPE, PREF, +// ENCODING, ...) and its unescaped value. Repeated properties keep EVERY +// occurrence in file order (review CR-08) instead of the last one clobbering +// the rest. +type vCardValue struct { + params map[string][]string + value string +} + +// splitVCardName splits the property name on unquoted semicolons so quoted +// parameter values ("...;X=a;b") survive. +func splitVCardName(name string) []string { + out := []string{} + var cur strings.Builder + inQuotes := false + for _, r := range name { + switch { + case r == '"': + inQuotes = !inQuotes + cur.WriteRune(r) + case r == ';' && !inQuotes: + out = append(out, cur.String()) + cur.Reset() + default: + cur.WriteRune(r) + } + } + return append(out, cur.String()) +} + +// parseVCard parses the documented subset of vCard (RFC 2426/6350) the +// contact sync consumes: CRLF and bare-LF unfolding, group prefixes +// (item1.EMAIL), parameter lists (including bare vCard 2.1 tokens as +// implicit TYPE values), and the standard escapes. Unsupported data is +// dropped, not silently misparsed (review CR-08): properties declaring a +// base64 ENCODING are skipped — the stored fields (UID/FN/EMAIL/TEL) are +// never legitimately encoded, and keeping the raw text would pretend to be +// a correct parse. A colon inside a quoted parameter value is outside the +// supported subset. +func parseVCard(s string) map[string][]vCardValue { + s = strings.ReplaceAll(s, "\r\n ", "") + s = strings.ReplaceAll(s, "\r\n\t", "") + s = strings.ReplaceAll(s, "\n ", "") + s = strings.ReplaceAll(s, "\n\t", "") + out := map[string][]vCardValue{} + for _, line := range strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") { + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + fields := splitVCardName(parts[0]) + key := strings.ToUpper(fields[0]) + if i := strings.LastIndex(key, "."); i >= 0 { + key = key[i+1:] // strip group prefix (item1.EMAIL -> EMAIL) + } + if key == "" { + continue + } + params := map[string][]string{} + for _, p := range fields[1:] { + kv := strings.SplitN(p, "=", 2) + if len(kv) != 2 { + // Bare token (vCard 2.1 style "EMAIL;INTERNET;WORK"): an + // implicit TYPE value. + if tok := strings.TrimSpace(kv[0]); tok != "" { + params["TYPE"] = append(params["TYPE"], strings.ToUpper(tok)) + } + continue + } + pk := strings.ToUpper(strings.TrimSpace(kv[0])) + for _, pv := range strings.Split(kv[1], ",") { + params[pk] = append(params[pk], strings.ToUpper(strings.Trim(strings.TrimSpace(pv), `"`))) + } + } + if enc := params["ENCODING"]; len(enc) > 0 && (enc[0] == "B" || enc[0] == "BASE64") { + continue // unsupported encoding: drop instead of misparsing + } + value := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`).Replace(parts[1]) + out[key] = append(out[key], vCardValue{params: params, value: value}) + } + return out +} + +// vCardPick deterministically selects the display value among repeated +// occurrences (review CR-08): the lowest explicit PREF=n wins (vCard 4.0), +// then the first TYPE=PREF occurrence (vCard 3.0), then file order. +func vCardPick(values []vCardValue) string { + if len(values) == 0 { + return "" + } + best := values[0].value + bestPref := 1 << 30 + for _, v := range values { + for _, p := range v.params["PREF"] { + if n, e := strconv.Atoi(p); e == nil && n < bestPref { + bestPref, best = n, v.value + } + } + } + if bestPref != 1<<30 { + return best + } + for _, v := range values { + for _, p := range v.params["TYPE"] { + if p == "PREF" { + return v.value + } + } + } + return best +} +func mailboxData(b object) (signals.Mailbox, error) { + if e := stringFields(b, "", "host username password folder"); e != nil { + return signals.Mailbox{}, e + } + if e := boolFields(b, "tls"); e != nil { + return signals.Mailbox{}, e + } + for _, l := range []struct { + key string + max int + }{ + {"host", maxMailboxHostLen}, + {"username", maxMailboxUsernameLen}, + {"password", maxMailboxPasswordLen}, + {"folder", maxMailboxFolderLen}, + } { + if e := limitText(b, l.key, l.max); e != nil { + return signals.Mailbox{}, e + } + } + m := signals.Mailbox{Host: str(b, "host"), Username: str(b, "username"), Password: str(b, "password"), Folder: str(b, "folder"), Port: 993, TLS: true} + if n, ok := b["port"]; ok { + v, ok := n.(float64) + if !ok || v < 1 || v > 65535 || float64(int(v)) != v { + return m, bad("Invalid port") + } + m.Port = int(v) + } + if v, ok := b["tls"].(bool); ok { + m.TLS = v + } + // Normal IMAP ports only (review "outbound fetches aren't limited to + // normal ports"). The fixture flags keep loopback fakes and deliberate + // LAN deployments working on an ephemeral port. + if !signals.AllowInsecure() && !netguard.AllowPrivate() && m.Port != 993 && m.Port != 143 { + return m, bad("Mailbox port must be 143 or 993") + } + // Review NET-02: mailbox credentials must never travel over a plaintext + // socket. Insecure settings are rejected at the API boundary; the + // development-only INTEGRATIONS_ALLOW_INSECURE_IMAP escape hatch mirrors + // netguard's fixture flag and is never set in production Compose. + if !m.TLS && !signals.AllowInsecure() { + return m, bad("TLS is required for mailbox connections") + } + if m.Folder == "" { + m.Folder = "INBOX" + } + if m.Host == "" || m.Username == "" { + return m, bad("Host and username required") + } + return m, nil +} +func HandleImapGet(w http.ResponseWriter, r *http.Request) { + v, e := db.One(r.Context(), database(r), `SELECT to_jsonb(m)-'password'||jsonb_build_object('password','••••••••') FROM "ImapConfig" m WHERE "userId"=$1`, user(r)) + if e == db.ErrNotFound { + respond(w, 200, nil, nil) + return + } + respond(w, 200, v, e) +} +func HandleImapSave(w http.ResponseWriter, r *http.Request) { + b, ok := read(w, r) + if !ok { + return + } + m, e := mailboxData(b) + if e != nil { + respond(w, 0, nil, e) + return + } + // Review CR-21: a blank/masked password means "keep the stored secret". + // The old path read the existing ciphertext and re-submitted it in the + // upsert, so a password saved concurrently between that read and write + // was silently rolled back. The keep-stored path therefore never assigns + // the password column at all — whatever is stored at commit time wins. + if m.Password == "" || m.Password == "••••••••" { + res, e := database(r).ExecContext(r.Context(), `UPDATE "ImapConfig" SET host=$1,port=$2,tls=$3,username=$4,folder=$5 WHERE "userId"=$6`, m.Host, m.Port, m.TLS, m.Username, m.Folder, user(r)) + if e != nil { + respond(w, 0, nil, e) + return + } + if n, _ := res.RowsAffected(); n == 0 { + // No configuration exists to keep a password from (or it was + // removed concurrently): initial creation requires a real one. + respond(w, 0, nil, bad("Password required")) + return + } + respond(w, 200, OK{true}, nil) + return + } + pw, e := crypt.Encrypt(m.Password, config.Load().EncryptionSecret()) + if e != nil { + respond(w, 0, nil, e) + return + } + _, e = database(r).ExecContext(r.Context(), `INSERT INTO "ImapConfig"(id,host,port,tls,username,password,folder,"userId") VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT("userId") DO UPDATE SET host=EXCLUDED.host,port=EXCLUDED.port,tls=EXCLUDED.tls,username=EXCLUDED.username,password=EXCLUDED.password,folder=EXCLUDED.folder`, db.NewID(), m.Host, m.Port, m.TLS, m.Username, pw, m.Folder, user(r)) + respond(w, 200, OK{true}, e) +} +func HandleImapTest(w http.ResponseWriter, r *http.Request) { + b, ok := read(w, r) + if !ok { + return + } + m, e := mailboxData(b) + if e != nil { + respond(w, 0, nil, e) + return + } + if m.Password == "" || m.Password == "••••••••" { + saved, e := signals.LoadMailbox(r.Context(), database(r), user(r)) + if errors.Is(e, db.ErrNotFound) { + respond(w, 0, nil, bad("Password required")) + return + } + if e != nil { + respond(w, 0, nil, e) + return + } + m.Password = saved.Password + } + c, e := signals.Connect(r.Context(), m) + if e != nil { + respond(w, 0, nil, bad("Mailbox connection or login failed")) + return + } + c.Logout() + respond(w, 200, OK{true}, nil) +} +func HandleImapCheck(w http.ResponseWriter, r *http.Request) { + // Bounded, single-connection pass: the watched-bookmark query is capped and + // all searches reuse one IMAP login under an overall deadline (matches the + // scheduler's 45s budget) instead of one fresh connection per bookmark. + bms, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(b) FROM "Bookmark" b WHERE "ownerId"=$1 AND "imapWatchEnabled" ORDER BY "createdAt" LIMIT 50`, user(r)) + if e != nil { + respond(w, 0, nil, e) + return + } + matches := []object{} + watched := make([]object, 0, len(bms)) + for _, b := range bms { + if str(b, "imapQuery") != "" { + watched = append(watched, b) + } + } + if len(watched) > 0 { + m, e := signals.LoadMailbox(r.Context(), database(r), user(r)) + if e != nil { + respond(w, 0, nil, bad("Configure IMAP first")) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second) + defer cancel() + c, e := signals.Connect(ctx, m) + if e != nil { + respond(w, 0, nil, bad("Mailbox connection or login failed")) + return + } + stop := context.AfterFunc(ctx, func() { c.Terminate() }) + defer stop() + defer c.Logout() + searchFailed := false + for _, b := range watched { + events, e := signals.SearchConn(c, m, "", str(b, "imapQuery")) + // A search can fail after returning partial events (review + // follow-up). Surface those matches and the failure together; + // only a failure with nothing found stays a plain 400. + for _, event := range events { + matches = append(matches, object{"bookmarkId": b["id"], "title": event.Title, "from": event.Summary, "externalId": event.ExternalID, "occurredAt": event.OccurredAt}) + } + if e != nil { + searchFailed = true + break + } + } + if searchFailed { + if len(matches) == 0 { + respond(w, 0, nil, bad("Mailbox search failed")) + return + } + respond(w, 200, object{"matches": matches, "error": "Mailbox search failed (partial results)"}, nil) + return + } + } + respond(w, 200, object{"matches": matches}, nil) +} diff --git a/internal/handlers/misc_test.go b/internal/handlers/misc_test.go new file mode 100644 index 0000000..751dc14 --- /dev/null +++ b/internal/handlers/misc_test.go @@ -0,0 +1,120 @@ +package handlers + +import ( + "net/url" + "testing" +) + +func TestMailboxRejectsUnusualPort(t *testing.T) { + t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "") + t.Setenv("INTEGRATIONS_ALLOW_INSECURE_IMAP", "") + if _, e := mailboxData(object{"host": "mail.example.com", "username": "u", "password": "p", "port": float64(22), "tls": true}); e == nil { + t.Fatal("mailbox accepted a non-IMAP port") + } + if _, e := mailboxData(object{"host": "mail.example.com", "username": "u", "password": "p", "port": float64(993), "tls": true}); e != nil { + t.Fatalf("normal IMAP port rejected: %v", e) + } +} + +// TestResolveCardDAVTarget pins review CR-01: the COMPLETE resolved +// destination is revalidated, so a same-host absolute http:// path cannot +// downgrade an https base and carry Basic credentials over plaintext. +func TestResolveCardDAVTarget(t *testing.T) { + base, e := url.Parse("https://cloud.example.com/remote.php/dav") + if e != nil { + t.Fatal(e) + } + accepted := []string{ + "/remote.php/dav/addressbooks/users/alice/contacts/", + "addressbooks/users/alice/contacts/", + "https://cloud.example.com/remote.php/dav/addressbooks/users/alice/contacts/", + } + for _, path := range accepted { + target, e := resolveCardDAVTarget(base, path) + if e != nil { + t.Fatalf("resolveCardDAVTarget(%q) rejected: %v", path, e) + } + if target.Scheme != "https" || target.Host != "cloud.example.com" { + t.Fatalf("resolveCardDAVTarget(%q) resolved to %s", path, target) + } + } + rejected := []string{ + // Same host, downgraded scheme — the CR-01 credential-leak vector. + "http://cloud.example.com/remote.php/dav/addressbooks/users/alice/contacts/", + // Cross-host absolute URLs. + "https://evil.example.com/remote.php/dav/", + "http://evil.example.com/remote.php/dav/", + // Scheme-relative and userinfo tricks. + "//evil.example.com/dav/", + "https://alice:pw@cloud.example.com/dav/", + } + for _, path := range rejected { + if target, e := resolveCardDAVTarget(base, path); e == nil { + t.Fatalf("resolveCardDAVTarget(%q) accepted, resolved to %s", path, target) + } + } +} + +// TestParseVCardSubset pins the documented parser subset (review CR-08): +// repeated properties are preserved, PREF selection is deterministic, +// groups/params/escapes/folding are handled, and base64-encoded properties +// are dropped instead of silently misparsed. +func TestParseVCardSubset(t *testing.T) { + card := "BEGIN:VCARD\r\n" + + "VERSION:3.0\r\n" + + "UID:uid-1\r\n" + + "item1.FN:Preferred Name\r\n" + + "item1.X-ABLabel:Main\r\n" + + "EMAIL;TYPE=WORK:work@computer.test\r\n" + + "EMAIL;TYPE=PREF:pref@computer.test\r\n" + + "EMAIL:plain@computer.test\r\n" + + "TEL;TYPE=HOME,VOICE:+1 555 0100\r\n" + + "NOTE:folded line\r\n" + + " continuation\r\n" + + "X-ESCAPED:a\\,b\\;c\\\\d\\ne\r\n" + + "PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRg==\r\n" + + "END:VCARD" + v := parseVCard(card) + + if got := vCardPick(v["UID"]); got != "uid-1" { + t.Fatalf("UID = %q", got) + } + // Group prefix stripped (item1.FN -> FN). + if got := vCardPick(v["FN"]); got != "Preferred Name" { + t.Fatalf("FN = %q", got) + } + // All three EMAIL occurrences survive; TYPE=PREF wins deterministically. + if len(v["EMAIL"]) != 3 { + t.Fatalf("repeated EMAILs not preserved: %d", len(v["EMAIL"])) + } + if got := vCardPick(v["EMAIL"]); got != "pref@computer.test" { + t.Fatalf("PREF selection = %q", got) + } + // vCard 4.0 numeric PREF ranks below any TYPE=PREF-free occurrence. + num := parseVCard("EMAIL:a@x.test\r\nEMAIL;PREF=2:b@x.test\r\nEMAIL;PREF=1:c@x.test\r\n") + if got := vCardPick(num["EMAIL"]); got != "c@x.test" { + t.Fatalf("numeric PREF selection = %q", got) + } + // Multi-value parameters (TYPE=HOME,VOICE) are split. + tel := v["TEL"] + if len(tel) != 1 || tel[0].params["TYPE"][0] != "HOME" || tel[0].params["TYPE"][1] != "VOICE" { + t.Fatalf("TEL params = %+v", tel) + } + // CRLF-space folding unfolds into one value: the first space is the fold + // marker, the second is content. + if got := vCardPick(v["NOTE"]); got != "folded line continuation" { + t.Fatalf("NOTE unfolding = %q", got) + } + // Escapes decode. + if got := vCardPick(v["X-ESCAPED"]); got != "a,b;c\\d\ne" { + t.Fatalf("escapes = %q", got) + } + // Base64-encoded properties are dropped, not stored as garbage. + if _, ok := v["PHOTO"]; ok { + t.Fatal("base64-encoded PHOTO was parsed instead of dropped") + } + // Missing properties pick to "". + if got := vCardPick(v["ORG"]); got != "" { + t.Fatalf("missing property = %q", got) + } +} diff --git a/internal/handlers/pages.go b/internal/handlers/pages.go new file mode 100644 index 0000000..e482a0c --- /dev/null +++ b/internal/handlers/pages.go @@ -0,0 +1,203 @@ +package handlers + +import ( + "context" + "database/sql" + "github.com/andrew/go-computer/internal/db" + "github.com/andrew/go-computer/internal/markdown" + "net/http" + "strings" + "time" +) + +// pageView is the canonical page projection over any queryer. HandlePagesUpdate +// reads it INSIDE the write transaction (review CR-18): a post-commit query +// could observe another writer's committed revision, and the acknowledgement +// would then describe state this write did not produce — a client adopting it +// could overwrite the other writer on its next save. +func pageView(ctx context.Context, q db.Queryer, id, uid string) (object, error) { + // Linked bookmarks use the viewer projection (review PRIV-02): a page can + // link bookmarks shared with its owner, and those arrive narrowed. + return db.One(ctx, q, `SELECT to_jsonb(p)||jsonb_build_object('bookmarks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('bookmark',`+db.BookmarkViewerView+`) ORDER BY pb.position) FROM "PageBookmark" pb JOIN "Bookmark" b ON b.id=pb."bookmarkId" WHERE pb."pageId"=p.id AND `+db.BookmarkVisible+`),'[]'::jsonb)) FROM "Page" p WHERE p.id=$1 AND p."ownerId"=$2`, id, uid) +} +func page(r *http.Request, id string) (object, error) { + return pageView(r.Context(), database(r), id, user(r)) +} +func HandlePagesList(w http.ResponseWriter, r *http.Request) { + // Review PERF-01: the list is a metadata projection — content bodies stay + // out of it (the sidebar/tab strip only render titles) and are fetched on + // demand via GET /api/pages/{id}. Search still matches content server-side. + // Escape LIKE wildcards (review PERF-2): "%" and "_" in a search are + // literal characters, not patterns. The pg_trgm indexes on title/content + // let Postgres use the same ILIKE predicate without scanning every page. + search := "%" + likeEscaper.Replace(r.URL.Query().Get("search")) + "%" + v, e := db.Many(r.Context(), database(r), `SELECT (to_jsonb(p)-'content')||jsonb_build_object('_count',jsonb_build_object('bookmarks',(SELECT count(*) FROM "PageBookmark" pb WHERE pb."pageId"=p.id))) FROM "Page" p WHERE "ownerId"=$1 AND (title ILIKE $2 ESCAPE '\' OR content ILIKE $2 ESCAPE '\') ORDER BY pinned DESC,position,"createdAt"`, user(r), search) + respond(w, 200, v, e) +} +func pageData(b object) (object, error) { + d := fields(b, "title content icon pinned") + if e := stringFields(d, "icon", "title content"); e != nil { + return nil, e + } + if e := boolFields(d, "pinned"); e != nil { + return nil, e + } + if e := limitText(d, "content", maxPageContentLen); e != nil { + return nil, e + } + if e := limitText(d, "icon", maxPageIconLen); e != nil { + return nil, e + } + if _, ok := d["title"]; ok { + s := strings.TrimSpace(str(d, "title")) + if s == "" { + s = "Untitled page" + } + rs := []rune(s) + if len(rs) > 120 { + rs = rs[:120] + } + d["title"] = string(rs) + } + return d, nil +} +func HandlePagesCreate(w http.ResponseWriter, r *http.Request) { + b, ok := read(w, r) + if !ok { + return + } + d, e := pageData(b) + if e != nil { + respond(w, 0, nil, e) + return + } + if _, ok := d["title"]; !ok { + d["title"] = "Untitled page" + } + d["id"], d["ownerId"] = db.NewID(), user(r) + var v object + e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + if e := lockUser(r, tx); e != nil { + return e + } + var n int + if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "Page" WHERE "ownerId"=$1`, user(r)).Scan(&n); e != nil { + return e + } + d["position"] = n + var e error + v, e = db.Insert(r.Context(), tx, "Page", d) + return e + }) + respond(w, 201, v, e) +} +func HandlePagesReorder(w http.ResponseWriter, r *http.Request) { + b, ok := read(w, r) + if !ok { + return + } + list, e := ids(b, "order") + if e == nil { + e = reorder(r, "Page", "ownerId", list) + } + respond(w, 200, OK{true}, e) +} +func HandlePagesGet(w http.ResponseWriter, r *http.Request) { + v, e := page(r, r.PathValue("id")) + respond(w, 200, v, e) +} +func HandlePagesUpdate(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + b, ok := read(w, r) + if !ok { + return + } + d, e := pageData(b) + if e != nil { + respond(w, 0, nil, e) + return + } + // Optimistic concurrency (review DATA-01): a client that sends the + // version it edited from gets a 409 instead of silently overwriting a + // newer save from another tab or device. The revision counter tracks + // content edits — metadata patches (title/pin/links) neither require nor + // bump it, so they cannot invalidate an editor's in-flight draft. + baseVersion, hasVersion := 0, false + if v, ok := b["version"]; ok { + f, isNum := v.(float64) + if !isNum || f < 0 || f != float64(int64(f)) { + respond(w, 0, nil, bad("version must be a non-negative integer")) + return + } + baseVersion, hasVersion = int(f), true + } + // Review DATA-05: concurrency must not be optional at the API boundary. + // A content write without a base version is rejected outright — otherwise + // any script or stale client could silently clobber newer content by + // omitting the field. Explicit overwrites re-fetch the latest version and + // send it (web `resolveOverwrite`); they never bypass this check. + if _, hasContent := d["content"]; hasContent && !hasVersion { + respond(w, 0, nil, bad("version is required when saving content; refetch the page and retry")) + return + } + d["updatedAt"] = time.Now().UTC() + // Review CR-18: the response snapshot is captured inside the same + // transaction as the write, so the acknowledged revision always + // describes exactly the state this request produced — never a later + // commit by a competing writer. + var v object + e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + // Same locking discipline as page creation/reorder: serializes + // max(position)+1 so concurrent link adds cannot duplicate positions. + if e := lockUser(r, tx); e != nil { + return e + } + current, e := owned(r, tx, "Page", id, "ownerId") + if e != nil { + return e + } + if hasVersion { + stored, _ := current["version"].(float64) + if int(stored) != baseVersion { + return conflict("Page was modified elsewhere; reload to see the latest version") + } + } + if _, e := db.Update(r.Context(), tx, "Page", id, "ownerId", user(r), d); e != nil { + return e + } + if _, ok := d["content"]; ok { + if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET version=version+1 WHERE id=$1`, id); e != nil { + return e + } + } + if bid := str(b, "addBookmarkId"); bid != "" { + if _, e := db.One(r.Context(), tx, `SELECT to_jsonb(b) FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible, bid, user(r)); e != nil { + return bad("Bookmark is not accessible") + } + _, e := tx.ExecContext(r.Context(), `INSERT INTO "PageBookmark"("pageId","bookmarkId",position) SELECT $1,$2,COALESCE(max(position),-1)+1 FROM "PageBookmark" WHERE "pageId"=$1 ON CONFLICT DO NOTHING`, id, bid) + if e != nil { + return e + } + } + if bid := str(b, "removeBookmarkId"); bid != "" { + _, e := tx.ExecContext(r.Context(), `DELETE FROM "PageBookmark" WHERE "pageId"=$1 AND "bookmarkId"=$2`, id, bid) + if e != nil { + return e + } + } + v, e = pageView(r.Context(), tx, id, user(r)) + return e + }) + respond(w, 200, v, e) +} +func HandlePagesDelete(w http.ResponseWriter, r *http.Request) { + respond(w, 200, OK{true}, remove(r, "Page", r.PathValue("id"), "ownerId")) +} +func HandlePagesMarkdown(w http.ResponseWriter, r *http.Request) { + v, e := page(r, r.PathValue("id")) + if e != nil { + respond(w, 0, nil, e) + return + } + markdown.Send(w, markdown.PageDocument(v), str(v, "title")) +} diff --git a/internal/handlers/public.go b/internal/handlers/public.go new file mode 100644 index 0000000..b5dc270 --- /dev/null +++ b/internal/handlers/public.go @@ -0,0 +1,15 @@ +package handlers + +import "net/http" + +// HandlePublicBookmark serves the unauthenticated share page. The projection +// in bookmark(..., public=true) is an allowlist; private metadata (contact, +// owner identity, imap settings, alerts, kanban placement, shares, page +// links) is never loaded — review PRIV-04 removed the owner-name attribution +// so the API matches the README's promise that owner identity stays private. +// Notes and dueDate ARE published by product decision — the share UI states +// exactly what a public link exposes. +func HandlePublicBookmark(w http.ResponseWriter, r *http.Request) { + v, e := bookmark(r, r.PathValue("id"), true) + respond(w, 200, v, e) +} diff --git a/internal/handlers/signals.go b/internal/handlers/signals.go new file mode 100644 index 0000000..58370c9 --- /dev/null +++ b/internal/handlers/signals.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "context" + "encoding/json" + "github.com/andrew/go-computer/internal/db" + "github.com/andrew/go-computer/internal/signals" + "net/http" + "strings" + "time" +) + +func HandleSignalsList(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if _, e := bookmark(r, id, false); e != nil { + respond(w, 0, nil, e) + return + } + sources, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(s) FROM "SignalSource" s WHERE "bookmarkId"=$1 AND "ownerId"=$2 ORDER BY "createdAt"`, id, user(r)) + if e != nil { + respond(w, 0, nil, e) + return + } + events, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(s) FROM "Signal" s WHERE "bookmarkId"=$1 AND "ownerId"=$2 ORDER BY "occurredAt" DESC LIMIT 50`, id, user(r)) + respond(w, 200, object{"sources": sources, "signals": events, "types": signals.PublicSourceTypes()}, e) +} +func HandleSignalsCreate(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if _, e := owned(r, database(r), "Bookmark", id, "ownerId"); e != nil { + respond(w, 0, nil, e) + return + } + b, ok := read(w, r) + if !ok { + return + } + if str(b, "action") == "check" { + // Review COR-4: a manual "Check now" must not race with the scheduler + // or another click. Take the SAME advisory lease the scheduler uses — + // identical key, held on a dedicated connection because session-level + // advisory locks are connection-bound (a pooled lock/unlock could land + // on different connections and leak the lock) — and answer 409 while + // it is held. The 45-second cap keeps the pass inside the server's + // WriteTimeout so it cannot be cut off mid-mutation. + ctx := r.Context() + leaseKey := "signal-poll:" + user(r) + conn, e := database(r).Conn(ctx) + if e != nil { + respond(w, 0, nil, e) + return + } + defer conn.Close() + var locked bool + if e := conn.QueryRowContext(ctx, `SELECT pg_try_advisory_lock(hashtextextended($1,0))`, leaseKey).Scan(&locked); e != nil { + respond(w, 0, nil, e) + return + } + if !locked { + respond(w, 0, nil, conflict("A check is already running")) + return + } + defer conn.ExecContext(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock(hashtextextended($1,0))`, leaseKey) + job, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + res, e := signals.RunSignalChecks(job, database(r), user(r), id) + if e != nil { + respond(w, 0, nil, e) + return + } + // Review CR-16: a manual check answers 200 with the structured + // per-source outcome (detail lives in each source's lastStatus); + // failed sources are counted, not folded into a blanket 500. + respond(w, 200, object{"checked": res.Sources, "created": res.Created, "failed": res.Failed}, nil) + return + } + if str(b, "type") != "email" { + respond(w, 0, nil, bad("Unknown signal type")) + return + } + cfg, ok := b["config"].(map[string]any) + if !ok { + respond(w, 0, nil, bad("Config must be an object")) + return + } + clean := object{} + for _, key := range []string{"from", "subject"} { + if v, ok := cfg[key]; ok { + s, ok := v.(string) + if !ok { + respond(w, 0, nil, bad("Config values must be text")) + return + } + rs := []rune(strings.TrimSpace(s)) + if len(rs) > 200 { + rs = rs[:200] + } + clean[key] = string(rs) + } + } + raw, _ := json.Marshal(clean) + v, e := db.Insert(r.Context(), database(r), "SignalSource", object{"id": db.NewID(), "bookmarkId": id, "ownerId": user(r), "type": "email", "config": string(raw)}) + respond(w, 201, v, e) +} +func HandleSignalSourcesUpdate(w http.ResponseWriter, r *http.Request) { + b, ok := read(w, r) + if !ok { + return + } + if _, ok = b["enabled"].(bool); !ok { + respond(w, 0, nil, bad("Enabled must be boolean")) + return + } + v, e := db.Update(r.Context(), database(r), "SignalSource", r.PathValue("id"), "ownerId", user(r), object{"enabled": b["enabled"], "updatedAt": time.Now().UTC()}) + respond(w, 200, v, e) +} +func HandleSignalSourcesDelete(w http.ResponseWriter, r *http.Request) { + respond(w, 200, OK{true}, remove(r, "SignalSource", r.PathValue("id"), "ownerId")) +} diff --git a/internal/handlers/tags.go b/internal/handlers/tags.go new file mode 100644 index 0000000..7ed0560 --- /dev/null +++ b/internal/handlers/tags.go @@ -0,0 +1,96 @@ +package handlers + +import ( + "database/sql" + "github.com/andrew/go-computer/internal/db" + "net/http" + "strings" + "time" +) + +// Sharing metadata (share list, total usage) belongs to the owner alone; +// recipients see the tag plus only their own usage count. +func HandleTagsList(w http.ResponseWriter, r *http.Request) { + v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(t)||jsonb_build_object( + '_count',jsonb_build_object('bookmarks', + CASE WHEN t."ownerId"=$1 + THEN (SELECT count(*) FROM "BookmarkTag" bt WHERE bt."tagId"=t.id) + ELSE (SELECT count(*) FROM "BookmarkTag" bt JOIN "Bookmark" b ON b.id=bt."bookmarkId" WHERE bt."tagId"=t.id AND b."ownerId"=$1) + END), + 'sharedWith', + CASE WHEN t."ownerId"=$1 + THEN COALESCE((SELECT jsonb_agg(to_jsonb(s)||jsonb_build_object('user',jsonb_build_object('id',u.id,'name',u.name,'email',u.email))) FROM "TagShare" s JOIN "User" u ON u.id=s."userId" WHERE s."tagId"=t.id),'[]'::jsonb) + ELSE '[]'::jsonb + END) + FROM "Tag" t WHERE "ownerId"=$1 OR EXISTS(SELECT 1 FROM "TagShare" s WHERE s."tagId"=t.id AND s."userId"=$1) ORDER BY name`, user(r)) + respond(w, 200, v, e) +} +func tagData(b object) (object, error) { + d := fields(b, "name color") + if e := stringFields(d, "", "name color"); e != nil { + return nil, e + } + if e := limitText(d, "name", maxTagNameLen); e != nil { + return nil, e + } + if _, ok := d["name"]; ok { + d["name"] = strings.TrimSpace(str(d, "name")) + if d["name"] == "" { + return nil, bad("Name required") + } + } + return d, validColor(d) +} +func HandleTagsCreate(w http.ResponseWriter, r *http.Request) { + b, ok := read(w, r) + if !ok { + return + } + d, e := tagData(b) + if e != nil { + respond(w, 0, nil, e) + return + } + if str(d, "name") == "" { + respond(w, 0, nil, bad("Name required")) + return + } + d["id"], d["ownerId"] = db.NewID(), user(r) + v, e := db.Insert(r.Context(), database(r), "Tag", d) + respond(w, 201, v, e) +} +func HandleTagsUpdate(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if _, e := owned(r, database(r), "Tag", id, "ownerId"); e != nil { + respond(w, 0, nil, e) + return + } + b, ok := read(w, r) + if !ok { + return + } + d, e := tagData(b) + if e != nil { + respond(w, 0, nil, e) + return + } + list, e := ids(b, "shareUserIds") + if e != nil { + respond(w, 0, nil, e) + return + } + d["updatedAt"] = time.Now().UTC() + var v object + e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { + var e error + v, e = db.Update(r.Context(), tx, "Tag", id, "ownerId", user(r), d) + if e != nil { + return e + } + return shareRows(r, tx, "TagShare", "tagId", id, list) + }) + respond(w, 200, v, e) +} +func HandleTagsDelete(w http.ResponseWriter, r *http.Request) { + respond(w, 200, OK{true}, remove(r, "Tag", r.PathValue("id"), "ownerId")) +} diff --git a/internal/handlers/unshare.go b/internal/handlers/unshare.go new file mode 100644 index 0000000..df0c73e --- /dev/null +++ b/internal/handlers/unshare.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "github.com/andrew/go-computer/internal/db" + "net/http" +) + +func unshare(w http.ResponseWriter, r *http.Request, parent, join, key string) { + id := r.PathValue("id") + if _, e := owned(r, database(r), parent, id, "ownerId"); e != nil { + respond(w, 0, nil, e) + return + } + _, e := database(r).ExecContext(r.Context(), `DELETE FROM `+db.Quote(join)+` WHERE `+db.Quote(key)+`=$1 AND "userId"=$2`, id, r.PathValue("userId")) + respond(w, 200, OK{true}, e) +} +func HandleBookmarkUnshare(w http.ResponseWriter, r *http.Request) { + unshare(w, r, "Bookmark", "BookmarkShare", "bookmarkId") +} +func HandleTagUnshare(w http.ResponseWriter, r *http.Request) { + unshare(w, r, "Tag", "TagShare", "tagId") +} +func HandleSharedDateDelete(w http.ResponseWriter, r *http.Request) { + respond(w, 200, OK{true}, remove(r, "SharedDate", r.PathValue("id"), "creatorId")) +} diff --git a/internal/http/collections_test.go b/internal/http/collections_test.go new file mode 100644 index 0000000..35d9a01 --- /dev/null +++ b/internal/http/collections_test.go @@ -0,0 +1,420 @@ +package http + +import ( + "testing" +) + +// Collections (Notion-like upgrades phase 2). Proves the locked semantics: +// contextual membership (same bookmark, independent workflows, bookmark +// never owned by the membership), versioned PATCHes (DATA-05 lesson), +// server-side validated filters/sorts with deterministic cursor pagination, +// and access revocation reaching every item surface (N-08). + +func collectionBookmark(t *testing.T, c apiClient, url string) string { + t.Helper() + return c.request("POST", "/api/bookmarks", map[string]any{"url": url, "title": "BM " + url}, 201)["id"].(string) +} + +func TestCollectionsLifecycle(t *testing.T) { + srv := testServer(t) + alice := newAPIUser(t, srv, "coll-life@computer.test") + bob := newAPIUser(t, srv, "coll-other@computer.test") + + bm1 := collectionBookmark(t, alice, "https://127.0.0.1/one") + + col := alice.request("POST", "/api/collections", map[string]any{"name": "Research", "description": "phase 2"}, 201) + cid := col["id"].(string) + views, _ := col["views"].([]any) + if len(views) != 1 { + t.Fatalf("new collection did not get a default view: %v", col) + } + + // Property surface: typed, validated, RELATION reserved but rejected. + alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Rel", "type": "RELATION"}, 400) + alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Bad", "type": "PERSON"}, 400) + status := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{ + "name": "Status", "type": "SELECT", + "config": map[string]any{"options": []any{ + map[string]any{"label": "Backlog"}, map[string]any{"label": "Doing"}, map[string]any{"label": "Done"}, + }}, + }, 201) + statusID := status["id"].(string) + opts, _ := status["config"].(map[string]any)["options"].([]any) + doing := opts[1].(map[string]any)["id"].(string) + done := opts[2].(map[string]any)["id"].(string) + priority := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Priority", "type": "NUMBER"}, 201) + priorityID := priority["id"].(string) + notes := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Notes", "type": "TEXT"}, 201) + notesID := notes["id"].(string) + alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{ + "name": "Due", "type": "DATE", "config": map[string]any{"mode": "bogus"}, + }, 400) + due := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{ + "name": "Due", "type": "DATE", "config": map[string]any{"mode": "date"}, + }, 201) + dueID := due["id"].(string) + + // Duplicate property name is a unique violation -> 409. + alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Priority", "type": "TEXT"}, 409) + + // Items: visibility required at creation, values validated, duplicates 409. + foreign := collectionBookmark(t, bob, "https://127.0.0.1/private") + alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": foreign}, 400) + alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1, + "propertyValues": map[string]any{statusID: "not-an-option"}}, 400) + alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1, + "propertyValues": map[string]any{notesID: 5}}, 400) + alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1, + "propertyValues": map[string]any{dueID: "2026-13-45"}}, 400) + item := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1, + "propertyValues": map[string]any{statusID: doing, priorityID: 2, dueID: "2026-12-31", notesID: "context"}}, 201) + itemID := item["id"].(string) + alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1}, 409) + pv, _ := item["propertyValues"].(map[string]any) + if pv[dueID] != "2026-12-31" { + t.Fatalf("date-only value not stored verbatim: %v", pv[dueID]) + } + if _, ok := item["bookmark"]; !ok { + t.Fatal("item response missing the bookmark projection") + } + + // Item PATCH enforces version (DATA-05 from day one) and merge semantics: + // omitted keys untouched, null removes. + alice.request("PATCH", "/api/collections/"+cid+"/items/"+itemID, map[string]any{"propertyValues": map[string]any{notesID: "x"}}, 400) + alice.request("PATCH", "/api/collections/"+cid+"/items/"+itemID, map[string]any{"version": 99, "propertyValues": map[string]any{notesID: "x"}}, 409) + updated := alice.request("PATCH", "/api/collections/"+cid+"/items/"+itemID, map[string]any{ + "version": 1, "propertyValues": map[string]any{notesID: nil, statusID: done}, + }, 200) + if updated["version"].(float64) != 2 { + t.Fatalf("item patch did not bump version: %v", updated["version"]) + } + pv, _ = updated["propertyValues"].(map[string]any) + if _, stillThere := pv[notesID]; stillThere { + t.Fatal("null did not remove the value") + } + if pv[statusID] != done { + t.Fatal("status not updated") + } + + // The same bookmark in a second collection: contextual values independent, + // intrinsic edits propagate to both (test matrix row 1). + col2 := alice.request("POST", "/api/collections", map[string]any{"name": "Reading"}, 201) + cid2 := col2["id"].(string) + status2 := alice.request("POST", "/api/collections/"+cid2+"/properties", map[string]any{ + "name": "Status", "type": "SELECT", + "config": map[string]any{"options": []any{map[string]any{"label": "Unread"}, map[string]any{"label": "Read"}}}, + }, 201) + unread := status2["config"].(map[string]any)["options"].([]any)[0].(map[string]any)["id"].(string) + item2 := alice.request("POST", "/api/collections/"+cid2+"/items", map[string]any{ + "bookmarkId": bm1, "propertyValues": map[string]any{status2["id"].(string): unread}, + }, 201) + putBookmark(alice, bm1, map[string]any{"title": "Renamed intrinsically"}, 200) + for _, pair := range [][2]string{{cid, itemID}, {cid2, item2["id"].(string)}} { + got := alice.getJSON("/api/collections/"+pair[0]+"/items", 200) + found := false + for _, it := range got { + if it["id"] == pair[1] { + found = true + bmk := it["bookmark"].(map[string]any) + if bmk["title"] != "Renamed intrinsically" { + t.Fatal("intrinsic edit did not propagate to membership", pair[0]) + } + } + } + if !found { + t.Fatal("item missing from list", pair[0]) + } + } + // Contextual independence: collection 1 says Done, collection 2 says Unread. + first := alice.getJSON("/api/collections/"+cid+"/items", 200)[0] + if first["propertyValues"].(map[string]any)[statusID] != done { + t.Fatal("contextual value leaked between collections") + } + + // Removing membership and then the whole collection never touches the + // bookmark (N-01 acceptance). + alice.request("DELETE", "/api/collections/"+cid2+"/items/"+item2["id"].(string), nil, 200) + alice.request("GET", "/api/bookmarks/"+bm1, nil, 200) + alice.request("DELETE", "/api/collections/"+cid2, nil, 200) + alice.request("GET", "/api/bookmarks/"+bm1, nil, 200) + alice.request("GET", "/api/collections/"+cid2, nil, 404) + + // Foreign users get 404 on every collection surface (ownership flow). + bob.request("GET", "/api/collections/"+cid, nil, 404) + bob.request("PATCH", "/api/collections/"+cid, map[string]any{"version": 1, "name": "hijack"}, 404) + bob.request("GET", "/api/collections/"+cid+"/items", nil, 404) + bob.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1}, 404) + bob.request("DELETE", "/api/collections/"+cid, nil, 404) +} + +func TestCollectionViewsFilterSortPaginate(t *testing.T) { + srv := testServer(t) + alice := newAPIUser(t, srv, "coll-views@computer.test") + col := alice.request("POST", "/api/collections", map[string]any{"name": "Pipeline"}, 201) + cid := col["id"].(string) + + status := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{ + "name": "Status", "type": "SELECT", + "config": map[string]any{"options": []any{map[string]any{"label": "Doing"}, map[string]any{"label": "Done"}}}, + }, 201) + statusID := status["id"].(string) + doing := status["config"].(map[string]any)["options"].([]any)[0].(map[string]any)["id"].(string) + done := status["config"].(map[string]any)["options"].([]any)[1].(map[string]any)["id"].(string) + prio := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Prio", "type": "NUMBER"}, 201) + prioID := prio["id"].(string) + + ids := []string{} + for i, s := range []string{doing, done, doing} { + bm := collectionBookmark(t, alice, "https://127.0.0.1/v"+string(rune('a'+i))) + p := float64(i) + if i == 2 { + p = 1 // tie with item 1 on the sort key: id must break it deterministically + } + it := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{ + "bookmarkId": bm, "propertyValues": map[string]any{statusID: s, prioID: p}, + }, 201) + ids = append(ids, it["id"].(string)) + } + + // View validation: unknown properties and type-incompatible operators 400. + alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{"name": "Bad", "filters": map[string]any{ + "operator": "AND", "clauses": []any{map[string]any{"propertyId": "nope", "op": "eq", "value": "x"}}, + }}, 400) + alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{"name": "Bad2", "filters": map[string]any{ + "clauses": []any{map[string]any{"propertyId": statusID, "op": "gt", "value": doing}}, + }}, 400) + alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{"name": "Bad3", "sorts": []any{ + map[string]any{"field": "nope"}, + }}, 400) + + board := alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{ + "name": "Doing board", "type": "BOARD", + "filters": map[string]any{"operator": "AND", "clauses": []any{map[string]any{"propertyId": statusID, "op": "eq", "value": doing}}}, + "sorts": []any{map[string]any{"field": prioID, "dir": "desc"}}, + "groupByPropertyId": statusID, + }, 201) + vid := board["id"].(string) + + // Server-side filtering: only the two Doing items come back. + doingItems := alice.getJSON("/api/collections/"+cid+"/items?viewId="+vid, 200) + if len(doingItems) != 2 { + t.Fatalf("filter returned %d items, want 2", len(doingItems)) + } + // Sorted by priority desc: first page item has prio 1 (the tie item), and + // cursor pagination with limit=1 walks both pages exactly once. + first := doingItems[0] + if first["propertyValues"].(map[string]any)[prioID].(float64) != 1 { + t.Fatal("sort desc did not put the higher priority first") + } + res, e := alice.c.Get(alice.base + "/api/collections/" + cid + "/items?viewId=" + vid + "&limit=1") + if e != nil { + t.Fatal(e) + } + cursor := res.Header.Get("X-Next-Cursor") + res.Body.Close() + if cursor == "" { + t.Fatal("no cursor despite more rows") + } + page := alice.getJSON("/api/collections/"+cid+"/items?viewId="+vid+"&limit=1&cursor="+cursor, 200) + if len(page) != 1 { + t.Fatalf("cursor page returned %d items", len(page)) + } + if page[0]["id"] == first["id"] { + t.Fatal("cursor page repeated the first row (non-deterministic tie-break)") + } + + // View PATCH enforces version like everything else. + alice.request("PATCH", "/api/collections/"+cid+"/views/"+vid, map[string]any{"name": "No version"}, 400) + alice.request("PATCH", "/api/collections/"+cid+"/views/"+vid, map[string]any{"version": 99, "name": "Stale"}, 409) + renamed := alice.request("PATCH", "/api/collections/"+cid+"/views/"+vid, map[string]any{"version": 1, "name": "Doing board v2"}, 200) + if renamed["version"].(float64) != 2 { + t.Fatalf("view patch did not bump version: %v", renamed["version"]) + } + + // Option removal with existing values is refused (stated policy); after + // clearing the values it succeeds. Renames keep the option identity. + alice.request("PATCH", "/api/collections/"+cid+"/properties/"+statusID, map[string]any{"version": 1, + "config": map[string]any{"options": []any{map[string]any{"id": doing, "label": "In flight"}}}}, 400) + // Clear the Doing values by moving every item to Done. + for _, row := range alice.getJSON("/api/collections/"+cid+"/items", 200) { + alice.request("PATCH", "/api/collections/"+cid+"/items/"+row["id"].(string), map[string]any{ + "version": row["version"].(float64), "propertyValues": map[string]any{statusID: done}, + }, 200) + } + renamedProp := alice.request("PATCH", "/api/collections/"+cid+"/properties/"+statusID, map[string]any{"version": 1, + "config": map[string]any{"options": []any{map[string]any{"id": done, "label": "Finished"}}}}, 200) + opts := renamedProp["config"].(map[string]any)["options"].([]any) + if len(opts) != 1 || opts[0].(map[string]any)["id"] != done { + t.Fatalf("option removal/rename produced %+v", opts) + } + // Type changes are forbidden; deleting the property scrubs values and views. + alice.request("PATCH", "/api/collections/"+cid+"/properties/"+statusID, map[string]any{"version": 2, "type": "TEXT"}, 400) + alice.request("DELETE", "/api/collections/"+cid+"/properties/"+statusID, nil, 200) + view := alice.request("GET", "/api/collections/"+cid, nil, 200) + for _, raw := range view["views"].([]any) { + v := raw.(map[string]any) + if v["id"] != vid { + continue + } + f := v["filters"].(map[string]any) + if len(f["clauses"].([]any)) != 0 { + t.Fatalf("deleted property left filter clauses behind: %v", f) + } + // The sort keys on the surviving Prio property and must be untouched. + if len(v["sorts"].([]any)) != 1 { + t.Fatalf("unrelated sorts were scrubbed: %v", v["sorts"]) + } + if v["groupByPropertyId"] != nil { + t.Fatalf("groupBy was not cleared: %v", v["groupByPropertyId"]) + } + } + for _, raw := range alice.getJSON("/api/collections/"+cid+"/items", 200) { + pv, _ := raw["propertyValues"].(map[string]any) + if _, still := pv[statusID]; still { + t.Fatal("deleted property left item values behind") + } + } +} + +func TestCollectionRevocationReachesItems(t *testing.T) { + srv := testServer(t) + alice := newAPIUser(t, srv, "coll-rev@computer.test") + aliceID := alice.request("GET", "/api/me", nil, 200)["user"].(map[string]any)["id"].(string) + bob := newAPIUser(t, srv, "coll-rev-owner@computer.test") + + // Bob owns a bookmark and shares it with Alice; Alice puts it in HER + // collection. Revocation must make the item vanish from every surface + // (list, count, mutation) without deleting anything (N-08, decision #4). + shared := collectionBookmark(t, bob, "https://127.0.0.1/shared") + bob.request("POST", "/api/bookmarks/"+shared+"/share", map[string]any{"userIds": []string{aliceID}}, 200) + col := alice.request("POST", "/api/collections", map[string]any{"name": "Watchlist"}, 201) + cid := col["id"].(string) + item := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": shared}, 201) + itemID := item["id"].(string) + if len(alice.getJSON("/api/collections/"+cid+"/items", 200)) != 1 { + t.Fatal("shared bookmark item not listed") + } + for _, c := range alice.getJSON("/api/collections", 200) { + if c["id"] == cid && c["_count"].(map[string]any)["items"].(float64) != 1 { + t.Fatalf("authorized count wrong: %v", c["_count"]) + } + } + + bob.request("DELETE", "/api/bookmarks/"+shared+"/share/"+aliceID, nil, 200) + + if len(alice.getJSON("/api/collections/"+cid+"/items", 200)) != 0 { + t.Fatal("revoked bookmark still listed in collection items") + } + for _, c := range alice.getJSON("/api/collections", 200) { + if c["id"] == cid && c["_count"].(map[string]any)["items"].(float64) != 0 { + t.Fatalf("revoked bookmark still counted: %v", c["_count"]) + } + } + alice.request("PATCH", "/api/collections/"+cid+"/items/"+itemID, map[string]any{"version": 1, "position": 3}, 404) + alice.request("DELETE", "/api/collections/"+cid+"/items/"+itemID, nil, 404) + // Review COR-2: a hidden membership row must not wedge reorder. Alice + // adds one of her own bookmarks, then reorders the visible items — the + // revoked membership stays hidden at the tail instead of making every + // PUT /items fail "Order must contain every item exactly once". + own := collectionBookmark(t, alice, "https://127.0.0.1/alice-own") + ownItem := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": own}, 201) + ownID := ownItem["id"].(string) + visible := alice.getJSON("/api/collections/"+cid+"/items", 200) + if len(visible) != 1 || visible[0]["id"] != ownID { + t.Fatalf("expected only the own item visible, got %v", visible) + } + alice.request("PUT", "/api/collections/"+cid+"/items", map[string]any{"order": []string{ownID}}, 200) + // The membership row itself survives revocation: re-sharing restores it. + bob.request("POST", "/api/bookmarks/"+shared+"/share", map[string]any{"userIds": []string{aliceID}}, 200) + items := alice.getJSON("/api/collections/"+cid+"/items", 200) + if len(items) != 2 { + t.Fatal("re-sharing did not restore the existing membership") + } +} + +// Review CR-09: indirect mutations advance revisions — reorder bumps every +// affected item, property deletion bumps the items whose values it dropped +// and only the views whose definitions it actually changed. Stale PATCHes +// based on the pre-reorder / pre-deletion state get 409s. +func TestCollectionIndirectMutationsAdvanceVersions(t *testing.T) { + srv := testServer(t) + alice := newAPIUser(t, srv, "coll-rev@computer.test") + bm1 := collectionBookmark(t, alice, "https://127.0.0.1/r1") + bm2 := collectionBookmark(t, alice, "https://127.0.0.1/r2") + col := alice.request("POST", "/api/collections", map[string]any{"name": "Revisions"}, 201) + cid := col["id"].(string) + dvid := col["views"].([]any)[0].(map[string]any)["id"].(string) + + prop := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Label", "type": "TEXT"}, 201) + pid := prop["id"].(string) + it1 := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1, "propertyValues": map[string]any{pid: "one"}}, 201) + it2 := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm2}, 201) + id1, id2 := it1["id"].(string), it2["id"].(string) + filtered := alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{ + "name": "Filtered", "type": "TABLE", + "filters": map[string]any{"operator": "AND", "clauses": []any{map[string]any{"propertyId": pid, "op": "eq", "value": "one"}}}, + }, 201) + fvid := filtered["id"].(string) + + itemVersion := func(id string) int { + t.Helper() + for _, it := range alice.getJSON("/api/collections/"+cid+"/items", 200) { + if it["id"] == id { + v, _ := it["version"].(float64) + return int(v) + } + } + t.Fatalf("item %s missing from list", id) + return -1 + } + viewVersion := func(id string) int { + t.Helper() + detail := alice.request("GET", "/api/collections/"+cid, nil, 200) + for _, raw := range detail["views"].([]any) { + v := raw.(map[string]any) + if v["id"] == id { + n, _ := v["version"].(float64) + return int(n) + } + } + t.Fatalf("view %s missing", id) + return -1 + } + + // Baselines: collection entities start at version 1 (migration 002). + base1, base2 := itemVersion(id1), itemVersion(id2) + fv0, dv0 := viewVersion(fvid), viewVersion(dvid) + + // Reorder advances every affected item's revision... + alice.request("PUT", "/api/collections/"+cid+"/items", map[string]any{"order": []string{id2, id1}}, 200) + if v := itemVersion(id1); v != base1+1 { + t.Fatalf("reorder left item1 at version %d, want %d", v, base1+1) + } + if v := itemVersion(id2); v != base2+1 { + t.Fatalf("reorder left item2 at version %d, want %d", v, base2+1) + } + // ...so a PATCH based on the pre-reorder snapshot is a 409, and the + // current base still succeeds. + alice.request("PATCH", "/api/collections/"+cid+"/items/"+id1, map[string]any{"position": 0, "version": base1}, 409) + alice.request("PATCH", "/api/collections/"+cid+"/items/"+id1, map[string]any{"position": 0, "version": base1 + 1}, 200) + if v := itemVersion(id1); v != base1+2 { + t.Fatalf("PATCH did not advance item1 to version %d: %d", base1+2, v) + } + + // Property deletion: the item carrying a value advances; the item without + // one does not. The view filtering on the property advances (and its + // clause is scrubbed); the untouched default view keeps its revision. + alice.request("DELETE", "/api/collections/"+cid+"/properties/"+pid, nil, 200) + if v := itemVersion(id1); v != base1+3 { + t.Fatalf("property deletion left valued item1 at version %d, want %d", v, base1+3) + } + if v := itemVersion(id2); v != base2+1 { + t.Fatalf("property deletion bumped valueless item2 to %d, want %d", v, base2+1) + } + if v := viewVersion(fvid); v != fv0+1 { + t.Fatalf("property deletion left the filtered view at version %d, want %d", v, fv0+1) + } + if v := viewVersion(dvid); v != dv0 { + t.Fatalf("property deletion bumped the untouched default view to %d, want %d", v, dv0) + } +} diff --git a/internal/http/coverage_test.go b/internal/http/coverage_test.go new file mode 100644 index 0000000..f038d22 --- /dev/null +++ b/internal/http/coverage_test.go @@ -0,0 +1,510 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/andrew/go-computer/internal/auth" + "github.com/andrew/go-computer/internal/signals" + "github.com/emersion/go-imap/backend/memory" + imapserver "github.com/emersion/go-imap/server" + "github.com/golang-jwt/jwt/v5" +) + +// Covers routes that previously had no coverage at any level (CODE_REVIEW +// TEST-1) plus signout revocation (AUTH-5) and the background scheduler +// (TEST-2). + +func (a apiClient) getJSON(path string, status int) []map[string]any { + a.t.Helper() + res, e := a.c.Get(a.base + path) + if e != nil { + a.t.Fatal(e) + } + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + if res.StatusCode != status { + a.t.Fatalf("GET %s: wanted %d, got %d: %s", path, status, res.StatusCode, raw) + } + var v []map[string]any + if e := json.Unmarshal(raw, &v); e != nil { + a.t.Fatalf("GET %s: not a JSON array: %v: %s", path, e, raw) + } + return v +} + +func (a apiClient) getText(path string, status int) string { + a.t.Helper() + res, e := a.c.Get(a.base + path) + if e != nil { + a.t.Fatal(e) + } + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + if res.StatusCode != status { + a.t.Fatalf("GET %s: wanted %d, got %d: %s", path, status, res.StatusCode, raw) + } + return string(raw) +} + +func newAPIUser(t *testing.T, srv *httptest.Server, email string) apiClient { + t.Helper() + jar, _ := cookiejar.New(nil) + c := apiClient{t, &http.Client{Jar: jar}, srv.URL} + creds := map[string]any{"email": email, "password": "coverage-test-password"} + c.request("POST", "/api/signup", creds, 201) + c.request("POST", "/api/auth/login", creds, 200) + return c +} + +func TestPreviouslyUncoveredRoutes(t *testing.T) { + srv := testServer(t) + alice := newAPIUser(t, srv, "coverage-alice@computer.test") + newAPIUser(t, srv, "coverage-searchable@computer.test") + + // GET /api/preview — handler plumbing (auth + query + parse). + html := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + io.WriteString(w, `Preview Targetx`) + })) + defer html.Close() + anon, _ := cookiejar.New(nil) + anonymous := apiClient{t, &http.Client{Jar: anon}, srv.URL} + anonymous.request("GET", "/api/preview?url="+html.URL, nil, 401) + if v := alice.request("GET", "/api/preview?url="+html.URL, nil, 200); v["title"] != "Preview Target" || v["description"] != "Described" { + t.Fatal("preview handler:", v) + } + + // GET /api/bookmarks/{id}/markdown — content, not just the link element. + b := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.org/coverage", "title": "Coverage Bookmark", "notes": "# Coverage notes"}, 201) + id := b["id"].(string) + md := alice.getText("/api/bookmarks/"+id+"/markdown", 200) + if !strings.Contains(md, "https://example.org/coverage") || !strings.Contains(md, "Coverage notes") { + t.Fatal("bookmark markdown:", md) + } + + // GET /api/users/search — name substring, exact email, masked output, + // no email-substring enumeration, 2-rune guard. + users := alice.getJSON("/api/users/search?q=coverage-sea", 200) + if len(users) != 1 || users[0]["email"] != "c***@computer.test" { + t.Fatal("users/search name match:", users) + } + if users := alice.getJSON("/api/users/search?q=coverage-searchable@computer.test", 200); len(users) != 1 { + t.Fatal("users/search exact email:", users) + } + // "able@computer.test" is a substring of the target's email but not a + // full email and not part of any name: must not match. + if users := alice.getJSON("/api/users/search?q=able%40computer.test", 200); len(users) != 0 { + t.Fatal("users/search must not substring-match emails:", users) + } + if users := alice.getJSON("/api/users/search?q=c", 200); len(users) != 0 { + t.Fatal("users/search short-query guard:", users) + } + // Review SEC-3: LIKE wildcards are escaped, so "%%"/"_%" cannot enumerate + // the whole user base. "%%" matches a literal percent-pair (2 runes, so + // it passes the minimum-length guard); a name literally containing "%%" + // still matches, but nothing else does. + if users := alice.getJSON("/api/users/search?q=%25%25", 200); len(users) != 0 { + t.Fatalf("users/search wildcard enumeration: %v", users) + } + + // PATCH /api/kanban/columns/{id} and PUT /api/kanban/columns happy paths. + col := alice.request("POST", "/api/kanban/columns", map[string]any{"label": "Coverage"}, 201)["id"].(string) + alice.request("PATCH", "/api/kanban/columns/"+col, map[string]any{"label": "Renamed", "color": "#00ff00"}, 200) + cols := alice.getJSON("/api/kanban/columns", 200) + var found bool + order := make([]string, 0, len(cols)) + for _, c := range cols { + order = append(order, c["id"].(string)) + if c["id"] == col { + found = c["label"] == "Renamed" && c["color"] == "#00ff00" + } + } + if !found { + t.Fatal("column patch not applied:", cols) + } + reversed := make([]string, 0, len(order)) + for i := len(order) - 1; i >= 0; i-- { + reversed = append(reversed, order[i]) + } + alice.request("PUT", "/api/kanban/columns", map[string]any{"order": reversed}, 200) + after := alice.getJSON("/api/kanban/columns", 200) + if after[0]["id"] != reversed[0] || after[len(after)-1]["id"] != reversed[len(reversed)-1] { + t.Fatal("column reorder not applied") + } + + // PUT /api/pages reorder. + p1 := alice.request("POST", "/api/pages", map[string]any{"title": "P1"}, 201)["id"].(string) + p2 := alice.request("POST", "/api/pages", map[string]any{"title": "P2"}, 201)["id"].(string) + p3 := alice.request("POST", "/api/pages", map[string]any{"title": "P3"}, 201)["id"].(string) + alice.request("PUT", "/api/pages", map[string]any{"order": []string{p3, p1, p2}}, 200) + pages := alice.getJSON("/api/pages", 200) + if pages[0]["id"] != p3 || pages[1]["id"] != p1 || pages[2]["id"] != p2 { + t.Fatal("pages reorder not applied:", pages) + } + + // DELETE /api/tags/{id} owner success + BookmarkTag cascade. + tag := alice.request("POST", "/api/tags", map[string]any{"name": "DeleteMe"}, 201)["id"].(string) + putBookmark(alice, id, map[string]any{"tagIds": []string{tag}}, 200) + alice.request("DELETE", "/api/tags/"+tag, nil, 200) + if v := alice.request("GET", "/api/bookmarks/"+id, nil, 200); len(v["tags"].([]any)) != 0 { + t.Fatal("tag delete did not cascade bookmark_tags") + } + for _, tg := range alice.getJSON("/api/tags", 200) { + if tg["id"] == tag { + t.Fatal("deleted tag still listed") + } + } + + // DELETE /api/signal-sources/{id} owner success. + source := alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"subject": "x"}}, 201)["id"].(string) + alice.request("DELETE", "/api/signal-sources/"+source, nil, 200) + if v := alice.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200); len(v["sources"].([]any)) != 0 { + t.Fatal("signal source still present after delete") + } +} + +// Signout must revoke every issued cookie JWT, not just clear this browser's +// cookie: a token copied before signout stops working. +func TestSignoutRevokesIssuedTokens(t *testing.T) { + srv := testServer(t) + u := newAPIUser(t, srv, "revoke@computer.test") + + creds, _ := json.Marshal(map[string]any{"email": "revoke@computer.test", "password": "coverage-test-password"}) + req, _ := http.NewRequest("POST", srv.URL+"/api/auth/login", bytes.NewReader(creds)) + req.Header.Set("Content-Type", "application/json") + res, e := u.c.Do(req) + if e != nil { + t.Fatal(e) + } + res.Body.Close() + var stale string + for _, c := range res.Cookies() { + if c.Name == "computer_session" { + stale = c.Value + } + } + if stale == "" { + t.Fatal("login did not set a session cookie") + } + + u.request("POST", "/api/auth/signout", map[string]any{}, 200) + + // Replay the pre-signout token directly (no jar): must be rejected. + replay := func(path string) int { + rq, _ := http.NewRequest("GET", srv.URL+path, nil) + rq.AddCookie(&http.Cookie{Name: "computer_session", Value: stale}) + rs, e := u.c.Do(rq) + if e != nil { + t.Fatal(e) + } + defer rs.Body.Close() + io.Copy(io.Discard, rs.Body) + return rs.StatusCode + } + if code := replay("/api/me"); code != 401 { + t.Fatalf("stale cookie accepted on /api/me: %d", code) + } + if code := replay("/api/bookmarks"); code != 401 { + t.Fatalf("stale cookie accepted on data route: %d", code) + } + rq, _ := http.NewRequest("GET", srv.URL+"/api/auth/session", nil) + rq.AddCookie(&http.Cookie{Name: "computer_session", Value: stale}) + rs, e := u.c.Do(rq) + if e != nil { + t.Fatal(e) + } + defer rs.Body.Close() + raw, _ := io.ReadAll(rs.Body) + if !bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + t.Fatalf("stale cookie still resolves a session: %s", raw) + } +} + +// One scheduler tick creates signals and marks sources checked (TEST-2). +func TestSignalSchedulerRunOnce(t *testing.T) { + srv, d := testServerDB(t) + alice := newAPIUser(t, srv, "scheduler@computer.test") + + listener, e := net.Listen("tcp", "127.0.0.1:0") + if e != nil { + t.Fatal(e) + } + mail := imapserver.New(memory.New()) + mail.AllowInsecureAuth = true + go mail.Serve(listener) + defer mail.Close() + host, port, _ := net.SplitHostPort(listener.Addr().String()) + pn, _ := strconv.Atoi(port) + + alice.request("POST", "/api/settings/imap", map[string]any{"host": host, "port": pn, "tls": false, "username": "username", "password": "password", "folder": "INBOX"}, 200) + id := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.org/scheduler", "imapQuery": "little"}, 201)["id"].(string) + alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"from": "contact@example.org", "subject": "little"}}, 201) + + signals.RunOnce(context.Background(), d) + + v := alice.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200) + if len(v["signals"].([]any)) == 0 { + t.Fatal("scheduler tick created no signals") + } + sources := v["sources"].([]any) + if len(sources) != 1 { + t.Fatalf("expected 1 source, got %d", len(sources)) + } + src := sources[0].(map[string]any) + if src["lastStatus"] != "Checked" || src["lastCheckedAt"] == nil { + t.Fatal("scheduler did not mark source checked:", src) + } + // A second tick must not duplicate signals (dedup under the scheduler path). + before := len(v["signals"].([]any)) + signals.RunOnce(context.Background(), d) + v = alice.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200) + if len(v["signals"].([]any)) != before { + t.Fatal("scheduler tick duplicated signals") + } +} + +// Review CR-16: mailbox failures are scheduler failures. A pass whose mailbox +// cannot even load must record the per-source status, answer manual checks +// with the structured outcome (failed count, not a blanket 500), and drive +// the per-user backoff so the next ticks are skipped. +func TestSchedulerBacksOffAfterMailboxFailure(t *testing.T) { + srv, d := testServerDB(t) + alice := newAPIUser(t, srv, "backoff@computer.test") + id := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.org/backoff"}, 201)["id"].(string) + alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"subject": "anything"}}, 201) + // Deliberately no saved IMAP configuration: every pass fails in LoadMailbox. + + v := alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"action": "check"}, 200) + if v["checked"] != float64(1) || v["failed"] != float64(1) { + t.Fatal("manual check did not report the structured failure outcome:", v) + } + src := func() map[string]any { + got := alice.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200) + return got["sources"].([]any)[0].(map[string]any) + } + if status, _ := src()["lastStatus"].(string); !strings.HasPrefix(status, "Error:") { + t.Fatal("per-source failure status not recorded:", status) + } + + s := signals.NewScheduler() + ctx := context.Background() + s.RunOnce(ctx, d) // tick 1: runs and fails → failures=1 + s.RunOnce(ctx, d) // tick 2: 2%2==0 → runs and fails → failures=2 + after := src()["lastCheckedAt"] + s.RunOnce(ctx, d) // tick 3: 3%4!=0 → backoff must skip it + if got := src()["lastCheckedAt"]; got != after { + t.Fatalf("backoff did not skip tick 3: lastCheckedAt changed %v → %v", after, got) + } +} + +// Page saves enforce optimistic concurrency (review DATA-01/DATA-05): a stale +// base version is rejected with 409 and a missing version with 400, both +// leaving the row untouched; content saves bump the revision, and metadata +// patches neither require nor bump it. The list projection omits content +// bodies (review PERF-01). +func TestPageVersionAndListProjection(t *testing.T) { + srv := testServer(t) + alice := newAPIUser(t, srv, "pagever@computer.test") + created := alice.request("POST", "/api/pages", map[string]any{"title": "Doc", "content": "first"}, 201) + id := created["id"].(string) + if created["version"].(float64) != 1 { + t.Fatalf("new page did not start at version 1: %v", created["version"]) + } + alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "stale tab", "version": 99}, 409) + if v := alice.request("GET", "/api/pages/"+id, nil, 200); v["content"] != "first" { + t.Fatal("rejected stale write still modified content") + } + saved := alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "second", "version": 1}, 200) + if saved["version"].(float64) != 2 { + t.Fatalf("content save did not bump version: %v", saved["version"]) + } + // Review DATA-05: omitting the version cannot bypass concurrency — the + // write is rejected and the row is untouched. Metadata patches stay + // version-free (they do not bump the revision either). + alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "unversioned clobber"}, 400) + if v := alice.request("GET", "/api/pages/"+id, nil, 200); v["content"] != "second" || v["version"].(float64) != 2 { + t.Fatalf("version-less content write mutated the page: %v", v) + } + pin := alice.request("PATCH", "/api/pages/"+id, map[string]any{"pinned": true}, 200) + if pin["version"].(float64) != 2 { + t.Fatalf("metadata patch changed the revision: %v", pin["version"]) + } + alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "third", "version": 1}, 409) + alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "x", "version": -1}, 400) + found := false + for _, p := range alice.getJSON("/api/pages", 200) { + if p["id"] != id { + continue + } + found = true + if _, ok := p["content"]; ok { + t.Fatal("list projection still includes content") + } + if p["title"] != "Doc" || p["_count"] == nil { + t.Fatal("list projection lost metadata:", p) + } + } + if !found { + t.Fatal("page missing from list") + } +} + +// Review CR-18: a content-save acknowledgement must describe the revision +// THIS write produced. Concurrent writers racing on the same base: exactly +// one wins and its response reports base+1 (the snapshot is read inside the +// write transaction), every loser gets 409 — no acknowledgement can carry a +// competing writer's committed revision. +func TestConcurrentPageSaveAckReportsOwnWrite(t *testing.T) { + srv := testServer(t) + alice := newAPIUser(t, srv, "pageack@computer.test") + id := alice.request("POST", "/api/pages", map[string]any{"title": "Ack", "content": "v1"}, 201)["id"].(string) + + const writers = 8 + type outcome struct { + status int + version float64 + } + results := make(chan outcome, writers) + var wg sync.WaitGroup + for i := 0; i < writers; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + body, _ := json.Marshal(map[string]any{"content": fmt.Sprintf("writer-%d", n), "version": 1}) + rq, _ := http.NewRequest("PATCH", srv.URL+"/api/pages/"+id, bytes.NewReader(body)) + rq.Header.Set("Content-Type", "application/json") + rs, e := alice.c.Do(rq) + if e != nil { + results <- outcome{status: -1} + return + } + defer rs.Body.Close() + raw, _ := io.ReadAll(rs.Body) + out := outcome{status: rs.StatusCode} + var v map[string]any + if json.Unmarshal(raw, &v) == nil { + out.version, _ = v["version"].(float64) + } + results <- out + }(i) + } + wg.Wait() + close(results) + wins := 0 + for o := range results { + switch o.status { + case 200: + wins++ + if o.version != 2 { + t.Fatalf("winning acknowledgement reported version %v, want exactly base+1 = 2", o.version) + } + case 409: + default: + t.Fatalf("concurrent writer got unexpected status %d", o.status) + } + } + if wins != 1 { + t.Fatalf("expected exactly one winning writer, got %d", wins) + } +} + +// Review CR-04: bookmark updates enforce optimistic concurrency — a missing +// version is 400, a stale base is 409 with the row untouched, success bumps +// the revision, and tag replacement rides the same contract. +func TestBookmarkVersionContract(t *testing.T) { + srv := testServer(t) + alice := newAPIUser(t, srv, "bmver@computer.test") + created := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.org/ver", "title": "v0"}, 201) + id := created["id"].(string) + if created["version"].(float64) != 0 { + t.Fatalf("new bookmark did not start at version 0: %v", created["version"]) + } + alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"title": "no version"}, 400) + alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"title": "stale", "version": 5}, 409) + if v := alice.request("GET", "/api/bookmarks/"+id, nil, 200); v["title"] != "v0" || v["version"].(float64) != 0 { + t.Fatalf("rejected writes modified the bookmark: %v", v) + } + saved := alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"title": "v1", "version": 0}, 200) + if saved["version"].(float64) != 1 { + t.Fatalf("successful update did not bump the version: %v", saved["version"]) + } + // A second writer holding the same base must not both succeed. + alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"title": "late", "version": 0}, 409) + tag := alice.request("POST", "/api/tags", map[string]any{"name": "versioned"}, 201)["id"].(string) + out := alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"tagIds": []string{tag}, "version": 1}, 200) + if out["version"].(float64) != 2 { + t.Fatalf("tag replacement did not advance the version: %v", out["version"]) + } + if tags, ok := out["tags"].([]any); !ok || len(tags) != 1 { + t.Fatalf("tag replacement lost tags: %v", out["tags"]) + } +} + +// Assertions ride the tokenVersion (review SEC-02): "sign out everywhere" +// must revoke NextAuth-backed Bearer assertions, not just Go cookie sessions. +func TestAssertionRevocationFollowsTokenVersion(t *testing.T) { + srv, d := testServerDB(t) + u := newAPIUser(t, srv, "assert-revoke@computer.test") + uid := u.request("GET", "/api/me", nil, 200)["user"].(map[string]any)["id"].(string) + var ver int + if e := d.QueryRow(`SELECT "tokenVersion" FROM "User" WHERE id=$1`, uid).Scan(&ver); e != nil { + t.Fatal(e) + } + sign := func(v int) string { + now := time.Now() + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, auth.SessionClaims{ + UserID: uid, Type: auth.TokenTypeAssertion, Ver: v, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "computer", + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(60 * time.Second)), + }, + }) + s, e := tok.SignedString([]byte("integration-test-secret")) + if e != nil { + t.Fatal(e) + } + return s + } + bearer := func(token string) int { + rq, _ := http.NewRequest("GET", srv.URL+"/api/me", nil) + rq.Header.Set("Authorization", "Bearer "+token) + rs, e := u.c.Do(rq) + if e != nil { + t.Fatal(e) + } + defer rs.Body.Close() + io.Copy(io.Discard, rs.Body) + return rs.StatusCode + } + if code := bearer(sign(ver)); code != 200 { + t.Fatalf("current-version assertion rejected: %d", code) + } + if code := bearer(sign(0)); code != 401 { + t.Fatalf("version-less assertion accepted: %d", code) + } + u.request("POST", "/api/auth/signout", map[string]any{}, 200) + if code := bearer(sign(ver)); code != 401 { + t.Fatalf("assertion minted before the revocation bump still accepted: %d", code) + } + var bumped int + if e := d.QueryRow(`SELECT "tokenVersion" FROM "User" WHERE id=$1`, uid).Scan(&bumped); e != nil { + t.Fatal(e) + } + if code := bearer(sign(bumped)); code != 200 { + t.Fatalf("assertion with the post-bump version rejected: %d", code) + } +} diff --git a/internal/http/integration_test.go b/internal/http/integration_test.go new file mode 100644 index 0000000..b74d1d6 --- /dev/null +++ b/internal/http/integration_test.go @@ -0,0 +1,220 @@ +package http + +import ( + "bytes" + "encoding/json" + "github.com/andrew/go-computer/internal/auth" + "github.com/andrew/go-computer/internal/config" + "github.com/andrew/go-computer/internal/db" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "os" + "testing" +) + +// Integration tests create and drop only their own random PostgreSQL schema. +// They never truncate application tables or delete pre-existing accounts. +func testServer(t *testing.T) *httptest.Server { + t.Helper() + srv, _ := testServerDB(t) + return srv +} +func testServerDB(t *testing.T) (*httptest.Server, *db.DB) { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set TEST_DATABASE_URL to run PostgreSQL integration tests") + } + // The limiter stores are process-global and login has no loopback + // exemption (review SEC-1); give every test a clean slate. + auth.ResetRateLimits() + t.Cleanup(auth.ResetRateLimits) + t.Setenv("AUTH_SECRET", "integration-test-secret") + t.Setenv("NEXTAUTH_SECRET", "integration-test-secret") + t.Setenv("ADMIN_EMAILS", "") + // CardDAV/IMAP fakes listen on loopback; netguard blocks private ranges by + // default (production compose never sets this). + t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1") + // The IMAP fixture is a plaintext loopback server; production refuses + // non-TLS mailboxes (review NET-02) and this flag is the test-only escape + // hatch (see signals.AllowInsecure). + t.Setenv("INTEGRATIONS_ALLOW_INSECURE_IMAP", "1") + admin, e := db.New(&config.Config{DatabaseURL: dsn}) + if e != nil { + t.Fatal(e) + } + schema := "test_" + db.NewID() + if _, e = admin.Exec(`CREATE SCHEMA ` + db.Quote(schema)); e != nil { + t.Fatal(e) + } + u, e := url.Parse(dsn) + if e != nil { + t.Fatal(e) + } + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + d, e := db.New(&config.Config{DatabaseURL: u.String()}) + if e != nil { + t.Fatal(e) + } + t.Cleanup(func() { d.Close(); admin.Exec(`DROP SCHEMA ` + db.Quote(schema) + ` CASCADE`); admin.Close() }) + raw, e := os.ReadFile("../../db/schema.sql") + if e != nil { + t.Fatal(e) + } + if _, e = d.Exec(string(raw)); e != nil { + t.Fatal(e) + } + srv := httptest.NewServer(NewServer(config.Load(), d).Handler) + t.Cleanup(srv.Close) + return srv, d +} + +type apiClient struct { + t *testing.T + c *http.Client + base string +} + +func (a apiClient) request(method, path string, body any, status int) map[string]any { + a.t.Helper() + var b io.Reader + if body != nil { + raw, _ := json.Marshal(body) + b = bytes.NewReader(raw) + } + req, _ := http.NewRequest(method, a.base+path, b) + req.Header.Set("Content-Type", "application/json") + res, e := a.c.Do(req) + if e != nil { + a.t.Fatal(e) + } + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + if res.StatusCode != status { + a.t.Fatalf("%s %s: wanted %d, got %d: %s", method, path, status, res.StatusCode, raw) + } + var v map[string]any + json.Unmarshal(raw, &v) + return v +} + +// meID resolves the authenticated client's user id. Signup responses no +// longer carry it (review CR-03: identical bodies for every accepted +// registration, so no unauthenticated existence oracle); identity comes from +// the session after login. +func meID(c apiClient) string { + c.t.Helper() + return c.request("GET", "/api/me", nil, 200)["user"].(map[string]any)["id"].(string) +} + +// putBookmark sends PUT /api/bookmarks/{id} with the bookmark's CURRENT +// version attached (review CR-04: the concurrency contract requires it). +func putBookmark(c apiClient, id string, body map[string]any, status int) map[string]any { + c.t.Helper() + cur := c.request("GET", "/api/bookmarks/"+id, nil, 200) + v, _ := cur["version"].(float64) + body["version"] = int(v) + return c.request("PUT", "/api/bookmarks/"+id, body, status) +} +func TestApplicationFlows(t *testing.T) { + srv := testServer(t) + newClient := func() apiClient { jar, _ := cookiejar.New(nil); return apiClient{t, &http.Client{Jar: jar}, srv.URL} } + alice, bob, anonymous := newClient(), newClient(), newClient() + post := func(email string) map[string]any { + return map[string]any{"email": email, "password": "a-long-test-password"} + } + alice.request("POST", "/api/signup", post("alice@computer.test"), 201) + bob.request("POST", "/api/signup", post("bob@computer.test"), 201) + // Review SEC-4/CR-03: a duplicate signup returns the SAME 201 {"ok":true} + // body as a fresh creation (no 409, no distinguishing field). + dup := alice.request("POST", "/api/signup", post("alice@computer.test"), 201) + if len(dup) != 1 || dup["ok"] != true { + t.Fatalf("duplicate signup body differs from a fresh creation: %v", dup) + } + alice.request("POST", "/api/auth/login", post("alice@computer.test"), 200) + bob.request("POST", "/api/auth/login", post("bob@computer.test"), 200) + au := meID(alice) + bu := meID(bob) + if alice.request("GET", "/api/me", nil, 200)["isAdmin"] != true { + t.Fatal("first signup was not admin") + } + anonymous.request("GET", "/api/bookmarks", nil, 401) + bob.request("GET", "/api/admin/users", nil, 403) + tag := alice.request("POST", "/api/tags", map[string]any{"name": "Research"}, 201)["id"].(string) + b := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/link", "title": "Original", "notes": "# Notes", "tagIds": []string{tag}, "dueDate": "2026-10-07", "alertAt": "2000-01-01"}, 201) + id := b["id"].(string) + bob.request("GET", "/api/bookmarks/"+id, nil, 404) + bob.request("PUT", "/api/bookmarks/"+id, map[string]any{"notes": "attack"}, 404) + putBookmark(alice, id, map[string]any{"dueDate": nil, "isPublic": false}, 200) + if v := alice.request("GET", "/api/bookmarks/"+id, nil, 200); v["title"] != "Original" || v["dueDate"] != nil { + t.Fatal("partial/null update broken") + } + putBookmark(alice, id, map[string]any{"tagIds": []string{"missing"}}, 400) + v := alice.request("GET", "/api/bookmarks/"+id, nil, 200) + if len(v["tags"].([]any)) != 1 { + t.Fatal("failed update lost tags") + } + alice.request("POST", "/api/bookmarks/"+id+"/share", map[string]any{"userIds": []string{bu}, "isPublic": true}, 200) + bob.request("GET", "/api/bookmarks/"+id, nil, 200) + anonymous.request("GET", "/api/public/bookmarks/"+id, nil, 200) + bob.request("DELETE", "/api/bookmarks/"+id, nil, 404) + alice.request("PUT", "/api/tags/"+tag, map[string]any{"shareUserIds": []string{bu}}, 200) + bob.request("GET", "/api/bookmarks?tagId="+tag, nil, 200) + page := bob.request("POST", "/api/pages", map[string]any{"title": "Workspace", "content": "Markdown text"}, 201)["id"].(string) + bob.request("PATCH", "/api/pages/"+page, map[string]any{"addBookmarkId": id}, 200) + bob.request("PATCH", "/api/pages/"+page, map[string]any{"addBookmarkId": id}, 200) + v = bob.request("GET", "/api/pages/"+page, nil, 200) + if len(v["bookmarks"].([]any)) != 1 { + t.Fatal("duplicate page link") + } + bob.request("GET", "/api/pages/"+page+"/markdown", nil, 200) + alice.request("GET", "/api/kanban/columns", nil, 200) + col := alice.request("POST", "/api/kanban/columns", map[string]any{"label": "Custom"}, 201)["id"].(string) + putBookmark(alice, id, map[string]any{"kanbanColumnId": col}, 200) + v = alice.request("DELETE", "/api/kanban/columns/"+col, nil, 200) + if v["moved"] != float64(1) { + t.Fatal("deleted column did not move card") + } + alice.request("GET", "/api/bookmarks/"+id, nil, 200) + alice.request("PUT", "/api/kanban/columns", map[string]any{"order": []string{"not-mine"}}, 400) + alice.request("GET", "/api/alerts/check", nil, 200) + alice.request("DELETE", "/api/alerts/"+id, nil, 200) + v = alice.request("GET", "/api/bookmarks/"+id, nil, 200) + if v["alertSent"] != true { + t.Fatal("alert dismissal") + } + putBookmark(alice, id, map[string]any{"alertAt": "2000-01-02"}, 200) + source := alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"from": "sender@computer.test"}}, 201)["id"].(string) + alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"action": "check"}, 200) + alice.request("PATCH", "/api/signal-sources/"+source, map[string]any{"enabled": false}, 200) + bob.request("DELETE", "/api/signal-sources/"+source, nil, 404) + alice.request("POST", "/api/settings/imap", map[string]any{"host": "imap.invalid", "username": "alice", "password": "encrypted-password"}, 200) + v = alice.request("GET", "/api/settings/imap", nil, 200) + if v["password"] != "••••••••" { + t.Fatal("mailbox password exposed") + } + alice.request("POST", "/api/settings/imap", map[string]any{"host": "imap.invalid", "username": "alice", "password": ""}, 200) + alice.request("POST", "/api/shared-dates", map[string]any{"date": "2026-10-07", "recipientIds": []string{bu}}, 201) + bob.request("GET", "/api/shared-dates", nil, 200) + alice.request("POST", "/api/admin/view-as", map[string]any{"userId": bu}, 200) + v = alice.request("GET", "/api/me", nil, 200) + if v["effectiveUser"].(map[string]any)["id"] != bu || v["user"].(map[string]any)["id"] != au { + t.Fatal("view-as identity") + } + alice.request("GET", "/api/admin/users", nil, 200) + alice.request("GET", "/api/pages/"+page, nil, 200) + alice.request("POST", "/api/admin/view-as", map[string]any{"userId": nil}, 200) + alice.request("PATCH", "/api/admin/users", map[string]any{"userId": au, "role": "USER"}, 400) + alice.request("GET", "/api/auth/oidc/status", nil, 200) + alice.request("GET", "/api/auth/callback/authelia", nil, 501) + bob.request("DELETE", "/api/pages/"+page, nil, 200) + alice.request("DELETE", "/api/bookmarks/"+id, nil, 200) + anonymous.request("GET", "/api/public/bookmarks/"+id, nil, 404) + alice.request("POST", "/api/auth/signout", map[string]any{}, 200) + alice.request("GET", "/api/me", nil, 401) +} diff --git a/internal/http/integrations_test.go b/internal/http/integrations_test.go new file mode 100644 index 0000000..68a602c --- /dev/null +++ b/internal/http/integrations_test.go @@ -0,0 +1,251 @@ +package http + +import ( + "context" + "encoding/json" + crypt "github.com/andrew/go-computer/internal/crypto" + "github.com/andrew/go-computer/internal/signals" + "github.com/emersion/go-imap/backend/memory" + imapserver "github.com/emersion/go-imap/server" + "io" + "net" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" +) + +func TestExternalIntegrations(t *testing.T) { + srv, d := testServerDB(t) + jar, _ := cookiejar.New(nil) + a := apiClient{t, &http.Client{Jar: jar}, srv.URL} + credentials := map[string]any{"email": "integrations@computer.test", "password": "test-password"} + a.request("POST", "/api/signup", credentials, 201) + a.request("POST", "/api/auth/login", credentials, 200) + // CardDAV fixture (reviews CR-07/CR-08): the served multistatus is + // mutable so one test can walk the reconciliation policy — full snapshot, + // partially-withheld response, remote deletion, empty response. + var davMu sync.Mutex + var davBody string + setBody := func(body string) { davMu.Lock(); davBody = body; davMu.Unlock() } + getBody := func() string { davMu.Lock(); defer davMu.Unlock(); return davBody } + dav := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "REPORT" || r.Header.Get("Depth") != "1" { + t.Error("invalid CardDAV request") + } + u, p, _ := r.BasicAuth() + if u != "nextcloud-user" || p != "app-password" { + t.Error("CardDAV authentication") + } + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(207) + io.WriteString(w, getBody()) + })) + defer dav.Close() + card1 := `/card.vcfBEGIN:VCARD +UID:contact-1 +FN:Contact Name +EMAIL:last@computer.test +EMAIL;TYPE=PREF:preferred@computer.test +TEL:1234 +END:VCARDHTTP/1.1 200 OK` + card2 := `/card2.vcfBEGIN:VCARD +UID:contact-2 +FN:Second Contact +EMAIL:second@computer.test +END:VCARDHTTP/1.1 200 OK` + card2Withheld := `/card2.vcfHTTP/1.1 404 Not Found` + wrap := func(responses ...string) string { + return `` + strings.Join(responses, "") + `` + } + contactsOf := func() []map[string]any { + t.Helper() + res, e := a.c.Get(srv.URL + "/api/contacts") + if e != nil { + t.Fatal(e) + } + defer res.Body.Close() + var contacts []map[string]any + json.NewDecoder(res.Body).Decode(&contacts) + return contacts + } + req := map[string]any{"nextcloudUrl": dav.URL, "username": "nextcloud-user", "password": "app-password"} + + // Full snapshot: both cards import; the PREF occurrence wins the repeated + // EMAIL property (review CR-08 — deterministic selection, no data loss). + setBody(wrap(card1, card2)) + if v := a.request("POST", "/api/contacts/sync", req, 200); v["synced"] != float64(2) || v["removed"] != float64(0) { + t.Fatal("CardDAV sync", v) + } + contacts := contactsOf() + if len(contacts) != 2 { + t.Fatal("CardDAV import count", contacts) + } + for _, c := range contacts { + if c["uid"] == "contact-1" && c["email"] != "preferred@computer.test" { + t.Fatal("repeated EMAIL did not resolve to the PREF value:", c) + } + } + // Idempotent re-sync: nothing removed, nothing duplicated. + if v := a.request("POST", "/api/contacts/sync", req, 200); v["synced"] != float64(2) || v["removed"] != float64(0) { + t.Fatal("CardDAV re-sync", v) + } + if len(contactsOf()) != 2 { + t.Fatal("CardDAV re-sync duplicated contacts") + } + + // Partially-withheld response (card2's data 404s): must NOT be read as a + // remote deletion (review CR-07 — never delete from a partial snapshot). + setBody(wrap(card1, card2Withheld)) + if v := a.request("POST", "/api/contacts/sync", req, 200); v["removed"] != float64(0) { + t.Fatal("partial response triggered reconciliation:", v) + } + if len(contactsOf()) != 2 { + t.Fatal("partial response deleted a contact") + } + + // Complete snapshot without card2: the remote deletion is mirrored. + setBody(wrap(card1)) + if v := a.request("POST", "/api/contacts/sync", req, 200); v["synced"] != float64(1) || v["removed"] != float64(1) { + t.Fatal("CardDAV deletion reconciliation", v) + } + contacts = contactsOf() + if len(contacts) != 1 || contacts[0]["uid"] != "contact-1" { + t.Fatal("wrong contact survived reconciliation:", contacts) + } + + // Empty response: the guard preserves everything (a server fault that + // answers 207 with zero cards must not wipe the address book). + setBody(wrap()) + if v := a.request("POST", "/api/contacts/sync", req, 200); v["synced"] != float64(0) || v["removed"] != float64(0) { + t.Fatal("empty response reconciliation", v) + } + if len(contactsOf()) != 1 { + t.Fatal("empty response deleted contacts") + } + listener, e := net.Listen("tcp", "127.0.0.1:0") + if e != nil { + t.Fatal(e) + } + mail := imapserver.New(memory.New()) + mail.AllowInsecureAuth = true + go mail.Serve(listener) + defer mail.Close() + host, port, _ := net.SplitHostPort(listener.Addr().String()) + pn, _ := strconv.Atoi(port) + mailbox := map[string]any{"host": host, "port": pn, "tls": false, "username": "username", "password": "password", "folder": "INBOX"} + a.request("POST", "/api/imap/test", mailbox, 200) + a.request("POST", "/api/settings/imap", mailbox, 200) + id := a.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/test", "imapQuery": "little"}, 201)["id"].(string) + a.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"from": "contact@example.org", "subject": "little"}}, 201) + v := a.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"action": "check"}, 200) + if v["created"] != float64(1) { + t.Fatal("IMAP signal creation", v) + } + // Review SYNC-02: the pass persisted a UID checkpoint for the source. + var lastUID int64 + if e := d.QueryRow(`SELECT "lastUid" FROM "SignalCheckpoint"`).Scan(&lastUID); e != nil { + t.Fatal("checkpoint not persisted:", e) + } + if lastUID < 1 { + t.Fatal("checkpoint did not advance:", lastUID) + } + v = a.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"action": "check"}, 200) + if v["created"] != float64(0) { + t.Fatal("signal deduplication", v) + } + v = a.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200) + if len(v["signals"].([]any)) != 1 { + t.Fatal("IMAP envelope fetch") + } + putBookmark(a, id, map[string]any{"imapWatchEnabled": true}, 200) + v = a.request("GET", "/api/imap/check", nil, 200) + if len(v["matches"].([]any)) != 1 { + t.Fatal("legacy mailbox watch") + } +} + +// CardDAV transport (review recheck): Basic-auth credentials must never +// travel plaintext. Without the netguard fixture escape hatch, an http +// Nextcloud URL is rejected at the API boundary — no dial, no credentials. +func TestCardDAVRequiresTLSOutsideFixtures(t *testing.T) { + srv := testServer(t) + a := newAPIUser(t, srv, "carddav-tls@computer.test") + t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "") + a.request("POST", "/api/contacts/sync", map[string]any{ + "nextcloudUrl": "http://nextcloud.example.test", "username": "u", "password": "p", + }, 400) + // https passes the transport gate (the dial itself fails: no such host + // through netguard — the point is the 400 above never happens for TLS). + resp := a.request("POST", "/api/contacts/sync", map[string]any{ + "nextcloudUrl": "https://nextcloud.invalid", "username": "u", "password": "p", + }, 400) + if resp == nil { + t.Fatal("expected a connect-level rejection, not a transport-policy one") + } +} + +// Review CR-21: credential writes must never restore a stale password. The +// lazy ENCRYPTION_KEY migration re-encrypts with a compare-and-swap against +// the exact ciphertext it read, and a blank/masked settings save no longer +// rewrites the password column at all. +func TestImapCredentialWritesNeverRestoreStalePasswords(t *testing.T) { + srv, d := testServerDB(t) + alice := newAPIUser(t, srv, "imap-stale@computer.test") + t.Setenv("ENCRYPTION_KEY", "dedicated-encryption-key") + uid := alice.request("GET", "/api/me", nil, 200)["user"].(map[string]any)["id"].(string) + + // Seed a legacy row encrypted under the AUTH secret (pre-ENCRYPTION_KEY), + // exactly the shape the transparent migration expects. + legacy, e := crypt.Encrypt("legacy-password", "integration-test-secret") + if e != nil { + t.Fatal(e) + } + if _, e := d.Exec(`INSERT INTO "ImapConfig"(id,host,port,tls,username,password,folder,"userId") VALUES('imap-cfg-1','legacy.example.test',993,true,'user',$1,'INBOX',$2)`, legacy, uid); e != nil { + t.Fatal(e) + } + + // Loading migrates: the password decrypts and the row is re-encrypted + // under the dedicated key. + m, e := signals.LoadMailbox(context.Background(), d, uid) + if e != nil || m.Password != "legacy-password" { + t.Fatalf("legacy load: %v %+v", e, m) + } + var stored string + if e := d.QueryRow(`SELECT password FROM "ImapConfig" WHERE "userId"=$1`, uid).Scan(&stored); e != nil { + t.Fatal(e) + } + if p, e := crypt.Decrypt(stored, "dedicated-encryption-key"); e != nil || p != "legacy-password" { + t.Fatalf("row was not migrated under ENCRYPTION_KEY: %v", e) + } + + // The CAS guard: an update conditioned on a stale ciphertext touches no + // rows, so a concurrent replacement can never be rolled back by migration. + res, e := d.Exec(`UPDATE "ImapConfig" SET password=$1 WHERE "userId"=$2 AND password=$3`, "stale-rewrite", uid, legacy) + if e != nil { + t.Fatal(e) + } + if n, _ := res.RowsAffected(); n != 0 { + t.Fatal("compare-and-swap against a stale ciphertext updated the row") + } + + // A concurrent replacement through the API survives... + alice.request("POST", "/api/settings/imap", map[string]any{"host": "new.example.test", "port": 993, "tls": true, "username": "user", "password": "replacement-password", "folder": "INBOX"}, 200) + // ...and a later masked save (connection settings only) must keep it. + alice.request("POST", "/api/settings/imap", map[string]any{"host": "masked.example.test", "port": 993, "tls": true, "username": "user", "password": "••••••••", "folder": "INBOX"}, 200) + m, e = signals.LoadMailbox(context.Background(), d, uid) + if e != nil { + t.Fatal(e) + } + if m.Password != "replacement-password" || m.Host != "masked.example.test" { + t.Fatalf("masked save clobbered the stored credentials: %+v", m) + } + + // A masked save with no stored configuration is a 400, never an empty + // or resurrected password. + bob := newAPIUser(t, srv, "imap-nocfg@computer.test") + bob.request("POST", "/api/settings/imap", map[string]any{"host": "h.example.test", "port": 993, "tls": true, "username": "user", "password": "••••••••", "folder": "INBOX"}, 400) +} diff --git a/internal/http/kanban_test.go b/internal/http/kanban_test.go new file mode 100644 index 0000000..acadc37 --- /dev/null +++ b/internal/http/kanban_test.go @@ -0,0 +1,123 @@ +package http + +import "testing" + +// Kanban ordering is persisted, not just a client-side illusion: a card move +// normalizes the target column, keeps legacy status-only cards in place, and +// still honors the bookmark concurrency version (review CR-04). +func TestKanbanCardMovePersistsOrdering(t *testing.T) { + srv := testServer(t) + alice := newAPIUser(t, srv, "kanban-order@computer.test") + cols := alice.getJSON("/api/kanban/columns", 200) + if len(cols) < 2 { + t.Fatalf("expected default columns, got %d", len(cols)) + } + inbox := cols[0]["id"].(string) + todo := cols[1]["id"].(string) + + create := func(title, column string) map[string]any { + return alice.request("POST", "/api/bookmarks", map[string]any{ + "url": "https://example.org/" + title, + "title": title, + "kanbanColumnId": column, + }, 201) + } + first := create("first", inbox) + second := create("second", inbox) + third := create("third", inbox) + + // New cards start newest-first (updatedAt desc), so establish the known + // first/second/third order before asserting a reorder. Third stays at its + // original version for the move below. + for _, step := range []struct { + id string + position int + }{{first["id"].(string), 0}, {second["id"].(string), 1}} { + cur := alice.request("GET", "/api/bookmarks/"+step.id, nil, 200) + alice.request("PUT", "/api/kanban/cards/"+step.id, map[string]any{ + "columnId": inbox, "position": step.position, "version": cur["version"], + }, 200) + } + + // Move the newest card to the top of its own column. + moved := alice.request("PUT", "/api/kanban/cards/"+third["id"].(string), map[string]any{ + "columnId": inbox, "position": 0, "version": 0, + }, 200) + if moved["kanbanPosition"] != float64(0) || moved["version"] != float64(1) { + t.Fatalf("move did not persist position/version: %v", moved) + } + if moved["kanbanColumnId"] != inbox { + t.Fatalf("move lost column: %v", moved) + } + + board := func(column string) []map[string]any { + t.Helper() + out := []map[string]any{} + for _, row := range alice.getJSON("/api/bookmarks", 200) { + if row["kanbanColumnId"] == column { + out = append(out, row) + } + } + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j]["kanbanPosition"].(float64) < out[j-1]["kanbanPosition"].(float64); j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out + } + order := board(inbox) + if len(order) != 3 { + t.Fatalf("expected 3 cards, got %d", len(order)) + } + for i, want := range []string{third["id"].(string), first["id"].(string), second["id"].(string)} { + if order[i]["id"] != want { + t.Fatalf("position %d: got %v, want %s", i, order[i]["id"], want) + } + if order[i]["kanbanPosition"] != float64(i) { + t.Fatalf("card %s position = %v, want %d", order[i]["id"], order[i]["kanbanPosition"], i) + } + } + + // Missing/stale versions are rejected without changing order. + alice.request("PUT", "/api/kanban/cards/"+first["id"].(string), map[string]any{ + "columnId": inbox, "position": 0, + }, 400) + alice.request("PUT", "/api/kanban/cards/"+third["id"].(string), map[string]any{ + "columnId": inbox, "position": 0, "version": 0, + }, 409) + + // Cross-column moves also normalize the target and leave the source intact. + secondNow := alice.request("GET", "/api/bookmarks/"+second["id"].(string), nil, 200) + alice.request("PUT", "/api/kanban/cards/"+second["id"].(string), map[string]any{ + "columnId": todo, "position": 0, "version": secondNow["version"], + }, 200) + if got := board(todo); len(got) != 1 || got[0]["id"] != second["id"] { + t.Fatalf("cross-column move failed: %v", got) + } + if got := board(inbox); len(got) != 2 || got[0]["id"] != third["id"] || got[1]["id"] != first["id"] { + t.Fatalf("source column order changed unexpectedly: %v", got) + } + + // A legacy status-only card joins the target column's explicit ordering. + legacy := alice.request("POST", "/api/bookmarks", map[string]any{ + "url": "https://example.org/legacy", "title": "legacy", "kanbanStatus": "TODO", + }, 201) + alice.request("PUT", "/api/kanban/cards/"+legacy["id"].(string), map[string]any{ + "columnId": todo, "position": 0, "version": 0, + }, 200) + if got := board(todo); len(got) != 2 || got[0]["id"] != legacy["id"] || got[1]["id"] != second["id"] { + t.Fatalf("legacy card did not join explicit ordering: %v", got) + } + if got := alice.request("GET", "/api/bookmarks/"+legacy["id"].(string), nil, 200); got["kanbanColumnId"] != todo { + t.Fatalf("legacy card did not gain explicit membership: %v", got) + } + + // Foreign columns are never accepted. + bob := newAPIUser(t, srv, "kanban-other@computer.test") + bobCols := bob.getJSON("/api/kanban/columns", 200) + foreign := bobCols[0]["id"].(string) + firstNow := alice.request("GET", "/api/bookmarks/"+first["id"].(string), nil, 200) + alice.request("PUT", "/api/kanban/cards/"+first["id"].(string), map[string]any{ + "columnId": foreign, "position": 0, "version": firstNow["version"], + }, 400) +} diff --git a/internal/http/limits_search_test.go b/internal/http/limits_search_test.go new file mode 100644 index 0000000..6af7f0c --- /dev/null +++ b/internal/http/limits_search_test.go @@ -0,0 +1,73 @@ +package http + +import ( + "net/url" + "strings" + "testing" +) + +// Review PERF-2: page search treats % and _ literally and is backed by the +// trigram indexes introduced alongside the escaping change. +func TestPageSearchEscapesWildcards(t *testing.T) { + srv, d := testServerDB(t) + alice, _ := accountClient(t, srv.URL, "pagesearch@computer.test") + other, _ := accountClient(t, srv.URL, "pagesearch-other@computer.test") + + alice.request("POST", "/api/pages", map[string]any{"title": "100% done", "content": "literal percent"}, 201) + alice.request("POST", "/api/pages", map[string]any{"title": "100x done", "content": "not a percent"}, 201) + alice.request("POST", "/api/pages", map[string]any{"title": "snake_case", "content": "underscore"}, 201) + alice.request("POST", "/api/pages", map[string]any{"title": "snakeXcase", "content": "not an underscore"}, 201) + other.request("POST", "/api/pages", map[string]any{"title": "100% private", "content": "someone else"}, 201) + + search := func(term string) []map[string]any { + return alice.getJSON("/api/pages?search="+url.QueryEscape(term), 200) + } + if got := search("100%"); len(got) != 1 || got[0]["title"] != "100% done" { + t.Fatalf("literal %% search returned %v", got) + } + if got := search("snake_"); len(got) != 1 || got[0]["title"] != "snake_case" { + t.Fatalf("literal _ search returned %v", got) + } + if got := search("100x"); len(got) != 1 || got[0]["title"] != "100x done" { + t.Fatalf("plain substring search returned %v", got) + } + + for _, idx := range []string{"Page_title_trgm_idx", "Page_content_trgm_idx"} { + var n int + if e := d.QueryRow(`SELECT count(*) FROM pg_indexes WHERE schemaname=current_schema() AND indexname=$1`, idx).Scan(&n); e != nil || n != 1 { + t.Fatalf("missing page search index %s: n=%d err=%v", idx, n, e) + } + } +} + +// Review "cap input sizes": per-field limits reject absurd values with a 400 +// instead of persisting them (the 1 MiB body cap alone is too coarse). +func TestTextFieldSizeLimits(t *testing.T) { + srv := testServer(t) + alice, _ := accountClient(t, srv.URL, "limits@computer.test") + + const ( + urlLimit = 2048 + titleLimit = 500 + tagNameLimit = 80 + contentLimit = 512 << 10 + ) + alice.request("POST", "/api/bookmarks", map[string]any{ + "url": "https://example.org/" + strings.Repeat("u", urlLimit), + "title": "too long", + }, 400) + alice.request("POST", "/api/bookmarks", map[string]any{ + "url": "https://example.org/ok", + "title": strings.Repeat("t", titleLimit+1), + }, 400) + alice.request("POST", "/api/tags", map[string]any{"name": strings.Repeat("n", tagNameLimit+1)}, 400) + alice.request("POST", "/api/pages", map[string]any{ + "title": "huge", + "content": strings.Repeat("x", contentLimit+1), + }, 400) + + // The boundary itself remains accepted. + if got := alice.request("POST", "/api/tags", map[string]any{"name": strings.Repeat("n", tagNameLimit)}, 201); got["name"] != strings.Repeat("n", tagNameLimit) { + t.Fatal("boundary-length tag rejected or altered") + } +} diff --git a/internal/http/migration_test.go b/internal/http/migration_test.go new file mode 100644 index 0000000..88159fb --- /dev/null +++ b/internal/http/migration_test.go @@ -0,0 +1,68 @@ +package http + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// The ledgered migrations (review MIG-01) must stay idempotent: applying +// every file twice in order cannot change the backfill guarantees. +func TestUpgradeBackfillsLegacyWatchesWithoutChangingExistingSources(t *testing.T) { + srv, d := testServerDB(t) + _, uid := accountClient(t, srv.URL, "watcher@computer.test") + if _, err := d.Exec(`INSERT INTO "Bookmark"(id,url,"ownerId","imapWatchEnabled","imapQuery") VALUES('old','https://127.0.0.1',$1,true,'Invoices'),('paused','https://127.0.0.1',$1,true,'Receipts'),('off','https://127.0.0.1',$1,false,'Other')`, uid); err != nil { + t.Fatal(err) + } + if _, err := d.Exec(`INSERT INTO "SignalSource"(id,type,config,enabled,"bookmarkId","ownerId") VALUES('modern','email','{"subject":"Modern"}',true,'old',$1),('existing','email','{"legacy":true,"subject":"Paused"}',false,'paused',$1)`, uid); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir("../../db/migrations") + if err != nil { + t.Fatal(err) + } + names := []string{} + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".sql") { + names = append(names, e.Name()) + } + } + sort.Strings(names) + if len(names) == 0 { + t.Fatal("no migration files found") + } + for i := 0; i < 2; i++ { + for _, name := range names { + raw, err := os.ReadFile(filepath.Join("../../db/migrations", name)) + if err != nil { + t.Fatal(err) + } + if _, err := d.Exec(string(raw)); err != nil { + t.Fatalf("%s: %v", name, err) + } + } + } + var count int + if err := d.QueryRow(`SELECT count(*) FROM "SignalSource"`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 3 { + t.Fatalf("expected existing two sources and one backfill, got %d", count) + } + var enabled bool + var subject string + if err := d.QueryRow(`SELECT enabled,config->>'subject' FROM "SignalSource" WHERE id='legacy-old'`).Scan(&enabled, &subject); err != nil { + t.Fatal(err) + } + if !enabled || subject != "Invoices" { + t.Fatal("legacy switch not migrated") + } + if err := d.QueryRow(`SELECT enabled,config->>'subject' FROM "SignalSource" WHERE id='existing'`).Scan(&enabled, &subject); err != nil { + t.Fatal(err) + } + if enabled || subject != "Paused" { + t.Fatal("existing paused watcher modified") + } +} diff --git a/internal/http/pagination_test.go b/internal/http/pagination_test.go new file mode 100644 index 0000000..c06ba95 --- /dev/null +++ b/internal/http/pagination_test.go @@ -0,0 +1,63 @@ +package http + +import ( + "encoding/json" + "fmt" + "net/url" + "testing" +) + +func TestBookmarkPaginationPreservesCollectionAndOwnership(t *testing.T) { + srv, d := testServerDB(t) + owner, uid := accountClient(t, srv.URL, "owner@computer.test") + other, oid := accountClient(t, srv.URL, "other@computer.test") + for _, user := range []string{uid, oid} { + if _, err := d.Exec(`INSERT INTO "Bookmark"(id,url,title,"ownerId","updatedAt") SELECT $1||'-'||lpad(n::text,4,'0'),'https://127.0.0.1/test','Match '||n,$1,'2026-10-07T12:00:00Z' FROM generate_series(1,205) n`, user); err != nil { + t.Fatal(err) + } + } + for _, c := range []apiClient{owner, other} { + seen := map[string]bool{} + cursor := "" + for { + res, err := c.c.Get(srv.URL + "/api/bookmarks?limit=100&search=Match&cursor=" + url.QueryEscape(cursor)) + if err != nil { + t.Fatal(err) + } + var records []map[string]any + if err := json.NewDecoder(res.Body).Decode(&records); err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != 200 || len(records) > 100 { + t.Fatal("invalid page", res.StatusCode, len(records)) + } + for _, row := range records { + id := row["id"].(string) + if seen[id] { + t.Fatal("duplicate bookmark across pages", id) + } + seen[id] = true + } + cursor = res.Header.Get("X-Next-Cursor") + if cursor == "" { + break + } + } + if len(seen) != 205 { + t.Fatalf("wanted 205 bookmarks, got %d", len(seen)) + } + expected := uid + if c.c == other.c { + expected = oid + } + for i := 1; i <= 205; i++ { + if !seen[fmt.Sprintf("%s-%04d", expected, i)] { + t.Fatal("bookmark missing or foreign collection included") + } + } + } + owner.request("GET", "/api/bookmarks?limit=201", nil, 400) + owner.request("GET", "/api/bookmarks?limit=0", nil, 400) + owner.request("GET", "/api/bookmarks?cursor=invalid", nil, 400) +} diff --git a/internal/http/security_test.go b/internal/http/security_test.go new file mode 100644 index 0000000..b3cd0f8 --- /dev/null +++ b/internal/http/security_test.go @@ -0,0 +1,256 @@ +package http + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "github.com/andrew/go-computer/internal/scripts" + "net/http" + "net/http/cookiejar" + "os" + "strconv" + "testing" + "time" +) + +func TestConfiguredAdminCannotBeBypassedDuringBootstrap(t *testing.T) { + srv, d := testServerDB(t) + t.Setenv("ADMIN_EMAILS", "operator@computer.test") + visitor, _ := accountClient(t, srv.URL, "visitor@computer.test") + if visitor.request("GET", "/api/me", nil, 200)["isAdmin"] != false { + t.Fatal("public signup bypassed reserved administrator bootstrap") + } + visitor.request("GET", "/api/admin/users", nil, 403) + t.Setenv("SEED_EMAIL", "operator@computer.test") + t.Setenv("SEED_PASSWORD", "operator-test-password") + if err := scripts.Seed(context.Background(), d); err != nil { + t.Fatal(err) + } + visitor.request("POST", "/api/auth/login", map[string]any{"email": "operator@computer.test", "password": "operator-test-password"}, 200) + if visitor.request("GET", "/api/me", nil, 200)["isAdmin"] != true { + t.Fatal("operator-provisioned account is not admin") + } + t.Setenv("SEED_PASSWORD", "different-password") + if err := scripts.Seed(context.Background(), d); err != nil { + t.Fatal(err) + } + visitor.request("POST", "/api/auth/login", map[string]any{"email": "operator@computer.test", "password": "operator-test-password"}, 200) +} + +func accountClient(t *testing.T, base, email string) (apiClient, string) { + t.Helper() + jar, _ := cookiejar.New(nil) + c := apiClient{t, &http.Client{Jar: jar}, base} + body := map[string]any{"email": email, "password": "test-long-password"} + c.request("POST", "/api/signup", body, 201) + c.request("POST", "/api/auth/login", body, 200) + // Review CR-03: the signup body no longer carries the id; identity comes + // from the authenticated session. + return c, meID(c) +} + +func TestAdminEnrollmentRequiresTrustedIdentity(t *testing.T) { + srv, d := testServerDB(t) + admin, _ := accountClient(t, srv.URL, "admin@computer.test") + user, uid := accountClient(t, srv.URL, "unverified@computer.test") + t.Setenv("ADMIN_EMAILS", "reserved@computer.test,unverified@computer.test") + // Reserved addresses answer exactly like existing accounts (no 403 oracle). + // Review SEC-4/CR-03: operator-reserved addresses return the SAME uniform + // 201 {"ok":true} body as every other accepted registration — no userId + // field, no 409. + reserved := user.request("POST", "/api/signup", map[string]any{"email": "reserved@computer.test", "password": "test-password"}, 201) + if len(reserved) != 1 || reserved["ok"] != true { + t.Fatalf("operator-reserved address body is distinguishable: %v", reserved) + } + if user.request("GET", "/api/me", nil, 200)["isAdmin"] != false { + t.Fatal("unverified address promoted to admin") + } + user.request("GET", "/api/admin/users", nil, 403) + if _, err := d.Exec(`UPDATE "User" SET "emailVerified"=now() WHERE id=$1`, uid); err != nil { + t.Fatal(err) + } + if user.request("GET", "/api/me", nil, 200)["isAdmin"] != true { + t.Fatal("verified allowlisted address was not promoted") + } + admin.request("GET", "/api/admin/users", nil, 200) +} + +func TestPublicBookmarksDoNotExposePrivateMetadata(t *testing.T) { + srv, d := testServerDB(t) + owner, uid := accountClient(t, srv.URL, "owner@computer.test") + viewer, vid := accountClient(t, srv.URL, "viewer@computer.test") + if _, err := d.Exec(`INSERT INTO "Contact"(id,uid,"displayName",email,phone,"userId") VALUES('contact','vcard','Private','private@computer.test','555-private',$1)`, uid); err != nil { + t.Fatal(err) + } + id := owner.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/test", "isPublic": true, "contactId": "contact", "imapQuery": "private mailbox query", "alertAt": "2026-10-07"}, 201)["id"].(string) + v := viewer.request("GET", "/api/public/bookmarks/"+id, nil, 200) + // Review PRIV-04: public links carry no owner attribution at all; the + // README promises owner identity stays private. + for _, key := range []string{"contact", "contactId", "sharedWith", "owner", "ownerId", "imapQuery", "imapWatchEnabled", "alertAt", "alertSent", "kanbanColumnId", "kanbanPosition", "pageLinks"} { + if _, exists := v[key]; exists { + t.Errorf("public response includes %s", key) + } + } + viewer.request("GET", "/api/bookmarks/"+id, nil, 404) + owner.request("POST", "/api/bookmarks/"+id+"/share", map[string]any{"userIds": []string{vid}}, 200) + // Review PRIV-02: an authenticated shared reader gets the narrowed + // projection — no contact record, no recipient list, no owner operational + // metadata — while the owner keeps the full view. + shared := viewer.request("GET", "/api/bookmarks/"+id, nil, 200) + for _, key := range []string{"contact", "contactId", "sharedWith", "imapQuery", "imapWatchEnabled", "alertAt", "alertSent", "kanbanColumnId", "kanbanPosition"} { + if _, exists := shared[key]; exists { + t.Errorf("shared-reader response includes %s", key) + } + } + if shared["url"] != "https://127.0.0.1/test" || shared["ownerId"] != uid { + t.Error("shared-reader response lost basic bookmark fields") + } + if _, exists := shared["tags"]; !exists { + t.Error("shared-reader response lost tags") + } + full := owner.request("GET", "/api/bookmarks/"+id, nil, 200) + if c, _ := full["contact"].(map[string]any); c == nil || c["email"] != "private@computer.test" { + t.Error("owner projection lost the contact record") + } + if s, ok := full["sharedWith"].([]any); !ok || len(s) != 1 { + t.Error("owner projection lost the recipient list") + } + if _, exists := full["imapQuery"]; !exists { + t.Error("owner projection lost mailbox-watch settings") + } + // The list endpoint must apply the same permission-specific projections. + res, err := viewer.c.Get(srv.URL + "/api/bookmarks?shared=true") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + var list []map[string]any + if err := json.NewDecoder(res.Body).Decode(&list); err != nil { + t.Fatal(err) + } + if len(list) != 1 || list[0]["id"] != id { + t.Fatalf("shared list returned %d entries", len(list)) + } + for _, key := range []string{"contact", "sharedWith", "imapQuery", "alertAt"} { + if _, exists := list[0][key]; exists { + t.Errorf("shared list response includes %s", key) + } + } + putBookmark(owner, id, map[string]any{"isPublic": false}, 200) + viewer.request("GET", "/api/public/bookmarks/"+id, nil, 404) +} + +func TestAdminBootstrapIsOneShot(t *testing.T) { + srv, d := testServerDB(t) + // Fresh installation: the first signup completes bootstrap as admin. + admin, adminID := accountClient(t, srv.URL, "first@computer.test") + if admin.request("GET", "/api/me", nil, 200)["isAdmin"] != true { + t.Fatal("first signup on a fresh installation was not admin") + } + // Review AUTH-06 acceptance: demoting the last administrator must not + // re-open administrator assignment through signup. + if _, err := d.Exec(`UPDATE "User" SET role='USER' WHERE id=$1`, adminID); err != nil { + t.Fatal(err) + } + latecomer, _ := accountClient(t, srv.URL, "late@computer.test") + if latecomer.request("GET", "/api/me", nil, 200)["isAdmin"] != false { + t.Fatal("signup re-opened admin bootstrap after the last admin was demoted") + } + // Removing every administrator entirely does not re-open it either. + if _, err := d.Exec(`DELETE FROM "User" WHERE id=$1`, adminID); err != nil { + t.Fatal(err) + } + successor, _ := accountClient(t, srv.URL, "successor@computer.test") + if successor.request("GET", "/api/me", nil, 200)["isAdmin"] != false { + t.Fatal("signup re-opened admin bootstrap after the last admin was removed") + } + successor.request("GET", "/api/admin/users", nil, 403) +} + +// Review SEC-5: with ALLOW_BOOTSTRAP_SIGNUP=0 a fresh installation must not +// hand administrator to the first signup — provisioning stays operator-driven. +func TestBootstrapSignupCanBeDisabled(t *testing.T) { + srv := testServer(t) + t.Setenv("ALLOW_BOOTSTRAP_SIGNUP", "0") + visitor, _ := accountClient(t, srv.URL, "unclaimed@computer.test") + if visitor.request("GET", "/api/me", nil, 200)["isAdmin"] != false { + t.Fatal("first signup became admin despite ALLOW_BOOTSTRAP_SIGNUP=0") + } + visitor.request("GET", "/api/admin/users", nil, 403) +} + +func TestDirectLoginRejectsBrowserForgery(t *testing.T) { + srv := testServer(t) + for _, tc := range []struct { + contentType, origin string + status int + }{ + {"text/plain", "https://attacker.invalid", 415}, + {"application/json", "https://attacker.invalid", 403}, + } { + req, _ := http.NewRequest("POST", srv.URL+"/api/auth/login", bytes.NewBufferString(`{"email":"attacker@computer.test","password":"password"}`)) + req.Header.Set("Content-Type", tc.contentType) + req.Header.Set("Origin", tc.origin) + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != tc.status || len(res.Cookies()) != 0 { + t.Fatalf("forged login: status=%d cookies=%v", res.StatusCode, res.Cookies()) + } + } +} + +// Review SEC-1/SEC-2: the failure lockout keys on the proxy-attested browser +// IP. Two different signed identities get independent lockout buckets; a +// replayed (stale-timestamp) signature is not honored. +func TestLoginLockoutHonorsSignedForwardedIdentity(t *testing.T) { + srv := testServer(t) + secret := os.Getenv("AUTH_SECRET") + sign := func(ip string, ts int64) (string, string, string) { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(ip + "|" + strconv.FormatInt(ts, 10))) + return ip, strconv.FormatInt(ts, 10), hex.EncodeToString(mac.Sum(nil)) + } + post := func(email, password, ip string, ts int64) int { + body, _ := json.Marshal(map[string]any{"email": email, "password": password}) + req, _ := http.NewRequest("POST", srv.URL+"/api/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if ip != "" { + hip, hts, sig := sign(ip, ts) + req.Header.Set("X-Computer-Client-Ip", hip) + req.Header.Set("X-Computer-Client-Ts", hts) + req.Header.Set("X-Computer-Client-Sig", sig) + } + res, e := http.DefaultClient.Do(req) + if e != nil { + t.Fatal(e) + } + res.Body.Close() + return res.StatusCode + } + now := time.Now().Unix() + // Browser A: five failures lock out its (IP, email) bucket. + for i := 0; i < 5; i++ { // failureThreshold + if code := post("victim-a@computer.test", "wrong-password", "198.51.100.1", now); code != 401 { + t.Fatalf("browser A attempt %d: %d", i+1, code) + } + } + if code := post("victim-a@computer.test", "wrong-password", "198.51.100.1", now); code != 429 { + t.Fatalf("browser A not locked out: %d", code) + } + // Browser B behind the same proxy peer keeps its own buckets. + if code := post("victim-a@computer.test", "wrong-password", "198.51.100.2", now); code != 401 { + t.Fatalf("browser B locked out by browser A's failures: %d", code) + } + // A stale signature (outside the ~60s window) falls back to the peer, so + // it cannot smuggle a fresh identity: these attempts count against the + // peer bucket and stay 401. + if code := post("victim-b@computer.test", "wrong-password", "198.51.100.9", now-3600); code != 401 { + t.Fatalf("stale signature changed behavior: %d", code) + } +} diff --git a/internal/http/server.go b/internal/http/server.go new file mode 100644 index 0000000..29b1129 --- /dev/null +++ b/internal/http/server.go @@ -0,0 +1,181 @@ +package http + +import ( + "context" + "net/http" + "os" + "time" + + "github.com/andrew/go-computer/internal/auth" + "github.com/andrew/go-computer/internal/authguard" + "github.com/andrew/go-computer/internal/config" + "github.com/andrew/go-computer/internal/db" + "github.com/andrew/go-computer/internal/handlers" + "github.com/andrew/go-computer/internal/oidc" +) + +// NewServer builds the HTTP server with all routes registered. +// Porting guide section 4 — every data endpoint resolves the effective user, +// returns 401 if none, checks ownership, returns 404 if not theirs, then acts. +// +// All routes return JSON unless marked .md. Errors are {error: string}. +func NewServer(cfg *config.Config, database *db.DB) *http.Server { + mux := http.NewServeMux() + + // Health / readiness. No auth, no DB. + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + }) + mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + if err := database.PingContext(ctx); err != nil { + handlers.WriteJSON(w, 503, handlers.Error{Error: "Database unavailable"}) + return + } + handlers.WriteJSON(w, 200, map[string]string{"status": "ready"}) + }) + + // 4.1 Auth and account (rate limited for non-loopback peers) + mux.HandleFunc("POST /api/signup", auth.RateLimitSignup(auth.HandleSignup)) + mux.HandleFunc("POST /api/auth/login", auth.RateLimitLogin(auth.HandleLogin)) + mux.HandleFunc("GET /api/auth/", auth.HandleAuth) // csrf, callbacks, session, signout + mux.HandleFunc("POST /api/auth/", auth.HandleAuth) + mux.HandleFunc("GET /api/me", authed(auth.HandleMe)) + + // Authelia OIDC (stub-aware; the client drives the flow via NextAuth) + mux.HandleFunc("GET /api/auth/oidc/status", oidc.HandleStatus) + mux.HandleFunc("GET /api/auth/oidc/login", oidc.HandleLogin) + mux.HandleFunc("GET /api/auth/callback/authelia", oidc.HandleCallback) + mux.HandleFunc("POST /api/auth/callback/authelia", oidc.HandleCallback) + + // 4.2 Admin (use real user; 403 unless ADMIN) + mux.HandleFunc("GET /api/admin/users", admin(authguard.HandleAdminListUsers)) + mux.HandleFunc("PATCH /api/admin/users", admin(authguard.HandleAdminPatchUser)) + mux.HandleFunc("POST /api/admin/view-as", admin(authguard.HandleAdminViewAs)) + + // 4.3 Bookmarks + mux.HandleFunc("GET /api/bookmarks", authed(handlers.HandleBookmarksList)) + mux.HandleFunc("POST /api/bookmarks", authed(handlers.HandleBookmarksCreate)) + mux.HandleFunc("GET /api/bookmarks/{id}", authed(handlers.HandleBookmarksGet)) + mux.HandleFunc("PUT /api/bookmarks/{id}", authed(handlers.HandleBookmarksUpdate)) + mux.HandleFunc("DELETE /api/bookmarks/{id}", authed(handlers.HandleBookmarksDelete)) + mux.HandleFunc("POST /api/bookmarks/{id}/share", authed(handlers.HandleBookmarksShare)) + mux.HandleFunc("DELETE /api/bookmarks/{id}/share/{userId}", authed(handlers.HandleBookmarkUnshare)) + mux.HandleFunc("GET /api/bookmarks/{id}/markdown", authed(handlers.HandleBookmarksMarkdown)) + + // 4.4 Tags + mux.HandleFunc("GET /api/tags", authed(handlers.HandleTagsList)) + mux.HandleFunc("POST /api/tags", authed(handlers.HandleTagsCreate)) + mux.HandleFunc("PUT /api/tags/{id}", authed(handlers.HandleTagsUpdate)) + mux.HandleFunc("DELETE /api/tags/{id}", authed(handlers.HandleTagsDelete)) + mux.HandleFunc("DELETE /api/tags/{id}/share/{userId}", authed(handlers.HandleTagUnshare)) + + // 4.5 Kanban columns + mux.HandleFunc("GET /api/kanban/columns", authed(handlers.HandleKanbanColumnsList)) + mux.HandleFunc("POST /api/kanban/columns", authed(handlers.HandleKanbanColumnsCreate)) + mux.HandleFunc("PUT /api/kanban/columns", authed(handlers.HandleKanbanColumnsReorder)) + mux.HandleFunc("PATCH /api/kanban/columns/{id}", authed(handlers.HandleKanbanColumnsUpdate)) + mux.HandleFunc("DELETE /api/kanban/columns/{id}", authed(handlers.HandleKanbanColumnsDelete)) + mux.HandleFunc("PUT /api/kanban/cards/{id}", authed(handlers.HandleKanbanCardsMove)) + + // 4.6 Pages + mux.HandleFunc("GET /api/pages", authed(handlers.HandlePagesList)) + mux.HandleFunc("POST /api/pages", authed(handlers.HandlePagesCreate)) + mux.HandleFunc("PUT /api/pages", authed(handlers.HandlePagesReorder)) + mux.HandleFunc("GET /api/pages/{id}", authed(handlers.HandlePagesGet)) + mux.HandleFunc("PATCH /api/pages/{id}", authed(handlers.HandlePagesUpdate)) + mux.HandleFunc("DELETE /api/pages/{id}", authed(handlers.HandlePagesDelete)) + mux.HandleFunc("GET /api/pages/{id}/markdown", authed(handlers.HandlePagesMarkdown)) + + // 4.7 Signals + mux.HandleFunc("GET /api/bookmarks/{id}/signals", authed(handlers.HandleSignalsList)) + mux.HandleFunc("POST /api/bookmarks/{id}/signals", authed(handlers.HandleSignalsCreate)) + mux.HandleFunc("PATCH /api/signal-sources/{id}", authed(handlers.HandleSignalSourcesUpdate)) + mux.HandleFunc("DELETE /api/signal-sources/{id}", authed(handlers.HandleSignalSourcesDelete)) + + // 4.8 Alerts, contacts, email, dates, users + mux.HandleFunc("GET /api/alerts/check", authed(handlers.HandleAlertsCheck)) + mux.HandleFunc("DELETE /api/alerts/{bookmarkId}", authed(handlers.HandleAlertsDismiss)) + mux.HandleFunc("GET /api/contacts", authed(handlers.HandleContactsList)) + mux.HandleFunc("POST /api/contacts/sync", authed(handlers.HandleContactsSync)) + mux.HandleFunc("GET /api/settings/imap", authed(handlers.HandleImapGet)) + mux.HandleFunc("POST /api/settings/imap", authed(handlers.HandleImapSave)) + mux.HandleFunc("POST /api/imap/test", authed(handlers.HandleImapTest)) + mux.HandleFunc("GET /api/imap/check", authed(handlers.HandleImapCheck)) + mux.HandleFunc("GET /api/shared-dates", authed(handlers.HandleSharedDatesList)) + mux.HandleFunc("POST /api/shared-dates", authed(handlers.HandleSharedDatesCreate)) + mux.HandleFunc("DELETE /api/shared-dates/{id}", authed(handlers.HandleSharedDateDelete)) + mux.HandleFunc("GET /api/users/search", authed(handlers.HandleUsersSearch)) + + // 4.9 Collections (Notion-like upgrades phase 2). Owner-scoped in this + // phase; membership never broadens bookmark access (N-08). + mux.HandleFunc("GET /api/collections", authed(handlers.HandleCollectionsList)) + mux.HandleFunc("POST /api/collections", authed(handlers.HandleCollectionsCreate)) + mux.HandleFunc("GET /api/collections/{id}", authed(handlers.HandleCollectionsGet)) + mux.HandleFunc("PATCH /api/collections/{id}", authed(handlers.HandleCollectionsUpdate)) + mux.HandleFunc("DELETE /api/collections/{id}", authed(handlers.HandleCollectionsDelete)) + mux.HandleFunc("POST /api/collections/{id}/properties", authed(handlers.HandleCollectionPropertiesCreate)) + mux.HandleFunc("PATCH /api/collections/{id}/properties/{propertyId}", authed(handlers.HandleCollectionPropertiesUpdate)) + mux.HandleFunc("DELETE /api/collections/{id}/properties/{propertyId}", authed(handlers.HandleCollectionPropertiesDelete)) + mux.HandleFunc("GET /api/collections/{id}/items", authed(handlers.HandleCollectionItemsList)) + mux.HandleFunc("POST /api/collections/{id}/items", authed(handlers.HandleCollectionItemsCreate)) + mux.HandleFunc("PUT /api/collections/{id}/items", authed(handlers.HandleCollectionItemsReorder)) + mux.HandleFunc("PATCH /api/collections/{id}/items/{itemId}", authed(handlers.HandleCollectionItemsUpdate)) + mux.HandleFunc("DELETE /api/collections/{id}/items/{itemId}", authed(handlers.HandleCollectionItemsDelete)) + mux.HandleFunc("POST /api/collections/{id}/views", authed(handlers.HandleCollectionViewsCreate)) + mux.HandleFunc("PATCH /api/collections/{id}/views/{viewId}", authed(handlers.HandleCollectionViewsUpdate)) + mux.HandleFunc("DELETE /api/collections/{id}/views/{viewId}", authed(handlers.HandleCollectionViewsDelete)) + + // 4.10 Public page (no login) + // Review SEC-10: public endpoints get per-IP rate limiting (no auth + // context, so no per-user limit). 30 req/min for public bookmark lookup, + // 20 req/min for preview (heavier outbound work). + publicBookmarkLimiter := auth.NewLimiterStore() + previewLimiter := auth.NewLimiterStore() + publicLimit := auth.Limiter(publicBookmarkLimiter, 30, time.Minute) + previewLimit := auth.Limiter(previewLimiter, 20, time.Minute) + mux.HandleFunc("GET /share/bookmark/{id}", publicLimit(handlers.HandlePublicBookmark)) + mux.HandleFunc("GET /api/public/bookmarks/{id}", publicLimit(handlers.HandlePublicBookmark)) + mux.HandleFunc("GET /api/preview", previewLimit(authed(handlers.HandlePreview))) + + return &http.Server{ + Handler: withDB(database, mux), + Addr: ":" + port(), + ReadTimeout: 15 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 60 * time.Second, + } +} + +// withDB carries the database into request context so handlers can pull it via +// db.FromContext. This keeps the DB out of global state (testable, swappable). +func withDB(database *db.DB, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := db.NewContext(r.Context(), database) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// authed wraps a data route with session authentication (401 if no valid +// session). All data endpoints follow: resolve effective user -> 401 -> +// ownership -> 404 -> act (porting guide 5.1). +func authed(fn http.HandlerFunc) http.HandlerFunc { + return auth.AuthMiddleware(http.HandlerFunc(fn)).ServeHTTP +} + +// admin wraps a route with auth + admin-only (403 unless ADMIN). +// AuthMiddleware runs first (attaches the session), then RequireAdmin checks it. +func admin(fn http.HandlerFunc) http.HandlerFunc { + return auth.AuthMiddleware(auth.RequireAdmin(http.HandlerFunc(fn))).ServeHTTP +} + +// port returns the listen port from PORT env, defaulting to 8080. +func port() string { + if p := os.Getenv("PORT"); p != "" { + return p + } + return "8080" +} diff --git a/internal/http/sharing_test.go b/internal/http/sharing_test.go new file mode 100644 index 0000000..289870a --- /dev/null +++ b/internal/http/sharing_test.go @@ -0,0 +1,68 @@ +package http + +import ( + "encoding/json" + "net/http" + "net/http/cookiejar" + "testing" +) + +func TestRevocationAndForeignRelations(t *testing.T) { + srv := testServer(t) + newClient := func() apiClient { jar, _ := cookiejar.New(nil); return apiClient{t, &http.Client{Jar: jar}, srv.URL} } + a, b := newClient(), newClient() + account := func(c apiClient, email string) string { + body := map[string]any{"email": email, "password": "test-password"} + c.request("POST", "/api/signup", body, 201) + c.request("POST", "/api/auth/login", body, 200) + // Review CR-03: the id comes from the authenticated session, not the + // uniform signup body. + return meID(c) + } + account(a, "owner@computer.test") + bid := account(b, "recipient@computer.test") + tag := a.request("POST", "/api/tags", map[string]any{"name": "Shared"}, 201)["id"].(string) + id := a.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/test", "tagIds": []string{tag}, "dueDate": "2026-10-07"}, 201)["id"].(string) + a.request("POST", "/api/bookmarks/"+id+"/share", map[string]any{"userIds": []string{bid}}, 200) + b.request("GET", "/api/bookmarks/"+id, nil, 200) + b.request("DELETE", "/api/bookmarks/"+id+"/share/"+bid, nil, 404) + a.request("DELETE", "/api/bookmarks/"+id+"/share/"+bid, nil, 200) + b.request("GET", "/api/bookmarks/"+id, nil, 404) + a.request("PUT", "/api/tags/"+tag, map[string]any{"shareUserIds": []string{bid}}, 200) + b.request("GET", "/api/bookmarks/"+id, nil, 200) + a.request("DELETE", "/api/tags/"+tag+"/share/"+bid, nil, 200) + b.request("GET", "/api/bookmarks/"+id, nil, 404) + // Unsharing a tag must remove every discovery surface, not just the + // detail path (review recheck): list and search share the single + // BookmarkVisible predicate, so prove it holds end-to-end. + for _, q := range []string{"", "?search=127.0.0.1"} { + var list []map[string]any + res, e := b.c.Get(srv.URL + "/api/bookmarks" + q) + if e != nil { + t.Fatal(e) + } + json.NewDecoder(res.Body).Decode(&list) + res.Body.Close() + for _, item := range list { + if item["id"] == id { + t.Fatalf("revoked bookmark still visible in list %q", q) + } + } + } + a.request("POST", "/api/shared-dates", map[string]any{"date": "2026-10-07", "recipientIds": []string{bid}}, 201) + response, e := a.c.Get(srv.URL + "/api/shared-dates") + if e != nil { + t.Fatal(e) + } + var dates []map[string]any + if e = json.NewDecoder(response.Body).Decode(&dates); e != nil { + t.Fatal(e) + } + response.Body.Close() + b.request("GET", "/api/bookmarks/"+id, nil, 200) + a.request("DELETE", "/api/shared-dates/"+dates[0]["id"].(string), nil, 200) + b.request("GET", "/api/bookmarks/"+id, nil, 404) + foreign := b.request("POST", "/api/kanban/columns", map[string]any{"label": "Private"}, 201)["id"].(string) + putBookmark(a, id, map[string]any{"kanbanColumnId": foreign}, 400) + putBookmark(a, id, map[string]any{"dueDate": map[string]any{}}, 400) +} diff --git a/internal/kanban/kanban.go b/internal/kanban/kanban.go new file mode 100644 index 0000000..228d789 --- /dev/null +++ b/internal/kanban/kanban.go @@ -0,0 +1,59 @@ +package kanban + +import ( + "context" + "database/sql" + "github.com/andrew/go-computer/internal/db" +) + +var DefaultColumns = []struct{ Label, Color, Key string }{{"Inbox", "#64748b", "INBOX"}, {"To Do", "#3b82f6", "TODO"}, {"In Progress", "#f59e0b", "IN_PROGRESS"}, {"Done", "#22c55e", "DONE"}} + +// GetColumns seeds the default board on first use. The common case (columns +// already exist) is a plain read — the user-row lock and seeding transaction +// are only taken when the count is zero, and the count is re-checked inside +// the transaction so concurrent first loads cannot double-seed. +func GetColumns(ctx context.Context, d *db.DB, userID string) ([]db.Object, error) { + var existing int + if err := d.QueryRowContext(ctx, `SELECT count(*) FROM "KanbanColumn" WHERE "userId"=$1`, userID).Scan(&existing); err != nil { + return nil, err + } + if existing == 0 { + err := db.Transaction(ctx, d, func(tx *sql.Tx) error { + if _, e := tx.ExecContext(ctx, `SELECT id FROM "User" WHERE id=$1 FOR UPDATE`, userID); e != nil { + return e + } + var n int + if e := tx.QueryRowContext(ctx, `SELECT count(*) FROM "KanbanColumn" WHERE "userId"=$1`, userID).Scan(&n); e != nil { + return e + } + if n == 0 { + for i, c := range DefaultColumns { + if _, e := db.Insert(ctx, tx, "KanbanColumn", db.Object{"id": db.NewID(), "userId": userID, "position": i, "label": c.Label, "color": c.Color, "key": c.Key}); e != nil { + return e + } + } + } + return nil + }) + if err != nil { + return nil, err + } + } + return db.Many(ctx, d, `SELECT to_jsonb(c) FROM "KanbanColumn" c WHERE "userId"=$1 ORDER BY position,id`, userID) +} +func ResolveColumnID(b db.Object, columns []db.Object) string { + for _, c := range columns { + if b["kanbanColumnId"] == c["id"] { + return c["id"].(string) + } + } + for _, c := range columns { + if c["key"] != nil && c["key"] == b["kanbanStatus"] { + return c["id"].(string) + } + } + if len(columns) > 0 { + return columns[0]["id"].(string) + } + return "" +} diff --git a/internal/markdown/markdown.go b/internal/markdown/markdown.go new file mode 100644 index 0000000..c7dcdcc --- /dev/null +++ b/internal/markdown/markdown.go @@ -0,0 +1,77 @@ +package markdown + +import ( + "fmt" + "github.com/andrew/go-computer/internal/db" + "net/http" + "regexp" + "strings" +) + +var slugRE = regexp.MustCompile(`[^a-z0-9]+`) + +func Slugify(s string) string { + s = strings.Trim(slugRE.ReplaceAllString(strings.ToLower(s), "-"), "-") + if len(s) > 60 { + s = s[:60] + } + if s == "" { + s = "untitled" + } + return s +} +func text(b db.Object, k string) string { s, _ := b[k].(string); return s } +func BookmarkDocument(b db.Object) string { + title := text(b, "title") + if title == "" { + title = text(b, "url") + } + out := "# " + title + "\n\n- URL: " + text(b, "url") + "\n" + if s := text(b, "dueDate"); len(s) >= 10 { + out += "- Date: " + s[:10] + "\n" + } + tags := []string{} + if a, ok := b["tags"].([]any); ok { + for _, x := range a { + bt, _ := x.(map[string]any) + t, _ := bt["tag"].(map[string]any) + tags = append(tags, text(t, "name")) + } + } + if len(tags) > 0 { + out += "- Tags: " + strings.Join(tags, ", ") + "\n" + } + return out + "\n" + strings.TrimSpace(text(b, "notes")) + "\n" +} +func PageDocument(p db.Object) string { + out := "# " + text(p, "title") + "\n\n" + strings.TrimSpace(text(p, "content")) + "\n" + if a, ok := p["bookmarks"].([]any); ok && len(a) > 0 { + out += "\n## Links\n" + for _, x := range a { + pb, _ := x.(map[string]any) + b, _ := pb["bookmark"].(map[string]any) + title := text(b, "title") + if title == "" { + title = text(b, "url") + } + title = strings.NewReplacer("[", "", "]", "", "\n", " ").Replace(title) + desc := strings.Join(strings.Fields(text(b, "description")), " ") + rs := []rune(desc) + if len(rs) > 160 { + desc = string(rs[:160]) + } + out += "- [" + title + "](" + text(b, "url") + ")" + if desc != "" { + out += " — " + desc + } + out += "\n" + } + } + return out + "\n\n" +} +func Send(w http.ResponseWriter, body, name string) { + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.md"`, Slugify(name))) + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write([]byte(body)) +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..0bb6340 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,196 @@ +package models + +// KanbanStatus is the legacy enum. Porting guide 2.1. +type KanbanStatus string + +const ( + Inbox KanbanStatus = "INBOX" + Todo KanbanStatus = "TODO" + InProgress KanbanStatus = "IN_PROGRESS" + Done KanbanStatus = "DONE" + Archived KanbanStatus = "ARCHIVED" +) + +// User. Porting guide 2.2. +type User struct { + ID string + Name *string + Email *string + EmailVerified *string + Image *string + Password *string + Role string // USER | ADMIN + TokenVersion int // embedded in cookie JWTs; bumping revokes them + CreatedAt string + UpdatedAt string +} + +// Account, Session, VerificationToken are auth-library tables. Porting guide 2.3. +// Session and VerificationToken are unused (sessions are JWT-based). +// Account is only needed if keeping OIDC. +type Account struct { + ID string + UserID string + Type string + Provider string + ProviderAccountID string + RefreshToken *string + AccessToken *string + ExpiresAt *int + TokenType *string + Scope *string + IDToken *string + SessionState *string +} + +// Bookmark is the core object: one saved URL. Porting guide 2.4. +type Bookmark struct { + ID string + URL string + Title *string + Description *string + Favicon *string + OgImage *string + OgTitle *string + OgDescription *string + Notes *string + DueDate *string + AlertAt *string + AlertSent bool + KanbanStatus KanbanStatus + KanbanColumnID *string + ImapWatchEnabled bool + ImapQuery *string + IsPublic bool + OwnerID string + ContactID *string + CreatedAt string + UpdatedAt string +} + +// Tag. Porting guide 2.5. +type Tag struct { + ID string + Name string + Color string + OwnerID string + CreatedAt string + UpdatedAt string +} + +// BookmarkTag join. Porting guide 2.6. +type BookmarkTag struct { + BookmarkID string + TagID string +} + +// BookmarkShare. Porting guide 2.7. +type BookmarkShare struct { + BookmarkID string + UserID string + CreatedAt string +} + +// TagShare. Porting guide 2.8. +type TagShare struct { + TagID string + UserID string + CreatedAt string +} + +// Contact, synced from Nextcloud CardDAV. Porting guide 2.9. +type Contact struct { + ID string + UID string + DisplayName string + Email *string + Phone *string + CardDavURL *string + UserID string + CreatedAt string + UpdatedAt string +} + +// ImapConfig, one per user; password is encrypted. Porting guide 2.10. +type ImapConfig struct { + ID string + Host string + Port int + TLS bool + Username string + Password string // encrypted + Folder string + UserID string +} + +// KanbanColumn, user-editable board column. Porting guide 2.11. +type KanbanColumn struct { + ID string + Label string + Color string + Position int + Key *string // INBOX/TODO/IN_PROGRESS/DONE for defaults, null for custom + UserID string + CreatedAt string +} + +// Page, a Markdown workspace. Porting guide 2.12. +type Page struct { + ID string + Title string + Content string + Icon *string + Pinned bool + Position int + OwnerID string + CreatedAt string + UpdatedAt string +} + +// PageBookmark links a bookmark onto a page. Porting guide 2.13. +type PageBookmark struct { + PageID string + BookmarkID string + Position int + CreatedAt string +} + +// SignalSource, a watcher attached to a bookmark. Porting guide 2.14. +type SignalSource struct { + ID string + Type string + Config string // JSON, default "{}" + Enabled bool + LastCheckedAt *string + LastStatus *string + BookmarkID string + OwnerID string + CreatedAt string + UpdatedAt string +} + +// Signal, one outside event about a bookmark. Porting guide 2.15. +type Signal struct { + ID string + SourceType string + Title string + Summary *string + URL *string + ExternalID *string + OccurredAt string + Read bool + SourceID *string + BookmarkID string + OwnerID string + CreatedAt string +} + +// SharedDate. Porting guide 2.16. +type SharedDate struct { + ID string + Date string + Note *string + CreatorID string + RecipientID string + CreatedAt string +} diff --git a/internal/netguard/netguard.go b/internal/netguard/netguard.go new file mode 100644 index 0000000..2c98222 --- /dev/null +++ b/internal/netguard/netguard.go @@ -0,0 +1,212 @@ +// Package netguard blocks server-initiated connections to non-public +// destinations. It backs URL previews, CardDAV sync and IMAP connections so a +// user-supplied host can never point the server at another user's internal +// service, the Docker database, or cloud metadata endpoints. +package netguard + +import ( + "context" + "errors" + "net" + "os" + "strconv" + "time" +) + +// AllowPrivate reports whether range checks are disabled via +// INTEGRATIONS_ALLOW_PRIVATE=1. Tests set it for loopback fake servers; +// operators can set it for deliberate LAN deployments (home Nextcloud or +// mail on private addresses). It is off in the production Compose stack. +func AllowPrivate() bool { return os.Getenv("INTEGRATIONS_ALLOW_PRIVATE") == "1" } + +// extraBlocked covers ranges net.IP's helpers miss. IPv4: "this network" +// (0.0.0.0/8), RFC 6598 CGNAT (100.64.0.0/10 — used by Tailscale, GKE +// internal LBs, some Docker setups), IETF protocol assignments +// (192.0.0.0/24), TEST-NET-1/2/3 documentation space (192.0.2.0/24, +// 198.51.100.0/24, 203.0.113.0/24), deprecated 6to4 relay anycast +// (192.88.99.0/24), benchmark space (198.18.0.0/15), and reserved/limited +// broadcast space (240.0.0.0/4, which includes 255.255.255.255). +// IPv6: discard-only (100::/64), benchmarking (2001:2::/48), ORCHID/ORCHIDv2 +// (2001:10::/28, 2001:20::/28), documentation (2001:db8::/32, 3fff::/20), +// SRv6 SIDs (5f00::/16), deprecated 6bone (3ffe::/16), and site-local +// fec0::/10. The transition prefixes with embedded IPv4 (6to4, Teredo, +// IPv4-compatible/translated, NAT64) are handled explicitly below. +var extraBlocked = func() []net.IPNet { + cidrs := []string{ + "0.0.0.0/8", "100.64.0.0/10", "192.0.0.0/24", "192.0.2.0/24", + "192.88.99.0/24", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", + "240.0.0.0/4", + "100::/64", "2001:2::/48", "2001:10::/28", "2001:20::/28", + "2001:db8::/32", "3ffe::/16", "3fff::/20", "5f00::/16", "fec0::/10", + } + nets := make([]net.IPNet, 0, len(cidrs)) + for _, cidr := range cidrs { + _, n, err := net.ParseCIDR(cidr) + if err != nil { + panic("netguard: bad CIDR " + cidr) + } + nets = append(nets, *n) + } + return nets +}() + +// parsePrefix is the panic-on-programmer-error helper for the fixed policy +// prefixes below. +func parsePrefix(cidr string) net.IPNet { + _, n, err := net.ParseCIDR(cidr) + if err != nil { + panic("netguard: bad CIDR " + cidr) + } + return *n +} + +// Transition prefixes that embed an IPv4 destination in a different place +// than the well-known NAT64 layout. They are the "rare IPv6 tricks" from the +// review: without these checks an IPv6 literal can smuggle a loopback or +// RFC 1918 destination past the range guard. +var ( + // RFC 6052 well-known NAT64: low 32 bits are the IPv4 destination. + nat64Prefix = parsePrefix("64:ff9b::/96") + // RFC 8215 local-use NAT64: never a public destination. + nat64LocalPrefix = parsePrefix("64:ff9b:1::/48") + // RFC 3056 6to4: bits 16..47 are the embedded IPv4 destination. + sixToFourPrefix = parsePrefix("2002::/16") + // RFC 4380 Teredo: block the whole prefix. Its client address is + // obfuscated and the route depends on a relay, so judging an embedded + // value is both unreliable and unnecessary for public web/mail targets. + teredoPrefix = parsePrefix("2001::/32") + // Deprecated IPv4-compatible (::/96) and IPv4-translated + // (::ffff:0:0:0/96) addresses. These are not IPv4-mapped addresses (those + // have To4() != nil and are judged directly above). + ipv4CompatiblePrefix = parsePrefix("::/96") + ipv4TranslatedPrefix = parsePrefix("::ffff:0:0:0/96") +) + +func publicIPv4(ip net.IP) bool { + v4 := ip.To4() + if v4 == nil || !v4.IsGlobalUnicast() || v4.IsPrivate() || v4.IsLoopback() || v4.IsLinkLocalUnicast() { + return false + } + for _, n := range extraBlocked { + if n.Contains(v4) { + return false + } + } + return true +} + +// Destination policy (review follow-up, stated explicitly): allowed targets +// are globally routable unicast addresses only. Blocked: private (RFC 1918, +// ULA fc00::/7), loopback, link-local, CGNAT, benchmark, IETF protocol and +// documentation ranges, unspecified/broadcast/multicast, IPv4-mapped +// addresses whose embedded IPv4 is non-public, and transition addresses +// (6to4/NAT64) whose embedded IPv4 is non-public. Teredo, +// IPv4-compatible/translated and local-use NAT64 prefixes are blocked +// wholesale. INTEGRATIONS_ALLOW_PRIVATE=1 (fixtures and deliberate LAN +// deployments) is the only relaxation. +func IsPublic(ip net.IP) bool { + if ip == nil || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsMulticast() || ip.IsUnspecified() { + return false + } + if v4 := ip.To4(); v4 != nil { + return publicIPv4(v4) + } + v6 := ip.To16() + if v6 == nil { + return false + } + if ipv4CompatiblePrefix.Contains(v6) || ipv4TranslatedPrefix.Contains(v6) { + return false + } + if sixToFourPrefix.Contains(v6) { + return publicIPv4(net.IPv4(v6[2], v6[3], v6[4], v6[5])) + } + if teredoPrefix.Contains(v6) || nat64LocalPrefix.Contains(v6) { + return false + } + if nat64Prefix.Contains(v6) { + return publicIPv4(net.IPv4(v6[12], v6[13], v6[14], v6[15])) + } + for _, n := range extraBlocked { + if n.Contains(v6) { + return false + } + } + return true +} + +var errBlocked = errors.New("netguard: private destination blocked") +var errBlockedPort = errors.New("netguard: destination port blocked") + +// DialContextPorts is DialContext with an allowlist of destination ports. +// Callers that fetch user-supplied URLs use it to keep outbound traffic on +// the protocol's normal ports: a preview has no business dialing SSH or a +// database port on a public host. Fixtures and deliberate LAN deployments +// can bypass it by calling DialContext directly when AllowPrivate is set. +func DialContextPorts(ctx context.Context, network, address string, allowed ...int) (net.Conn, error) { + _, rawPort, e := net.SplitHostPort(address) + if e != nil { + return nil, e + } + port, e := strconv.Atoi(rawPort) + if e != nil { + return nil, errBlockedPort + } + ok := false + for _, p := range allowed { + if port == p { + ok = true + break + } + } + if !ok { + return nil, errBlockedPort + } + return DialContext(ctx, network, address) +} + +// dialMaxAttempts bounds the fallback across already-validated addresses +// (review follow-up): the first A/AAAA record being unreachable does not make +// the host unreachable, but we never chase an unbounded list — and there is +// deliberately no second DNS lookup anywhere in this path. +const dialMaxAttempts = 3 + +// DialContext resolves the host, rejects non-public destinations, then dials +// the checked IPs. Check and dial use the same lookup result, so there is no +// DNS-rebinding window between them. Every resolved address must pass the +// range check (a host that mixes public and private records is refused +// outright), and the dial then falls back among the validated addresses +// instead of failing on the first unreachable one. +func DialContext(ctx context.Context, network, address string) (net.Conn, error) { + host, port, e := net.SplitHostPort(address) + if e != nil { + return nil, e + } + ips, e := net.DefaultResolver.LookupIPAddr(ctx, host) + if e != nil { + return nil, e + } + if len(ips) == 0 { + return nil, errors.New("netguard: unresolved address") + } + if !AllowPrivate() { + for _, a := range ips { + if !IsPublic(a.IP) { + return nil, errBlocked + } + } + } + dialer := &net.Dialer{Timeout: 10 * time.Second} + var lastErr error + for i, a := range ips { + if i >= dialMaxAttempts { + break + } + var conn net.Conn + conn, lastErr = dialer.DialContext(ctx, network, net.JoinHostPort(a.IP.String(), port)) + if lastErr == nil { + return conn, nil + } + } + return nil, lastErr +} diff --git a/internal/netguard/netguard_test.go b/internal/netguard/netguard_test.go new file mode 100644 index 0000000..bb3334b --- /dev/null +++ b/internal/netguard/netguard_test.go @@ -0,0 +1,113 @@ +package netguard + +import ( + "context" + "fmt" + "net" + "testing" + "time" +) + +func TestIsPublic(t *testing.T) { + public := []string{ + "8.8.8.8", "1.1.1.1", "2001:4860:4860::8888", + // NAT64 and 6to4 wrapping a PUBLIC embedded IPv4 address a real + // public destination on IPv6-only networks — allowed by policy. + "64:ff9b::8.8.8.8", "2002:808:808::", + } + blocked := []string{ + "127.0.0.1", "::1", // loopback + "10.1.2.3", "172.16.0.1", "192.168.1.1", "fc00::1", "fd12::1", "fec0::1", // RFC1918 + ULA + site-local + "169.254.169.254", "fe80::1", // link-local / cloud metadata + "100.64.0.1", "100.127.255.254", // RFC 6598 CGNAT + "198.18.0.1", "198.19.255.255", // benchmark + "192.0.0.1", // IETF protocol assignments + "192.0.2.1", "198.51.100.1", "203.0.113.1", "2001:db8::1", "3fff::1", // documentation space + "0.1.2.3", "240.0.0.1", "255.255.255.255", // this-network / reserved / broadcast + "0.0.0.0", "::", "224.0.0.1", "ff02::1", // unspecified / multicast + "::ffff:10.0.0.1", "::ffff:127.0.0.1", // IPv4-mapped private/loopback + "64:ff9b::7f00:1", "64:ff9b::a00:1", "64:ff9b::a9fe:a9fe", // NAT64-embedded loopback / private / metadata + "64:ff9b:1::8.8.8.8", // local-use NAT64 is never public + "2002:7f00:1::", "2002:a00:1::", "2002:a9fe:a9fe::", // 6to4-embedded loopback / private / metadata + "2001:0:0:0:0:0:7f00:1", "2001:0:4136:e378:8000:63bf:3fff:fdd2", // Teredo + "::127.0.0.1", "::10.0.0.1", "::ffff:0:7f00:1", "::ffff:0:10.0.0.1", // IPv4-compatible / translated + "100::1", "2001:2::1", "2001:10::1", "2001:20::1", "5f00::1", "3ffe::1", // discard, benchmark, ORCHID, SRv6, 6bone + } + for _, s := range public { + if !IsPublic(net.ParseIP(s)) { + t.Errorf("IsPublic(%s) = false, want true", s) + } + } + for _, s := range blocked { + if IsPublic(net.ParseIP(s)) { + t.Errorf("IsPublic(%s) = true, want false", s) + } + } +} + +func TestAllowPrivateEnv(t *testing.T) { + if AllowPrivate() { + t.Fatal("AllowPrivate must default to false") + } + t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1") + if !AllowPrivate() { + t.Fatal("AllowPrivate must honor INTEGRATIONS_ALLOW_PRIVATE=1") + } +} + +func TestDialContextBlocksLoopbackByDefault(t *testing.T) { + if _, e := DialContext(context.Background(), "tcp", "127.0.0.1:5432"); e == nil { + t.Fatal("loopback destination accepted") + } +} + +func TestDialContextPortsRejectsUnusualPorts(t *testing.T) { + // Port policy is checked before DNS, so this never dials. + if _, e := DialContextPorts(context.Background(), "tcp", "8.8.8.8:22", 80, 443); e == nil { + t.Fatal("SSH port accepted for a URL preview") + } + if _, e := DialContextPorts(context.Background(), "tcp", "8.8.8.8:not-a-port", 80, 443); e == nil { + t.Fatal("non-numeric port accepted") + } +} + +// The NAT64 well-known prefix must not smuggle a non-public IPv4 destination +// past the guard (review follow-up: comprehensive special-range coverage). +func TestDialContextBlocksNAT64WrappedLoopback(t *testing.T) { + if _, e := DialContext(context.Background(), "tcp", "[64:ff9b::7f00:1]:80"); e == nil { + t.Fatal("NAT64-wrapped loopback destination accepted") + } + if _, e := DialContext(context.Background(), "tcp", "[64:ff9b::a9fe:a9fe]:80"); e == nil { + t.Fatal("NAT64-wrapped metadata destination accepted") + } +} + +// Bounded fallback among already-validated addresses (review follow-up): a +// host that resolves to several addresses connects even when the first +// record is unreachable. "localhost" resolves to ::1 and/or 127.0.0.1 +// depending on the resolver; the listener binds only IPv4 loopback, so on +// dual-stack machines the ::1 attempt must fail over to 127.0.0.1. +func TestDialContextFallsBackAmongValidatedAddresses(t *testing.T) { + t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1") + ln, e := net.Listen("tcp4", "127.0.0.1:0") + if e != nil { + t.Skipf("no IPv4 loopback listener possible: %v", e) + } + defer ln.Close() + go func() { + for { + c, e := ln.Accept() + if e != nil { + return + } + c.Close() + } + }() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn, e := DialContext(ctx, "tcp", fmt.Sprintf("localhost:%d", ln.Addr().(*net.TCPAddr).Port)) + if e != nil { + t.Fatalf("fallback dial to localhost failed: %v", e) + } + conn.Close() +} diff --git a/internal/oidc/config.go b/internal/oidc/config.go new file mode 100644 index 0000000..0fac65a --- /dev/null +++ b/internal/oidc/config.go @@ -0,0 +1,73 @@ +package oidc + +// Authelia OIDC configuration (STUB-AWARE). Porting guide 3.8. +// +// Authelia sign-in is only enabled when all three env vars hold real values: +// OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET +// +// Empty values or obvious placeholders (REPLACE_ME, example.com, changeme, ...) +// are treated as "not connected": the Authelia provider is not registered, the +// login button is shown disabled, and email/password login keeps working. +// +// See docs/AUTHELIA.md for the full connection guide. + +import ( + "os" + "regexp" + "strings" +) + +var placeholderPatterns = []*regexp.Regexp{ + regexp.MustCompile(`replace[_-]?me`), + regexp.MustCompile(`example\.(com|org|net)`), + regexp.MustCompile(`changeme`), + regexp.MustCompile(`placeholder`), + regexp.MustCompile(`^<.*>$`), + regexp.MustCompile(`^your[_-]`), + regexp.MustCompile(`^todo$`), +} + +func isRealValue(value string) bool { + v := strings.ToLower(strings.TrimSpace(value)) + if v == "" { + return false + } + for _, p := range placeholderPatterns { + if p.MatchString(v) { + return false + } + } + return true +} + +// OidcStatus mirrors lib/oidc-config.ts. Enabled only when all three env +// values are real (non-placeholder). Never exposes the secret. +type OidcStatus struct { + Enabled bool + IssuerSet bool + ClientIDSet bool + ClientSecretSet bool + Issuer *string +} + +// Status returns the current OIDC configuration state. +func Status() OidcStatus { + issuerSet := isRealValue(os.Getenv("OIDC_ISSUER")) + clientIdSet := isRealValue(os.Getenv("OIDC_CLIENT_ID")) + clientSecretSet := isRealValue(os.Getenv("OIDC_CLIENT_SECRET")) + var issuer *string + if issuerSet { + v := strings.TrimSpace(os.Getenv("OIDC_ISSUER")) + issuer = &v + } + return OidcStatus{ + Enabled: issuerSet && clientIdSet && clientSecretSet, + IssuerSet: issuerSet, + ClientIDSet: clientIdSet, + ClientSecretSet: clientSecretSet, + Issuer: issuer, + } +} + +// CallbackPath is where Authelia sends the OIDC code back. +const CallbackPath = "/api/auth/callback/authelia" diff --git a/internal/oidc/provider.go b/internal/oidc/provider.go new file mode 100644 index 0000000..e807693 --- /dev/null +++ b/internal/oidc/provider.go @@ -0,0 +1,96 @@ +package oidc + +// Provider is the Authelia OIDC sign-in provider. +// +// Porting guide 3.1: the Authelia provider is registered only when +// getOidcStatus().enabled. Scope "openid profile email". Profile mapping: +// id=sub, name = name ?? preferred_username ?? sub, email, image = picture. +// It links to an existing account with the same email. +// +// STUB: the full OIDC code flow (authorization code exchange, token endpoint, +// userinfo) is NOT implemented in Go — it will be driven by the client, which +// runs NextAuth and handles the provider itself. These handlers exist so the +// route table matches the original app and the Settings/login pages can read +// the status. They return 501 until the client-side flow is wired. + +import ( + "encoding/json" + "net/http" +) + +// HandleStatus returns the current OIDC configuration state. This is what the +// Settings page and the login page read to decide whether to show the +// "Sign in with Authelia" button. +// +// GET /api/auth/oidc/status -> {enabled, issuerSet, clientIdSet, clientSecretSet, issuer} +func HandleStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if r.Method != http.MethodGet { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusMethodNotAllowed) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"}) + return + } + s := Status() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "enabled": s.Enabled, + "issuerSet": s.IssuerSet, + "clientIdSet": s.ClientIDSet, + "clientSecretSet": s.ClientSecretSet, + "issuer": s.Issuer, + }) +} + +// HandleLogin starts the OIDC authorization-code flow. STUB: returns 501 until +// the client drives the flow via NextAuth. +// +// GET /api/auth/oidc/login +func HandleLogin(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotImplemented) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "oidc not implemented", + "detail": "Authelia OIDC is driven by the client (NextAuth); this handler is a stub", + }) +} + +// HandleCallback is the Authelia OIDC callback. STUB: returns 501 until the +// client drives the flow via NextAuth. +// +// GET /api/auth/callback/authelia +func HandleCallback(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotImplemented) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "oidc not implemented", + "detail": "Authelia OIDC is driven by the client (NextAuth); this handler is a stub", + }) +} + +// Profile maps an Authelia userinfo response to a user identity. Porting guide +// 3.1: id=sub, name = name ?? preferred_username ?? sub, email, image = picture. +type Profile struct { + Sub string `json:"sub"` + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + Email string `json:"email"` + Picture string `json:"picture"` +} + +// MapProfile converts a raw Authelia profile into the identity fields. +func MapProfile(p Profile) (id, name, email, image string) { + id = p.Sub + if p.Name != "" { + name = p.Name + } else if p.PreferredUsername != "" { + name = p.PreferredUsername + } else { + name = p.Sub + } + email = p.Email + image = p.Picture + return +} diff --git a/internal/preview/preview.go b/internal/preview/preview.go new file mode 100644 index 0000000..4d20a07 --- /dev/null +++ b/internal/preview/preview.go @@ -0,0 +1,267 @@ +package preview + +import ( + "context" + "fmt" + "github.com/andrew/go-computer/internal/netguard" + "golang.org/x/net/html" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +type Preview struct { + Title *string `json:"title"` + Description *string `json:"description"` + Favicon *string `json:"favicon"` + OgImage *string `json:"ogImage"` + OgTitle *string `json:"ogTitle"` + OgDescription *string `json:"ogDescription"` +} + +// DNS is resolved and checked on each connection, including redirects, so +// URL previews cannot reach another user's internal service or Docker DB. +// The shared range logic lives in netguard (also used by CardDAV and IMAP). +// Preview fetches are additionally pinned to the normal web ports: a public +// host can still expose SSH, SMTP or a database, and a preview has no reason +// to touch them. The fixture/LAN escape hatch keeps loopback test servers +// (and deliberate private deployments) working on arbitrary ports. +func publicDial(ctx context.Context, network, address string) (net.Conn, error) { + if netguard.AllowPrivate() { + return netguard.DialContext(ctx, network, address) + } + return netguard.DialContextPorts(ctx, network, address, 80, 443) +} + +// previewClient is reused for all preview fetches (review PERF-1): a new +// Transport per call leaked idle connections because IdleConnTimeout +// defaulted to 0. A single client with bounded idle pool and timeout keeps +// the connection count and file descriptors under control. +var previewClient = &http.Client{ + Timeout: 8 * time.Second, + Transport: &http.Transport{ + DialContext: publicDial, + IdleConnTimeout: 30 * time.Second, + MaxIdleConns: 32, + MaxIdleConnsPerHost: 8, + }, + CheckRedirect: func(r *http.Request, via []*http.Request) error { + if len(via) > 5 { + return fmt.Errorf("too many redirects") + } + if r.URL.Scheme != "http" && r.URL.Scheme != "https" { + return fmt.Errorf("invalid scheme") + } + return nil + }, +} + +// Shared preview-work budget (review CR-06). EVERY entry point that fetches +// preview metadata — the dedicated /api/preview route, bookmark creation and +// URL-changing updates — reserves against this one budget, so mutations can +// no longer bypass the preview route's throttle and generate unbounded +// outbound work. Two layers: +// - per-identity rate: at most keyLimit reservations per keyWindow; +// - global concurrency: at most maxConcurrent fetches in flight. +// +// Exhaustion returns an empty Preview immediately instead of queueing: +// previews are decorative, and a bookmark save must never be coupled to +// preview availability (the row persists without og metadata). +const ( + keyLimit = 20 + keyWindow = time.Minute + maxConcurrent = 8 +) + +type rateWindow struct { + count int + reset time.Time +} + +type budgetStore struct { + mu sync.Mutex + inFlight int + keys map[string]*rateWindow +} + +var sharedBudget = &budgetStore{keys: map[string]*rateWindow{}} + +// Reserve claims one preview-work slot for key (the requesting identity). +// The returned release function MUST be called when the fetch finishes. +// ok is false when either budget layer is exhausted. +func Reserve(key string) (release func(), ok bool) { + if key == "" { + key = "anonymous" + } + sharedBudget.mu.Lock() + defer sharedBudget.mu.Unlock() + if sharedBudget.inFlight >= maxConcurrent { + return nil, false + } + now := time.Now() + // Opportunistic prune keeps the map bounded by active identities. + if len(sharedBudget.keys) > 4096 { + for k, w := range sharedBudget.keys { + if now.After(w.reset) { + delete(sharedBudget.keys, k) + } + } + } + w := sharedBudget.keys[key] + if w == nil || now.After(w.reset) { + w = &rateWindow{reset: now.Add(keyWindow)} + sharedBudget.keys[key] = w + } + if w.count >= keyLimit { + return nil, false + } + w.count++ + sharedBudget.inFlight++ + return func() { + sharedBudget.mu.Lock() + sharedBudget.inFlight-- + sharedBudget.mu.Unlock() + }, true +} + +// FetchPreview resolves metadata for raw on behalf of key (the requesting +// identity, e.g. "user:"). The caller's context bounds the fetch (review +// CORR-02): when the incoming request is cancelled — the user closed the tab +// or aborted the save — the outbound I/O and HTML parsing stop instead of +// running to the 8-second client timeout. An exhausted shared budget (review +// CR-06) yields an empty Preview without any outbound work. +func FetchPreview(ctx context.Context, key, raw string) Preview { + release, ok := Reserve(key) + if !ok { + return Preview{} + } + defer release() + u, e := url.Parse(raw) + if e != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil { + return Preview{} + } + req, e := http.NewRequestWithContext(ctx, "GET", raw, nil) + if e != nil { + return Preview{} + } + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; computer/1.0)") + resp, e := previewClient.Do(req) + if e != nil { + return Preview{} + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return Preview{} + } + // Review CORR-01: resolve relative favicons/og:images against the final + // response URL, not the caller-supplied one — a redirect can change the + // host or base path and break every relative asset. + base := u + if resp.Request != nil && resp.Request.URL != nil { + base = resp.Request.URL + } + return ParseHTML(io.LimitReader(resp.Body, 1<<20), base) +} + +// clipRunes keeps remote metadata bounded: a hostile or merely careless page +// can otherwise put a megabyte into a title/description that every bookmark +// list then carries around. +func clipRunes(s string, max int) string { + r := []rune(s) + if len(r) > max { + r = r[:max] + } + return string(r) +} + +func ParseHTML(r io.Reader, base *url.URL) Preview { + out := Preview{} + z := html.NewTokenizer(r) + var title strings.Builder + inTitle := false + absolute := func(s string) *string { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + u, e := base.Parse(s) + if e != nil || (u.Scheme != "http" && u.Scheme != "https") { + return nil + } + s = clipRunes(u.String(), 2048) + return &s + } + for { + t := z.Next() + if t == html.ErrorToken { + break + } + tok := z.Token() + if t == html.TextToken && inTitle { + title.WriteString(tok.Data) + } + if t == html.EndTagToken && tok.Data == "title" { + inTitle = false + } + if t != html.StartTagToken && t != html.SelfClosingTagToken { + continue + } + if tok.Data == "title" { + inTitle = true + } + attrs := map[string]string{} + for _, a := range tok.Attr { + attrs[a.Key] = a.Val + } + if tok.Data == "meta" { + key := strings.ToLower(attrs["property"]) + if key == "" { + key = strings.ToLower(attrs["name"]) + } + v := strings.TrimSpace(attrs["content"]) + if v != "" { + switch key { + case "og:title": + if out.OgTitle == nil { + c := clipRunes(v, 500) + out.OgTitle = &c + } + case "og:description": + if out.OgDescription == nil { + c := clipRunes(v, 2000) + out.OgDescription = &c + } + case "description": + if out.Description == nil { + c := clipRunes(v, 2000) + out.Description = &c + } + case "og:image": + if out.OgImage == nil { + out.OgImage = absolute(v) + } + } + } + } + if tok.Data == "link" && strings.Contains(strings.ToLower(attrs["rel"]), "icon") && out.Favicon == nil { + out.Favicon = absolute(attrs["href"]) + } + } + if out.OgTitle != nil { + out.Title = out.OgTitle + } else if s := clipRunes(strings.TrimSpace(title.String()), 500); s != "" { + out.Title = &s + } + if out.OgDescription != nil { + out.Description = out.OgDescription + } + if out.Favicon == nil { + s := base.Scheme + "://" + base.Host + "/favicon.ico" + out.Favicon = &s + } + return out +} diff --git a/internal/preview/preview_test.go b/internal/preview/preview_test.go new file mode 100644 index 0000000..5c187b0 --- /dev/null +++ b/internal/preview/preview_test.go @@ -0,0 +1,85 @@ +package preview + +import ( + "context" + "fmt" + "net/url" + "strings" + "testing" + "time" +) + +func TestHTMLMetadata(t *testing.T) { + u, _ := url.Parse("https://site.test/docs/page") + p := ParseHTML(strings.NewReader(``), u) + if p.OgTitle == nil || *p.OgTitle != "A & B" || p.OgImage == nil || *p.OgImage != "https://site.test/cover.png" || p.Favicon == nil || *p.Favicon != "https://site.test/icon.png" { + t.Fatalf("metadata: %+v", p) + } +} + +// Remote metadata is clipped so a hostile page cannot bloat every bookmark +// list response with a megabyte-long title or description. +func TestHTMLMetadataIsBounded(t *testing.T) { + u, _ := url.Parse("https://site.test/") + title := strings.Repeat("T", 1000) + desc := strings.Repeat("D", 5000) + p := ParseHTML(strings.NewReader(``+title+``), u) + if p.Title == nil || len([]rune(*p.Title)) != 500 { + t.Fatalf("title not clipped: %v", p.Title) + } + if p.Description == nil || len([]rune(*p.Description)) != 2000 { + t.Fatalf("description not clipped: %v", p.Description) + } +} + +func TestPrivatePreviewIsBlocked(t *testing.T) { + if c, e := publicDial(context.Background(), "tcp", "127.0.0.1:5432"); e == nil { + c.Close() + t.Fatal("private destination accepted") + } +} + +// Review CR-06: every preview entry point draws from ONE shared budget — a +// per-identity rate and a global concurrency cap. Exhaustion must deny +// immediately (callers then persist bookmarks without preview metadata) +// instead of queueing outbound work. +func TestSharedBudget(t *testing.T) { + stamp := time.Now().UnixNano() + key := fmt.Sprintf("user:rate-%d", stamp) + for i := 0; i < keyLimit; i++ { + release, ok := Reserve(key) + if !ok { + t.Fatalf("reservation %d within the per-key limit denied", i) + } + release() + } + if _, ok := Reserve(key); ok { + t.Fatal("per-key rate budget exceeded") + } + // An unrelated identity is unaffected by the exhausted key. + if release, ok := Reserve(key + "-other"); !ok { + t.Fatal("independent key denied") + } else { + release() + } + // Global concurrency: held (unreleased) slots cap all identities. + var releases []func() + for i := 0; i < maxConcurrent; i++ { + release, ok := Reserve(fmt.Sprintf("user:conc-%d-%d", stamp, i)) + if !ok { + t.Fatalf("concurrency slot %d denied", i) + } + releases = append(releases, release) + } + if _, ok := Reserve(fmt.Sprintf("user:conc-overflow-%d", stamp)); ok { + t.Fatal("global concurrency cap not enforced") + } + for _, release := range releases { + release() + } + if release, ok := Reserve(fmt.Sprintf("user:conc-after-%d", stamp)); !ok { + t.Fatal("slot not returned after release") + } else { + release() + } +} diff --git a/internal/scripts/seed.go b/internal/scripts/seed.go new file mode 100644 index 0000000..7a7f1b1 --- /dev/null +++ b/internal/scripts/seed.go @@ -0,0 +1,36 @@ +package scripts + +import ( + "context" + "fmt" + "github.com/andrew/go-computer/internal/db" + "golang.org/x/crypto/bcrypt" + "os" + "strings" +) + +// Seed only creates the requested account; it never clears existing records. +func Seed(ctx context.Context, d *db.DB) error { + email, password := strings.ToLower(strings.TrimSpace(os.Getenv("SEED_EMAIL"))), os.Getenv("SEED_PASSWORD") + if email == "" || password == "" { + return fmt.Errorf("SEED_EMAIL and SEED_PASSWORD are required") + } + repo := db.NewUserRepo(d) + if _, e := repo.FindByEmail(ctx, email); e == nil { + return nil + } + hash, e := bcrypt.GenerateFromPassword([]byte(password), 12) + if e != nil { + return e + } + name := os.Getenv("SEED_NAME") + if name == "" { + name = "Admin" + } + role := "USER" + if db.IsAdminEmail(email) { + role = "ADMIN" + } + _, e = repo.Create(ctx, &db.NewUserInput{ID: db.NewID(), Name: name, Email: email, Password: string(hash), Role: role}) + return e +} diff --git a/internal/signals/checkpoint_test.go b/internal/signals/checkpoint_test.go new file mode 100644 index 0000000..86ae2ea --- /dev/null +++ b/internal/signals/checkpoint_test.go @@ -0,0 +1,347 @@ +package signals + +import ( + "context" + "errors" + "fmt" + "net" + "sync" + "testing" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/backend" + "github.com/emersion/go-imap/backend/memory" + imapserver "github.com/emersion/go-imap/server" +) + +// validityBackend wraps the in-memory backend so tests can force the +// UIDVALIDITY the server reports (the stock memory backend hardcodes 1) and +// capture the SEARCH criteria every pass issues (review CR-15/CR-19 +// verification). +type validityBackend struct { + backend.Backend + validity uint32 + mu sync.Mutex + criteria []*imap.SearchCriteria + // failFetchAfter makes the fixture fail ListMessages calls after the + // given count, simulating a mailbox that dies partway through a pass. + failFetchAfter int + fetches int +} + +func (b *validityBackend) Login(connInfo *imap.ConnInfo, username, password string) (backend.User, error) { + u, e := b.Backend.Login(connInfo, username, password) + if e != nil { + return nil, e + } + return &validityUser{User: u, be: b}, nil +} + +func (b *validityBackend) captured() []*imap.SearchCriteria { + b.mu.Lock() + defer b.mu.Unlock() + return append([]*imap.SearchCriteria(nil), b.criteria...) +} + +type validityUser struct { + backend.User + be *validityBackend +} + +func (u *validityUser) GetMailbox(name string) (backend.Mailbox, error) { + m, e := u.User.GetMailbox(name) + if e != nil { + return nil, e + } + return &validityMailbox{Mailbox: m, be: u.be}, nil +} + +type validityMailbox struct { + backend.Mailbox + be *validityBackend +} + +func (m *validityMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) { + st, e := m.Mailbox.Status(items) + if e == nil && m.be.validity != 0 { + st.UidValidity = m.be.validity + } + return st, e +} + +func (m *validityMailbox) SearchMessages(uid bool, c *imap.SearchCriteria) ([]uint32, error) { + if uid { + m.be.mu.Lock() + m.be.criteria = append(m.be.criteria, c) + m.be.mu.Unlock() + } + return m.Mailbox.SearchMessages(uid, c) +} + +func (m *validityMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error { + m.be.mu.Lock() + m.be.fetches++ + fail := m.be.failFetchAfter > 0 && m.be.fetches > m.be.failFetchAfter + m.be.mu.Unlock() + if fail { + close(ch) + return errors.New("fixture fetch failure") + } + return m.Mailbox.ListMessages(uid, seqset, items, ch) +} + +// newTestMailbox stands up the in-memory IMAP fixture the integration suite +// uses, reseeded with n messages in INBOX (the backend ships one). The server +// enforces real protocol semantics, so SearchConnFrom exercises +// UidSearch/UidFetch against genuine UIDVALIDITY/UIDNEXT responses. +func newTestMailbox(t *testing.T, n int) (Mailbox, func()) { + m, _, cleanup := newTestMailboxValidity(t, n, 0) + return m, cleanup +} + +// newTestMailboxValidity is newTestMailbox with a forced UIDVALIDITY (0 keeps +// the backend default) and access to the criteria capture. +func newTestMailboxValidity(t *testing.T, n int, validity uint32) (Mailbox, *validityBackend, func()) { + t.Helper() + mb := memory.New() + // Reach the fixture's own user/mailbox through a refused login, which is + // how the backend exposes them. + _, err := mb.Login(nil, "username", "bad") + if err == nil { + t.Fatal("fixture login unexpectedly succeeded") + } + user, err := mb.Login(nil, "username", "password") + if err != nil { + t.Fatal(err) + } + mbox, err := user.GetMailbox("INBOX") + if err != nil { + t.Fatal(err) + } + memMbox := mbox.(*memory.Mailbox) + memMbox.Messages = memMbox.Messages[:0] + for i := 0; i < n; i++ { + body := "From: sender@computer.test\r\n" + + "Subject: subject " + fmt.Sprint(i) + "\r\n" + + "Message-Id: \r\n" + + "\r\n" + "body" + memMbox.Messages = append(memMbox.Messages, &memory.Message{ + Uid: uint32(i + 1), + Date: time.Now(), + Size: uint32(len(body)), + Body: []byte(body), + }) + } + listener, e := net.Listen("tcp", "127.0.0.1:0") + if e != nil { + t.Fatal(e) + } + be := &validityBackend{Backend: mb, validity: validity} + mail := imapserver.New(be) + mail.AllowInsecureAuth = true + go mail.Serve(listener) + host, port, _ := net.SplitHostPort(listener.Addr().String()) + var p int + fmt.Sscanf(port, "%d", &p) + m := Mailbox{Host: host, Username: "username", Password: "password", Port: p, TLS: false, Folder: "INBOX"} + t.Setenv("INTEGRATIONS_ALLOW_INSECURE_IMAP", "1") + return m, be, func() { mail.Close() } +} + +// TestTruncatedPassKeepsRemainingMatches pins review COR-1: when the +// per-pass cap truncates the matched UID list, the checkpoint must advance +// only to the last fetched UID — never to the mailbox high-water mark — or +// matches past the cap are dropped forever. +func TestTruncatedPassKeepsRemainingMatches(t *testing.T) { + // Connect dials loopback through netguard; the fixture flag unblocks it + // (same escape hatch the integration harness uses). + t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1") + cap := searchBatch * maxIngestBatches + m, cleanup := newTestMailbox(t, cap+7) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + c, e := Connect(ctx, m) + if e != nil { + t.Fatal(e) + } + defer c.Logout() + + events, next, e := SearchConnFrom(c, m, "", "subject", Checkpoint{}) + if e != nil { + t.Fatal(e) + } + if len(events) != cap { + t.Fatalf("fetched %d events, want %d (per-pass cap)", len(events), cap) + } + if next.LastUID != uint32(cap) { + t.Fatalf("checkpoint advanced to %d, want %d (high-water shortcut must stay off after truncation)", next.LastUID, cap) + } + // A follow-up pass from the new checkpoint picks up every remaining match. + rest, final, e := SearchConnFrom(c, m, "", "subject", next) + if e != nil { + t.Fatal(e) + } + if len(rest) != 7 { + t.Fatalf("second pass fetched %d events, want 7", len(rest)) + } + if final.LastUID != uint32(cap+7) { + t.Fatalf("final checkpoint %d, want %d", final.LastUID, cap+7) + } +} + +// Review follow-up: a mailbox that fails after an earlier batch succeeded +// still returns the events already fetched. The caller persists them but must +// not advance the checkpoint past the failed batch. +func TestPartialFetchFailureReturnsEarlierEvents(t *testing.T) { + t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1") + m, be, cleanup := newTestMailboxValidity(t, searchBatch*2+5, 0) + defer cleanup() + be.mu.Lock() + be.failFetchAfter = 1 + be.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + c, e := Connect(ctx, m) + if e != nil { + t.Fatal(e) + } + defer c.Logout() + + events, next, e := SearchConnFrom(c, m, "", "subject", Checkpoint{}) + if e == nil { + t.Fatal("fixture fetch failure was not reported") + } + if len(events) != searchBatch { + t.Fatalf("partial pass returned %d events, want the first batch (%d)", len(events), searchBatch) + } + if next.LastUID != searchBatch { + t.Fatalf("partial checkpoint %d, want %d (never past the failed batch)", next.LastUID, searchBatch) + } +} + +func TestCheckpointHighWaterShortcutAppliesWhenComplete(t *testing.T) { + t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1") + // When the SEARCH considered every message (no truncation), the + // checkpoint may advance to the mailbox high-water mark. + m, cleanup := newTestMailbox(t, searchBatch+1) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + c, e := Connect(ctx, m) + if e != nil { + t.Fatal(e) + } + defer c.Logout() + + events, next, e := SearchConnFrom(c, m, "", "subject", Checkpoint{}) + if e != nil { + t.Fatal(e) + } + if len(events) != searchBatch+1 { + t.Fatalf("fetched %d events, want %d", len(events), searchBatch+1) + } + if next.LastUID != uint32(searchBatch+1) { + t.Fatalf("checkpoint %d, want %d", next.LastUID, searchBatch+1) + } +} + +// TestUIDValidityResetReingests pins review CR-15 with a fixture-backed +// ingestion pass: when UIDVALIDITY changes, the new UID space is scanned from +// zero — even when every new UID sits BELOW the old checkpoint's LastUID. +// The old behavior filtered against the stale high-water mark and skipped the +// whole new space while advancing the checkpoint past it. +func TestUIDValidityResetReingests(t *testing.T) { + t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1") + // New UID space: UIDs 1..20 under UIDVALIDITY 8. + m, _, cleanup := newTestMailboxValidity(t, 20, 8) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + c, e := Connect(ctx, m) + if e != nil { + t.Fatal(e) + } + defer c.Logout() + + // A checkpoint from the PREVIOUS UID space whose LastUID (100) is above + // every UID that now exists. + stale := Checkpoint{UIDValidity: 7, LastUID: 100} + events, next, e := SearchConnFrom(c, m, "", "subject", stale) + if e != nil { + t.Fatal(e) + } + if len(events) != 20 { + t.Fatalf("reset pass ingested %d events, want all 20 of the new UID space", len(events)) + } + if next.UIDValidity != 8 || next.LastUID != 20 { + t.Fatalf("checkpoint after reset = %+v, want {UIDValidity:8 LastUID:20}", next) + } + // A subsequent pass from the reset checkpoint finds nothing new and does + // not regress the checkpoint. + again, final, e := SearchConnFrom(c, m, "", "subject", next) + if e != nil { + t.Fatal(e) + } + if len(again) != 0 { + t.Fatalf("follow-up pass ingested %d events, want 0", len(again)) + } + if final.UIDValidity != 8 || final.LastUID != 20 { + t.Fatalf("follow-up checkpoint = %+v, want {UIDValidity:8 LastUID:20}", final) + } +} + +// TestIncrementalPassConstrainsUIDSearch pins review CR-19: once a source is +// caught up, the server-side SEARCH carries a UID window derived from the +// normalized checkpoint instead of re-matching the whole history. The reset +// pass (CR-15) must NOT constrain — the whole new UID space is fair game. +func TestIncrementalPassConstrainsUIDSearch(t *testing.T) { + t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1") + m, be, cleanup := newTestMailboxValidity(t, 10, 5) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + c, e := Connect(ctx, m) + if e != nil { + t.Fatal(e) + } + defer c.Logout() + + // Pass 1: mismatched UIDVALIDITY forces a reset; no UID constraint. + if _, next, e := SearchConnFrom(c, m, "", "subject", Checkpoint{UIDValidity: 4, LastUID: 99}); e != nil { + t.Fatal(e) + } else if next.LastUID != 10 { + t.Fatalf("reset pass checkpoint %d, want 10", next.LastUID) + } + // Pass 2: caught up at UID 4 of validity 5 — only UIDs 5..10 ingest. + events, next2, e := SearchConnFrom(c, m, "", "subject", Checkpoint{UIDValidity: 5, LastUID: 4}) + if e != nil { + t.Fatal(e) + } + if len(events) != 6 { + t.Fatalf("incremental pass ingested %d events, want 6", len(events)) + } + if next2.LastUID != 10 { + t.Fatalf("incremental checkpoint %d, want 10", next2.LastUID) + } + + got := be.captured() + if len(got) != 2 { + t.Fatalf("captured %d UID searches, want 2", len(got)) + } + if got[0].Uid != nil { + t.Fatalf("reset pass constrained the SEARCH to %v; it must rescan the whole UID space", got[0].Uid) + } + if got[1].Uid == nil { + t.Fatal("incremental pass did not constrain the SEARCH by UID window") + } + if !got[1].Uid.Contains(5) || got[1].Uid.Contains(4) { + t.Fatalf("incremental SEARCH UID window %v does not start at 5", got[1].Uid) + } +} diff --git a/internal/signals/scheduler.go b/internal/signals/scheduler.go new file mode 100644 index 0000000..ce1fc57 --- /dev/null +++ b/internal/signals/scheduler.go @@ -0,0 +1,153 @@ +package signals + +import ( + "context" + "github.com/andrew/go-computer/internal/db" + "log" + "sync" + "time" +) + +const ( + // pollConcurrency bounds simultaneous mailbox passes; each pass holds one + // IMAP login and a 45-second budget (review SCALE-01). + pollConcurrency = 3 + perUserTimeout = 45 * time.Second + // maxBackoffShift caps failure backoff at 2^4 ticks (~80 minutes). + maxBackoffShift = 4 +) + +// Scheduler polls users with enabled signal sources. Review SCALE-01: +// bounded concurrency, per-user exponential backoff on consecutive failures, +// logged outcomes (counts only — never credentials or mail content), and a +// database-backed advisory lease so multiple app instances never poll the +// same user simultaneously. Database deduplication would hide duplicate +// records but not the duplicated external work the lease prevents. +type Scheduler struct { + mu sync.Mutex + failures map[string]int + tick int +} + +func NewScheduler() *Scheduler { return &Scheduler{failures: map[string]int{}} } + +// RunOnce performs a single scheduler pass over every user with enabled +// sources. Extracted from RunLoop so tests can drive one tick directly. +func RunOnce(ctx context.Context, d *db.DB) { NewScheduler().RunOnce(ctx, d) } + +func (s *Scheduler) RunOnce(ctx context.Context, d *db.DB) { + users, e := db.Many(ctx, d, `SELECT jsonb_build_object('id',"ownerId") FROM "SignalSource" WHERE enabled GROUP BY "ownerId"`) + if e != nil { + log.Printf("watchers: query failed: %v", e) + return + } + s.mu.Lock() + s.tick++ + tick := s.tick + s.mu.Unlock() + sem := make(chan struct{}, pollConcurrency) + var wg sync.WaitGroup + for _, u := range users { + if ctx.Err() != nil { + break + } + id, _ := u["id"].(string) + if id == "" || s.skipTick(id, tick) { + continue + } + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + s.checkUser(ctx, d, id) + }() + } + wg.Wait() +} + +// skipTick applies per-user exponential backoff: after n consecutive failed +// passes the user is polled on every 2^n-th tick instead of every tick. +func (s *Scheduler) skipTick(id string, tick int) bool { + s.mu.Lock() + defer s.mu.Unlock() + f := s.failures[id] + if f == 0 { + return false + } + shift := min(f, maxBackoffShift) + return tick%(1< 0 { + // Review CR-16: real integration failures (mailbox auth/connection, + // search, insert, checkpoint or status writes) must drive backoff — + // previously they were recorded per source only and the pass counted + // as scheduler success, retrying every tick forever. + s.note(id, false) + log.Printf("watchers: user %s: %d of %d sources failed", id, res.Failed, res.Sources) + return + } + s.note(id, true) + if res.Sources > 0 || res.Created > 0 { + log.Printf("watchers: user %s: %d sources checked, %d signals created", id, res.Sources, res.Created) + } +} + +func (s *Scheduler) note(id string, ok bool) { + s.mu.Lock() + defer s.mu.Unlock() + if ok { + delete(s.failures, id) + } else { + s.failures[id]++ + } +} + +// Browser alerts remain browser-only. Email watchers are checked independently +// of an open tab, with source/external-ID deduplication and a durable per-source +// UID checkpoint on every run. A slow pass delays the next one because the +// ticker drops missed ticks. +func RunLoop(ctx context.Context, d *db.DB) { + tick := time.NewTicker(5 * time.Minute) + defer tick.Stop() + s := NewScheduler() + s.RunOnce(ctx, d) + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + s.RunOnce(ctx, d) + } + } +} diff --git a/internal/signals/signals.go b/internal/signals/signals.go new file mode 100644 index 0000000..76c57e0 --- /dev/null +++ b/internal/signals/signals.go @@ -0,0 +1,443 @@ +package signals + +import ( + "context" + "crypto/tls" + "database/sql" + "errors" + "fmt" + "github.com/andrew/go-computer/internal/config" + crypt "github.com/andrew/go-computer/internal/crypto" + "github.com/andrew/go-computer/internal/db" + "github.com/andrew/go-computer/internal/netguard" + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" + "log" + "math" + "net" + "os" + "sort" + "strings" + "time" +) + +type SignalInput struct { + ExternalID, Title, Summary string + OccurredAt time.Time +} +type Mailbox struct { + Host, Username, Password, Folder string + Port int + TLS bool +} + +// AllowInsecure reports whether plaintext IMAP logins are permitted. Only +// explicit development/test fixtures may set INTEGRATIONS_ALLOW_INSECURE_IMAP; +// production configurations must always authenticate over verified TLS +// (review NET-02). Never set it in production Compose. +func AllowInsecure() bool { return os.Getenv("INTEGRATIONS_ALLOW_INSECURE_IMAP") == "1" } + +// checkTransport refuses password login on a plaintext socket (review NET-02). +// Implicit TLS is the only production transport; there is no STARTTLS +// downgrade path. +func checkTransport(m Mailbox) error { + if !m.TLS && !AllowInsecure() { + return errors.New("signals: refusing plaintext IMAP connection; TLS is required") + } + return nil +} + +// Connect dials through netguard, so user-supplied IMAP hosts cannot point +// the server at internal infrastructure. The 15-second deadline covers +// dial/TLS/login only and is cleared afterwards; later commands are bounded +// by the caller's context (AfterFunc termination), not by a stale absolute +// deadline. +func Connect(ctx context.Context, m Mailbox) (*client.Client, error) { + if e := checkTransport(m); e != nil { + return nil, e + } + if m.Port == 0 { + m.Port = 993 + } + // Keep outbound mailbox traffic on the normal IMAP ports. The fixture + // flags cover loopback fakes (and deliberate LAN deployments) that listen + // on an ephemeral port; production has no reason to dial, say, an SSH or + // database port through the netguard escape hatch. + if !netguard.AllowPrivate() && !AllowInsecure() && m.Port != 993 && m.Port != 143 { + return nil, errors.New("signals: mailbox port must be 143 or 993") + } + if m.Folder == "" { + m.Folder = "INBOX" + } + conn, e := netguard.DialContext(ctx, "tcp", net.JoinHostPort(m.Host, fmt.Sprint(m.Port))) + if e != nil { + return nil, e + } + if e = conn.SetDeadline(time.Now().Add(15 * time.Second)); e != nil { + conn.Close() + return nil, e + } + if m.TLS { + t := tls.Client(conn, &tls.Config{ServerName: m.Host, MinVersion: tls.VersionTLS12}) + if e = t.HandshakeContext(ctx); e != nil { + conn.Close() + return nil, e + } + conn = t + } + c, e := client.New(conn) + if e != nil { + conn.Close() + return nil, e + } + if e = c.Login(m.Username, m.Password); e != nil { + c.Terminate() + return nil, e + } + if e = conn.SetDeadline(time.Time{}); e != nil { + c.Terminate() + return nil, e + } + return c, nil +} +func Search(ctx context.Context, m Mailbox, from, subject string) ([]SignalInput, error) { + c, e := Connect(ctx, m) + if e != nil { + return nil, e + } + stop := context.AfterFunc(ctx, func() { c.Terminate() }) + defer stop() + defer c.Logout() + return SearchConn(c, m, from, subject) +} + +// Checkpoint is the durable per-source ingestion state (review SYNC-02): +// the folder UIDVALIDITY it was recorded against and the highest UID already +// considered. A zero value means "nothing ingested yet". +type Checkpoint struct{ UIDValidity, LastUID uint32 } + +func LoadCheckpoint(ctx context.Context, q db.Queryer, sourceID string) (Checkpoint, error) { + var cp Checkpoint + var validity, last int64 + e := q.QueryRowContext(ctx, `SELECT "uidValidity","lastUid" FROM "SignalCheckpoint" WHERE "sourceId"=$1`, sourceID).Scan(&validity, &last) + if errors.Is(e, sql.ErrNoRows) { + return Checkpoint{}, nil + } + if e != nil { + return Checkpoint{}, e + } + cp.UIDValidity, cp.LastUID = uint32(validity), uint32(last) + return cp, nil +} + +// SaveCheckpoint persists cp. Callers must only advance a checkpoint after +// the batch's signals are durably handled. The update is monotonic (review +// COR-4): when uidValidity is unchanged, lastUid only advances via GREATEST, +// so a slower concurrent pass can never regress the checkpoint and force a +// re-scan of messages already ingested. +func SaveCheckpoint(ctx context.Context, q db.Queryer, sourceID string, cp Checkpoint) error { + _, e := q.ExecContext(ctx, `INSERT INTO "SignalCheckpoint"("sourceId","uidValidity","lastUid","updatedAt") VALUES($1,$2,$3,now()) ON CONFLICT("sourceId") DO UPDATE SET "uidValidity"=EXCLUDED."uidValidity","lastUid"=CASE WHEN "SignalCheckpoint"."uidValidity"=EXCLUDED."uidValidity" THEN GREATEST("SignalCheckpoint"."lastUid",EXCLUDED."lastUid") ELSE EXCLUDED."lastUid" END,"updatedAt"=now()`, sourceID, int64(cp.UIDValidity), int64(cp.LastUID)) + return e +} + +const ( + // searchBatch bounds one UID FETCH round trip. + searchBatch = 50 + // maxIngestBatches caps a single checkpointed pass at 2500 messages per + // source; a later pass continues from the persisted checkpoint. + maxIngestBatches = 50 +) + +// SearchConn runs one bounded search on an established connection and +// reports the most recent matches. It is preview semantics for interactive +// checks ("Check now", legacy mailbox watch): no checkpoint is consulted or +// advanced. Durable ingestion uses SearchConnFrom. An empty criteria would +// match the entire mailbox, so it yields nothing instead. On a mid-pass fetch +// failure it returns the matches already fetched alongside the error. +func SearchConn(c *client.Client, m Mailbox, from, subject string) ([]SignalInput, error) { + events, _, e := search(c, m, from, subject, nil) + return events, e +} + +// SearchConnFrom processes only messages newer than cp and returns the +// checkpoint to persist after — and only after — the returned inputs are +// durably handled (review SYNC-02). A mid-pass fetch failure returns the +// events fetched so far; callers must persist them WITHOUT saving the +// checkpoint, so the failed batch is retried and deduplicated. Batches are processed until the source +// is caught up or the per-pass cap is reached. When the folder's UIDVALIDITY +// changed, the checkpoint resets and the mailbox is re-scanned; Message-ID +// deduplication keeps re-scans from duplicating signals that carry one. +func SearchConnFrom(c *client.Client, m Mailbox, from, subject string, cp Checkpoint) ([]SignalInput, Checkpoint, error) { + events, next, e := search(c, m, from, subject, &cp) + return events, next, e +} + +func search(c *client.Client, m Mailbox, from, subject string, cp *Checkpoint) ([]SignalInput, Checkpoint, error) { + next := Checkpoint{} + if cp != nil { + next = *cp + } + if from == "" && subject == "" { + return []SignalInput{}, next, nil + } + folder := m.Folder + if folder == "" { + folder = "INBOX" + } + status, e := c.Select(folder, true) + if e != nil { + return nil, next, e + } + // Effective checkpoint (review CR-15): exactly one normalized checkpoint + // drives BOTH the UID filtering and the advancement below. When the + // folder's UIDVALIDITY changed, the previous UID space is meaningless — + // the effective checkpoint resets to zero so every UID in the new space + // is (re)ingested, and filtering must never consult the stale LastUID. + eff := Checkpoint{} + if cp != nil { + if cp.UIDValidity == status.UidValidity { + eff = *cp + } + next = eff + } + next.UIDValidity = status.UidValidity + criteria := imap.NewSearchCriteria() + if from != "" { + criteria.Header.Add("From", from) + } + if subject != "" { + criteria.Header.Add("Subject", subject) + } + // Review CR-19: constrain the server-side SEARCH to the UID window that + // can still hold unhandled messages, so a caught-up source does not + // re-transfer its whole match history every pass. "n:*" is inclusive of + // the mailbox's highest UID even when that UID is below n (RFC 3501 + // seq-number semantics), so the defensive local filter below stays — + // the range is an optimization, never the correctness boundary. + if cp != nil && eff.LastUID > 0 && eff.LastUID < math.MaxUint32 { + criteria.Uid = new(imap.SeqSet) + criteria.Uid.AddRange(eff.LastUID+1, 0) // Stop 0 renders as "*" + } + uids, e := c.UidSearch(criteria) + if e != nil { + return nil, next, e + } + sort.Slice(uids, func(i, j int) bool { return uids[i] < uids[j] }) + // truncated reports whether the per-pass cap shortened the UID list. + // Review COR-1: when it did, the SEARCH did NOT consider every matching + // message, so the high-water shortcut must stay off — otherwise UIDs past + // the cap would land below the new checkpoint and be dropped forever. + truncated := false + if cp != nil { + // Incremental ingestion: everything at or below the EFFECTIVE + // checkpoint has already been durably handled by an earlier pass + // (or the UID space was reset and nothing has — review CR-15). + filtered := uids[:0] + for _, uid := range uids { + if uid > eff.LastUID { + filtered = append(filtered, uid) + } + } + uids = filtered + if len(uids) > searchBatch*maxIngestBatches { + uids = uids[:searchBatch*maxIngestBatches] + truncated = true + } + } else if len(uids) > searchBatch { + // Preview mode: the most recent matches, best effort. + uids = uids[len(uids)-searchBatch:] + } + out := []SignalInput{} + for start := 0; start < len(uids); start += searchBatch { + batch := uids[start:min(start+searchBatch, len(uids))] + seq := new(imap.SeqSet) + seq.AddNum(batch...) + ch := make(chan *imap.Message, len(batch)) + done := make(chan error, 1) + go func() { done <- c.UidFetch(seq, []imap.FetchItem{imap.FetchUid, imap.FetchEnvelope}, ch) }() + for msg := range ch { + if msg.Uid > next.LastUID { + next.LastUID = msg.Uid + } + if msg.Envelope == nil { + continue + } + env := msg.Envelope + id := env.MessageId + if id == "" { + id = fmt.Sprintf("%s:%d:%d", folder, status.UidValidity, msg.Uid) + } + names := []string{} + for _, a := range env.From { + names = append(names, a.Address()) + } + out = append(out, SignalInput{ExternalID: id, Title: env.Subject, Summary: strings.Join(names, ", "), OccurredAt: env.Date}) + } + if e := <-done; e != nil { + // Partial results (review follow-up): a fetch can fail after an + // earlier batch (or part of this one) already produced events. + // Return what was found; callers persist it but must NOT advance + // the checkpoint, so the failed batch is retried and deduped. + return out, next, e + } + } + if cp != nil && !truncated && status.UidNext > next.LastUID+1 { + // The SEARCH considered every message in the folder (the cap did not + // shorten it), so non-matching UIDs below the mailbox high-water + // mark can be skipped for good; anything arriving after this snapshot + // stays above the checkpoint. Review COR-1: this shortcut is OFF + // after truncation — remaining matches get a future pass instead of + // being skipped forever. + next.LastUID = status.UidNext - 1 + } + return out, next, nil +} + +func LoadMailbox(ctx context.Context, d *db.DB, user string) (Mailbox, error) { + v, e := db.One(ctx, d, `SELECT to_jsonb(m) FROM "ImapConfig" m WHERE "userId"=$1`, user) + if e != nil { + return Mailbox{}, e + } + s := func(k string) string { v, _ := v[k].(string); return v } + cfg := config.Load() + pw, e := crypt.Decrypt(s("password"), cfg.EncryptionSecret()) + if e != nil { + // Review OPS-01: rows encrypted before ENCRYPTION_KEY existed used the + // auth-secret-derived key. Decrypt with the legacy key and re-encrypt + // under the dedicated key so the migration completes transparently. + legacy, legacyErr := crypt.Decrypt(s("password"), cfg.Secret()) + if legacyErr != nil { + return Mailbox{}, e + } + pw = legacy + if cfg.EncryptionSecret() != cfg.Secret() { + if enc, encErr := crypt.Encrypt(pw, cfg.EncryptionSecret()); encErr == nil { + // Review CR-21: compare-and-swap against the EXACT ciphertext + // this pass decrypted. A password saved concurrently between + // the read and this write must win; zero updated rows means + // exactly that, not permission to overwrite. + res, updErr := d.ExecContext(ctx, `UPDATE "ImapConfig" SET password=$1 WHERE "userId"=$2 AND password=$3`, enc, user, s("password")) + if updErr != nil { + log.Printf("imap: re-encrypt stored password under ENCRYPTION_KEY: %v", updErr) + } else if n, _ := res.RowsAffected(); n == 0 { + log.Printf("imap: stored password changed during key migration; keeping the newer value") + } + } + } + } + port, _ := v["port"].(float64) + tls, _ := v["tls"].(bool) + return Mailbox{s("host"), s("username"), pw, s("folder"), int(port), tls}, nil +} +func PublicSourceTypes() []db.Object { + return []db.Object{{"type": "email", "label": "Email (IMAP)", "description": "Search your configured mailbox for matching messages", "stub": false, "configFields": []db.Object{{"key": "from", "label": "From", "placeholder": "sender@domain.com"}, {"key": "subject", "label": "Subject", "placeholder": "Subject contains"}}}} +} + +// CheckResult is the structured outcome of one signal pass (review CR-16). +// Sources counts the sources considered, Created the signals inserted, and +// Failed the sources whose check did not complete successfully (mailbox +// connection/search failure, signal insert failure, checkpoint failure or +// status-write failure). Per-source detail is persisted on +// SignalSource.lastStatus for the UI. The returned error is reserved for +// pass-level infrastructure failures (loading the source list): per-source +// failures live in Failed so scheduler backoff reacts to real integration +// failures while a manual check still answers 200 with per-source outcomes. +type CheckResult struct{ Sources, Created, Failed int } + +func RunSignalChecks(ctx context.Context, d *db.DB, user, bookmark string) (CheckResult, error) { + res := CheckResult{} + sources, e := db.Many(ctx, d, `SELECT to_jsonb(s) FROM "SignalSource" s WHERE "ownerId"=$1 AND enabled AND ($2='' OR "bookmarkId"=$2)`, user, bookmark) + if e != nil { + return res, e + } + res.Sources = len(sources) + if len(sources) == 0 { + return res, nil + } + // Load and connect once for the whole pass; every source of a user shares + // the same mailbox credentials. + m, connErr := LoadMailbox(ctx, d, user) + var c *client.Client + if connErr == nil { + c, connErr = Connect(ctx, m) + if connErr == nil { + stop := context.AfterFunc(ctx, func() { c.Terminate() }) + defer stop() + defer c.Logout() + } + } + for _, src := range sources { + sourceID := fmt.Sprint(src["id"]) + status := "Checked" + events := []SignalInput{} + e := connErr + var next Checkpoint + if e == nil { + cfg, _ := src["config"].(map[string]any) + from, _ := cfg["from"].(string) + subject, _ := cfg["subject"].(string) + // Incremental, checkpointed ingestion (review SYNC-02): only UIDs + // newer than the durable checkpoint are processed, in bounded + // batches, until the source is caught up. Review COR-3: a failed + // checkpoint load is surfaced as an error for this source instead + // of silently degrading to a zero checkpoint (full re-scan). + var cp Checkpoint + if cp, e = LoadCheckpoint(ctx, d, sourceID); e == nil { + events, next, e = SearchConnFrom(c, m, from, subject, cp) + } + } + // Persist whatever events this source produced. On a partial search + // failure the caller still inserts them; the checkpoint stays put so + // the failed batch is retried and ON CONFLICT dedupes the rest. + insertEvents := func() error { + for _, s := range events { + when := s.OccurredAt + if when.IsZero() { + when = time.Now() + } + r, err := d.ExecContext(ctx, `INSERT INTO "Signal"(id,"sourceType",title,summary,"externalId","occurredAt","sourceId","bookmarkId","ownerId") VALUES($1,'email',$2,$3,$4,$5,$6,$7,$8) ON CONFLICT("sourceId","externalId") DO NOTHING`, db.NewID(), s.Title, s.Summary, s.ExternalID, when, src["id"], src["bookmarkId"], user) + if err != nil { + return err + } + n, _ := r.RowsAffected() + res.Created += int(n) + } + return nil + } + if e != nil { + // Connection/search failures are recorded per source (lastStatus) + // and counted in Failed (review CR-16) — they are NOT folded into + // the pass-level error, so a manual check still answers 200 with + // the per-source outcome and db.ErrNotFound from a missing + // mailbox configuration cannot surface as a misleading 404. The + // scheduler reads Failed and backs off on repeated failures. + status = "Error: mailbox connection or search failed" + res.Failed++ + // Review follow-up: a search that failed partway may still have + // found real messages. Keep them instead of discarding the whole + // pass; the checkpoint is deliberately not saved below. + if len(events) > 0 { + if err := insertEvents(); err != nil { + status = "Error: signal insert failed" + } + } + } else if err := insertEvents(); err != nil { + // Review COR-5: record the per-source failure in lastStatus + // and continue with the remaining sources instead of aborting + // the whole pass. The checkpoint does NOT advance, so the + // failed event and the rest of the batch are retried next + // pass (ON CONFLICT dedupes the ones already inserted). + status = "Error: signal insert failed" + res.Failed++ + } else if err := SaveCheckpoint(ctx, d, sourceID, next); err != nil { + // Advance the checkpoint only after the batch is durably handled. + status = "Error: checkpoint save failed" + res.Failed++ + } + // Record this source's status even on error so the UI can surface it. + if _, err := d.ExecContext(ctx, `UPDATE "SignalSource" SET "lastCheckedAt"=now(),"lastStatus"=$1,"updatedAt"=now() WHERE id=$2`, status, src["id"]); err != nil { + res.Failed++ + } + } + return res, nil +} diff --git a/internal/signals/signals_test.go b/internal/signals/signals_test.go new file mode 100644 index 0000000..9dd8608 --- /dev/null +++ b/internal/signals/signals_test.go @@ -0,0 +1,31 @@ +package signals + +import ( + "context" + "strings" + "testing" + "time" +) + +// Review NET-02: mailbox credentials must never travel over a plaintext +// socket unless the explicit development fixture flag is set. +func TestPlaintextTransportRejected(t *testing.T) { + t.Setenv("INTEGRATIONS_ALLOW_INSECURE_IMAP", "") + if e := checkTransport(Mailbox{TLS: false}); e == nil { + t.Fatal("plaintext mailbox accepted without the development flag") + } + if e := checkTransport(Mailbox{TLS: true}); e != nil { + t.Fatalf("TLS mailbox rejected: %v", e) + } + // The guard runs before any dial, so Connect fails fast without network. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, e := Connect(ctx, Mailbox{Host: "127.0.0.1", Port: 1, TLS: false}) + if e == nil || !strings.Contains(e.Error(), "TLS is required") { + t.Fatalf("plaintext Connect was not refused up front: %v", e) + } + t.Setenv("INTEGRATIONS_ALLOW_INSECURE_IMAP", "1") + if e := checkTransport(Mailbox{TLS: false}); e != nil { + t.Fatalf("development fixture flag did not permit plaintext: %v", e) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..68d6134 --- /dev/null +++ b/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "errors" + "github.com/andrew/go-computer/internal/config" + "github.com/andrew/go-computer/internal/db" + "github.com/andrew/go-computer/internal/http" + "github.com/andrew/go-computer/internal/scripts" + "github.com/andrew/go-computer/internal/signals" + "log" + stdhttp "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "--healthcheck" { + client := stdhttp.Client{Timeout: 3 * time.Second} + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + res, err := client.Get("http://127.0.0.1:" + port + "/readyz") + if err != nil { + os.Exit(1) + } + res.Body.Close() + if res.StatusCode != 200 { + os.Exit(1) + } + return + } + cfg := config.Load() + if cfg.Secret() == "" { + log.Fatal("config: AUTH_SECRET (or NEXTAUTH_SECRET) must be set") + } + d, err := db.New(cfg) + if err != nil { + log.Fatalf("db: %v", err) + } + defer d.Close() + if len(os.Args) > 1 && os.Args[1] == "--seed" { + if err := scripts.Seed(context.Background(), d); err != nil { + log.Fatal(err) + } + return + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + // Shutdown order is owned by main (review OPS-01): drain in-flight HTTP + // requests first, then await the background loops, and only afterwards let + // the deferred db close run. Previously Shutdown raced ListenAndServe's + // return and the process exited mid-drain. + workersDone := make(chan struct{}) + go func() { + defer close(workersDone) + signals.RunLoop(ctx, d) + }() + srv := http.NewServer(cfg, d) + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, stdhttp.ErrServerClosed) { + log.Fatalf("server failed: %v", err) + } + }() + log.Printf("computer API listening on %s", srv.Addr) + <-ctx.Done() + stop() + shutdown, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(shutdown); err != nil { + log.Printf("shutdown: %v", err) + } + <-workersDone +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..66b1a27 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Deploy the computer stack to a target server of your choice. +# +# Run from anywhere in the repository on your workstation. The deployment +# target is explicit — there are no built-in host or domain defaults: +# +# HOST=app-server DOMAIN=computer.example.tld CERTBOT_EMAIL=ops@example.tld \ +# ./scripts/deploy.sh (or: make deploy with the same variables) +# +# Flow: record the exact git revision, rsync the source tree to a UNIQUE +# staging directory on the server, then run scripts/server-deploy.sh there +# under sudo (one password prompt). The server script takes a deployment +# lock (flock) BEFORE publishing the staged tree into the shared release +# directory, provisions packages, backs up the database, builds the Compose +# stack (images are tagged with the revision), configures nginx + Let's +# Encrypt from deploy/nginx/computer.conf.template, and records the revision +# marker only after everything passed (review CR-11: overlapping deploys can +# no longer mix source and release state — synchronization targets are +# per-release, mutation of the shared directory is serialized, and the last +# successful marker survives failures). +# +# Review OPS-04: a release is a known revision — dirty working trees are +# refused unless ALLOW_DIRTY=1 acknowledges them, the revision is recorded on +# the server (.deployed-revision) and the built images are tagged with it. +# Rollback: re-deploy an older revision (git checkout && ./scripts/deploy.sh, +# which reuses/rebuilds that known source), or restore one of the last 15 +# pre-deploy pg_dumps (+ its .env secret snapshot) from /var/backups/computer +# on the server. Database migrations are additive. +# +# Requirements: ssh access to HOST (an ssh config alias works well), rsync and +# git locally. DNS for DOMAIN must already point at the server (certbot +# HTTP-01). Optional overrides: REMOTE_DIR (default /var/www/computer), +# ALLOW_DIRTY=1. +set -euo pipefail + +usage="usage: HOST=ssh-host DOMAIN=app.domain.tld CERTBOT_EMAIL=ops@domain.tld ./scripts/deploy.sh" +HOST="${HOST:?${usage}}" +DOMAIN="${DOMAIN:?${usage}}" +CERTBOT_EMAIL="${CERTBOT_EMAIL:?${usage}}" +REMOTE_DIR="${REMOTE_DIR:-/var/www/computer}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +# --- 0. Record the revision being deployed (review OPS-04) ---------------------- +REV="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null)" || die "git is required to deploy a known revision" +if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then + [ "${ALLOW_DIRTY:-0}" = "1" ] || die "working tree has uncommitted changes; commit or stash them so the release is a known revision (ALLOW_DIRTY=1 overrides)" + REV="$REV-dirty" + log "warning: deploying a DIRTY working tree as $REV" +fi +log "Deploying revision $REV" + +REMOTE_USER="$(ssh -G "$HOST" | awk '/^user /{print $2; exit}')" +[ -n "$REMOTE_USER" ] || die "could not determine ssh user for $HOST" +command -v rsync >/dev/null 2>&1 || die "rsync is required locally (e.g. sudo apt install rsync / sudo pacman -S rsync)" + +# --- 1. Prepare the remote directories (may prompt for your sudo password) ------ +# The release directory is shared and mutable; this deploy's staging directory +# is unique (rev + local pid), so overlapping rsyncs never touch shared +# release inputs (review CR-11). +STAGING_ROOT="$REMOTE_DIR-staging" +STAGE="$STAGING_ROOT/$REV-$$" +log "Ensuring $HOST:$REMOTE_DIR and $STAGING_ROOT exist and belong to $REMOTE_USER" +ssh -t "$HOST" "sudo sh -c 'mkdir -p \"$REMOTE_DIR\" \"$STAGING_ROOT\" && chown \"$REMOTE_USER:\" \"$REMOTE_DIR\" \"$STAGING_ROOT\"'" + +# --- 2. Sync the source tree to this deploy's staging directory ---------------- +log "Syncing repository to $HOST:$STAGE" +# --delete mirrors the repo into the (empty) stage; excluded files (a local +# .env, build output) never ship. The server-side publish step protects the +# server's own .env and release marker. +rsync -az --delete \ + --exclude '.git' \ + --exclude '.env' \ + --exclude '.env.*' \ + --exclude '.deployed-revision' \ + --exclude 'node_modules' \ + --exclude 'web/.next' \ + --exclude 'web/out' \ + --exclude 'web/*.tsbuildinfo' \ + --exclude '*.log' \ + "$REPO_ROOT/" "$HOST:$STAGE/" + +# --- 3. Run the server-side deploy as root (may prompt for your sudo password) -- +# The STAGED copy of the script runs (not the old one in $REMOTE_DIR): it +# takes the server-side deployment lock, publishes the stage into the shared +# release directory, and records .deployed-revision only after everything +# passed — a failed deploy preserves the last successful release marker +# (review CR-11/OPS-04). +log "Running server-side deploy (lock + publish + backup + build + nginx + TLS)" +ssh -t "$HOST" "sudo bash '$STAGE/scripts/server-deploy.sh' '$REMOTE_DIR' '$DOMAIN' '$CERTBOT_EMAIL' '$REMOTE_USER' '$REV' '$STAGE'" + +# --- 4. Final check from this machine --------------------------------------------- +log "Verifying https://$DOMAIN/login from this machine" +if curl -fsS -o /dev/null --max-time 15 "https://$DOMAIN/login"; then + log "Deploy complete: https://$DOMAIN (revision $REV)" +else + cat >&2 < { + try { + return execFileSync('docker', ['compose', 'ps', '--status', 'running', '-q', 'db'], { cwd: repoRoot, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() !== '' + } catch { return false } +} +if (!loopback) { + if (process.env.E2E_ALLOW_REMOTE !== '1') { + console.error(`Refusing to run against non-local target ${base}: cleanup is tied to the local compose database. Use the local stack, or set E2E_ALLOW_REMOTE=1 together with E2E_KEEP_DATA=1 to accept that QA accounts will persist on the remote target.`) + process.exit(1) + } + if (!keepData) { + console.error(`Remote target ${base} cannot be cleaned up through local docker; set E2E_KEEP_DATA=1 to acknowledge created accounts will persist.`) + process.exit(1) + } +} +if (!keepData && !localDbRunning()) { + console.error('Cleanup requires the local compose db service to be running (docker compose up -d db); refusing to create QA accounts that could not be removed.') + process.exit(1) +} +const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', headless: true, args: ['--no-sandbox'] }) +const accounts = [] +const errors = [] +let testFailed = false +const password = 'qa-test-long-password' +const email = `qa-${Date.now()}@computer.test` +const context = await browser.newContext() +const page = await context.newPage() +page.on('pageerror', error => errors.push(error.message)) + +async function json(response, status = 200) { + assert.equal(response.status(), status, `${response.url()}: ${await response.text()}`) + return response.json() +} +try { + // Review CR-03: every accepted registration answers with the SAME uniform + // {ok:true} body — the account id is private until authentication + // establishes ownership, so it comes from /api/me after login. + const signup = await json(await context.request.post(`${base}/api/signup`, { data: { email, password, name: 'Browser QA' } }), 201) + assert.deepEqual(signup, { ok: true }) + await page.goto(`${base}/login`) + await page.getByPlaceholder('Email', { exact: true }).fill(email) + await page.getByPlaceholder('Password', { exact: true }).fill(password) + await page.getByRole('button', { name: 'Sign in with Email', exact: true }).click() + await page.waitForURL('**/pages') + await page.getByRole('heading', { name: 'No pages yet', exact: true }).waitFor() + const me = await json(await context.request.get(`${base}/api/me`)) + accounts.push(me.user.id) + // Target/cleanup identity preflight (review TEST-03): the account created + // through BASE_URL must exist in the local compose database that cleanup + // deletes from. If it does not, the browser target is backed by some other + // database — abort before creating further data. + if (!keepData && loopback) { + assert.match(me.user.id, /^[a-z0-9]+$/i) + const found = execFileSync('docker', ['compose', 'exec', '-T', 'db', 'psql', '-U', 'computer', '-d', 'computer', '-Atc', + `SELECT count(*) FROM "User" WHERE id='${me.user.id}';`], { cwd: repoRoot, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() + if (found !== '1') { + throw new Error(`preflight failed: QA account ${me.user.id} created through ${base} is not present in the local compose database; target and cleanup environments differ (remove it on the target environment)`) + } + console.log('PASS: browser target and cleanup database are the same stack') + } + await page.getByRole('button', { name: 'New', exact: true }).first().click() + const dialog = page.getByRole('dialog') + await dialog.getByPlaceholder('https://...').fill('https://example.org/browser-qa') + await dialog.getByPlaceholder('Auto-filled from URL').fill('Browser QA bookmark') + // Wait for the create to land server-side; navigating earlier cancels the + // in-flight POST (Go fetches the preview before inserting, so a cancelled + // request context aborts creation). + const saved = page.waitForResponse(r => r.url().includes('/api/bookmarks') && r.request().method() === 'POST' && r.status() === 201) + await dialog.getByRole('button', { name: 'Save Bookmark', exact: true }).click() + await saved + await page.goto(`${base}/bookmarks`) + await page.getByText('Browser QA bookmark', { exact: true }).first().waitFor() + let bookmarks = await json(await context.request.get(`${base}/api/bookmarks`)) + const bookmark = bookmarks.find(b => b.title === 'Browser QA bookmark') + assert.ok(bookmark?.id, 'UI-created bookmark was not persisted by Go') + await page.getByText('Browser QA bookmark', { exact: true }).first().click() + await page.getByRole('link', { name: 'Download Markdown', exact: true }).waitFor() + // Optimistic concurrency (review CR-04): the PUT carries the version it edited from. + const update = await json(await context.request.put(`${base}/api/bookmarks/${bookmark.id}`, { data: { notes: '# Browser verified', isPublic: true, version: bookmark.version } })) + assert.equal(update.notes, '# Browser verified') + // Notion-style Quick Find (Cmd/Ctrl+K): open, dismiss, then jump straight + // to this bookmark's detail sheet through the ?open= deep link. + await page.keyboard.press('Escape') + await page.getByRole('link', { name: 'Download Markdown', exact: true }).waitFor({ state: 'hidden' }) + await page.keyboard.press('Control+k') + const quickFind = page.getByPlaceholder('Search pages, bookmarks, tags, and commands…') + await quickFind.waitFor() + await page.keyboard.press('Escape') + await quickFind.waitFor({ state: 'hidden' }) + await page.keyboard.press('Control+k') + await quickFind.waitFor() + await quickFind.fill('Browser QA') + await page.getByRole('option', { name: /Browser QA bookmark/ }).first().click() + await page.waitForURL('**/bookmarks?open=*') + await page.getByRole('link', { name: 'Download Markdown', exact: true }).waitFor() + console.log('PASS: Quick Find opens with Ctrl+K and deep-links to the bookmark sheet') + const publicContext = await browser.newContext() + const publicPage = await publicContext.newPage() + publicPage.on('pageerror', error => errors.push(error.message)) + await publicPage.goto(`${base}/share/bookmark/${bookmark.id}`) + await publicPage.getByText('Browser QA bookmark', { exact: true }).first().waitFor() + // Privacy field assertions (review PRIV-04): the public API response is an + // allowlist — no owner attribution, no contact/recipient/operational data. + const publicBody = await json(await publicContext.request.get(`${base}/api/public/bookmarks/${bookmark.id}`)) + assert.equal(publicBody.title, 'Browser QA bookmark') + assert.equal('# Browser verified', publicBody.notes, 'public link must publish notes (documented behavior)') + for (const key of ['owner', 'ownerId', 'contact', 'contactId', 'sharedWith', 'imapQuery', 'imapWatchEnabled', 'alertAt', 'alertSent', 'kanbanColumnId', 'kanbanPosition', 'kanbanStatus', 'pageLinks']) { + assert.equal(key in publicBody, false, `public response must not include ${key}`) + } + console.log('PASS: public response matches the documented privacy policy (no owner attribution)') + await publicContext.close() + await page.goto(`${base}/kanban`) + await page.getByRole('heading', { name: 'Kanban Board' }).waitFor() + await page.getByText('Browser QA bookmark', { exact: true }).waitFor() + const columns = await json(await context.request.get(`${base}/api/kanban/columns`)) + assert.equal(columns.length, 4) + const custom = await json(await context.request.post(`${base}/api/kanban/columns`, { data: { label: 'QA done' } }), 201) + await json(await context.request.put(`${base}/api/kanban/cards/${bookmark.id}`, { data: { columnId: custom.id, position: 0, version: update.version } })) + const moved = await json(await context.request.delete(`${base}/api/kanban/columns/${custom.id}`)) + assert.equal(moved.moved, 1) + await page.goto(`${base}/pages`) + await page.locator('main').getByRole('button', { name: 'New page', exact: true }).click() + await page.getByRole('textbox', { name: 'Page title' }).waitFor() + await page.getByPlaceholder('Write Markdown notes… type / for blocks, paste links, lists, thoughts.').fill('# QA page\n\nAutosaved markdown.') + await page.getByText('Saved', { exact: true }).waitFor() + const pages = await json(await context.request.get(`${base}/api/pages`)) + const detail = await json(await context.request.get(`${base}/api/pages/${pages[0].id}`)) + assert.equal(detail.content, '# QA page\n\nAutosaved markdown.') + // Notion-style slash menu: "/h2" at the start of a line inserts a heading. + const editor = page.getByPlaceholder('Write Markdown notes… type / for blocks, paste links, lists, thoughts.') + await editor.click() + await page.keyboard.press('Control+End') + await page.keyboard.press('Enter') + await page.keyboard.type('/h2') + await page.getByRole('option', { name: /Heading 2/ }).click() + await editor.evaluate((el, suffix) => new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`slash command did not insert heading: ${JSON.stringify(el.value)}`)), 5000) + const check = setInterval(() => { if (el.value.endsWith(suffix)) { clearTimeout(timeout); clearInterval(check); resolve() } }, 25) + }), '\n## ') + console.log('PASS: slash command menu inserts Markdown blocks') + await json(await context.request.patch(`${base}/api/pages/${pages[0].id}`, { data: { addBookmarkId: bookmark.id } })) + const markdown = await context.request.get(`${base}/api/pages/${pages[0].id}/markdown`) + assert.equal(markdown.status(), 200) + assert.ok((await markdown.text()).includes('Browser QA bookmark')) + // Collections (Notion-like upgrades phase 2, validation scenario): an + // existing bookmark joins a collection without duplication, gets a + // collection-scoped status, and the same membership is driven from both + // the table view and a board view. + await page.goto(`${base}/collections`) + await page.getByRole('heading', { name: 'Collections', exact: true }).waitFor() + await page.getByLabel('New collection name').fill('Research') + await page.getByRole('button', { name: 'Create collection' }).click() + await page.getByText('Collection created').waitFor() + await page.getByRole('button', { name: 'Add bookmarks' }).click() + await page.getByLabel('Search bookmarks').fill('Browser QA') + await page.getByRole('button', { name: 'Add', exact: true }).first().click() + await page.keyboard.press('Escape') + await page.getByText('Browser QA bookmark', { exact: true }).first().waitFor() + await page.getByRole('button', { name: 'Property' }).click() + await page.getByLabel('Property name').fill('Status') + await page.getByRole('button', { name: 'Option', exact: true }).click() + await page.getByLabel('Option label').first().fill('Backlog') + await page.getByRole('button', { name: 'Option', exact: true }).click() + await page.getByLabel('Option label').nth(1).fill('Doing') + await page.getByRole('button', { name: 'Create property' }).click() + await page.getByText('Property added').waitFor() + await page.getByLabel('Status', { exact: true }).first().click() + await page.getByRole('option', { name: 'Doing' }).click() + await page.getByRole('button', { name: 'Add view' }).click() + await page.getByLabel('View name').fill('Board') + await page.getByLabel('View type').click() + await page.getByRole('option', { name: 'Board' }).click() + await page.getByRole('button', { name: 'Create view' }).click() + await page.getByText('View added').waitFor() + await page.getByLabel('Group by property').click() + await page.getByRole('option', { name: 'Status' }).click() + await page.getByLabel('Move Browser QA bookmark').getByText('Doing').waitFor() + // Moving the card on the board and reading it back in the table proves both + // views edit one membership, not copies. + const boardCard = page.locator('[data-collection-item-id]').filter({ hasText: 'Browser QA bookmark' }) + const backlogColumn = page.locator('[data-board-group]').filter({ hasText: 'Backlog' }) + const movedMembership = page.waitForResponse(r => r.url().includes('/items/') && r.request().method() === 'PATCH' && r.status() === 200) + await boardCard.locator('[data-drag-handle]').dragTo(backlogColumn) + await movedMembership + await page.getByRole('button', { name: 'Table', exact: true }).click() + await page.getByLabel('Status', { exact: true }).first().getByText('Backlog').waitFor() + const membership = await json(await context.request.get(`${base}/api/collections`)) + assert.equal(membership[0]._count.items, 1, 'bookmark count after membership add') + console.log('PASS: collections — membership without duplication, typed properties, table+board over one membership') + for (const [path, heading] of [['bookmarks', 'All Bookmarks'], ['tags', 'Tags'], ['calendar', 'Calendar'], ['collections', 'Collections'], ['contacts', 'Contacts'], ['settings', 'Settings']]) { + await page.goto(`${base}/${path}`) + await page.getByRole('heading', { name: heading, exact: true }).first().waitFor() + } + assert.deepEqual(errors, [], 'Browser runtime errors') + await json(await context.request.delete(`${base}/api/bookmarks/${bookmark.id}`)) + console.log('PASS: browser login, Go-backed bookmark create/edit/delete, public sharing, quick find deep links, kanban, collections, pages/autosave/slash-commands/export, all application screens') +} catch (error) { + testFailed = true + throw error +} finally { + await browser.close() + if (!keepData && accounts.length) { + // Cleanup failures must not mask the real test error; log them and fail + // the run only when the tests themselves passed. + try { + assert.ok(accounts.every(id => /^[a-z0-9]+$/i.test(id))) + execFileSync('docker', ['compose', 'exec', '-T', 'db', 'psql', '-U', 'computer', '-d', 'computer', '-v', 'ON_ERROR_STOP=1', '-c', `DELETE FROM "User" WHERE id IN (${accounts.map(id => `'${id}'`).join(',')});`], { cwd: repoRoot, stdio: 'inherit' }) + } catch (cleanupError) { + console.error(`QA account cleanup failed (${accounts.join(',')}); delete manually.`, cleanupError.message) + if (!testFailed) process.exitCode = 1 + } + } +} diff --git a/scripts/package-lock.json b/scripts/package-lock.json new file mode 100644 index 0000000..01625ae --- /dev/null +++ b/scripts/package-lock.json @@ -0,0 +1,59 @@ +{ + "name": "scripts", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "playwright": "1.58.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..6114fc4 --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "type": "module", + "scripts": { "test": "node e2e.mjs", "test:regressions": "node regressions.mjs" }, + "devDependencies": { "playwright": "1.58.2" } +} diff --git a/scripts/regressions.mjs b/scripts/regressions.mjs new file mode 100644 index 0000000..9aa4bc2 --- /dev/null +++ b/scripts/regressions.mjs @@ -0,0 +1,500 @@ +import assert from 'node:assert/strict' +import http from 'node:http' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' + +// The real built Next server/UI with isolated API fixtures: no app users or DB writes. +const pages = new Map(['a', 'b'].map((id, position) => [id, { id, title: `Page ${id.toUpperCase()}`, content: '', version: 1, position, pinned: false, bookmarks: [] }])) +const tags = [{ id: 'one', name: 'First tag', color: '#123456' }, { id: 'two', name: 'Second tag', color: '#654321' }] +const kanbanColumns = [ + { id: 'inbox', label: 'Inbox', color: '#123456', key: 'INBOX', position: 0 }, + { id: 'todo', label: 'To Do', color: '#abcdef', key: 'TODO', position: 1 }, +] +const bookmarks = Array.from({ length: 205 }, (_, i) => ({ + id: `bookmark-${i}`, ownerId: 'fixture-user', title: `Fixture ${String(i).padStart(3, '0')}`, url: 'https://127.0.0.1/test', + description: '', notes: '', tags: [{ tag: tags[0] }], sharedWith: [], kanbanStatus: 'INBOX', kanbanColumnId: 'inbox', kanbanPosition: i, + isPublic: false, imapWatchEnabled: false, alertAt: null, dueDate: null, version: 0, createdAt: '2026-10-07T00:00:00Z', updatedAt: '2026-10-07T00:00:00Z', +})) +let failSaves = false +// Review CR-02: forces Go's signout endpoint to fail so the browser test can +// prove a revocation failure is reported accurately and local sign-out still +// completes. +let failSignout = false +// Fails only saves whose body.content matches (deterministic mid-loop +// failure for the DATA-07 envelope test — no racing the serial queue). +let failContent = null +// Mirrors the proxy's streaming body cap (web/app/api/[...path]/route.ts). +const MAX_PROXY_BODY = 1 << 20 +// Records Authorization headers of POST /api/auth/signout so the browser +// sign-out test can prove the Next server reached Go's revocation endpoint. +const signouts = [] +let delaySaves = 0 +let activeSaves = 0 +let maxConcurrentSaves = 0 +let paginatedRequests = 0 +// Board moves are recorded so the browser test can prove a drag reached the +// API with the card's expected version/position. +const kanbanMoves = [] +// Counts every PATCH /api/pages/* attempt (including 409/500 rejections) so +// tests can prove a paused conflict issues no further saves (review TEST-01). +let patchAttempts = 0 +const api = http.createServer(async (req, res) => { + try { + let raw = '' + for await (const chunk of req) raw += chunk + const body = raw ? JSON.parse(raw) : {} + const url = new URL(req.url, 'http://fixture') + let result = [] + if (url.pathname === '/api/auth/login') result = { user: { id: 'fixture-user', name: 'Fixture', email: body.email, role: 'USER' }, tokenVersion: 1 } + else if (url.pathname === '/api/auth/signout' && req.method === 'POST') { + signouts.push(req.headers.authorization || '') + if (failSignout) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end('{"error":"Fixture revocation failed"}'); return } + result = { ok: true } + } + else if (url.pathname === '/api/me') result = { user: { id: 'fixture-user' }, effectiveUser: { id: 'fixture-user' }, isAdmin: false, viewingAs: null } + // The list endpoint is a metadata projection: content bodies stay out (PERF-01). + else if (url.pathname === '/api/pages') { + if (req.method === 'PUT') { + for (const [position, id] of (body.order || []).entries()) { + const page = pages.get(id) + if (page) page.position = position + } + result = { ok: true } + } else { + result = [...pages.values()].sort((a, b) => a.position - b.position).map(({ content, ...summary }) => summary) + } + } + else if (url.pathname.startsWith('/api/pages/')) { + const id = url.pathname.split('/')[3] + if (req.method === 'PATCH') { + patchAttempts++ + if (failSaves) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end('{"error":"Fixture save failed"}'); return } + if (failContent !== null && body.content === failContent) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end('{"error":"Fixture save failed"}'); return } + const target = pages.get(id) + // Optimistic concurrency (DATA-01): a stale base version is a 409. + if (body.version !== undefined && body.version !== target.version) { + res.writeHead(409, { 'Content-Type': 'application/json' }) + res.end('{"error":"Page was modified elsewhere; reload to see the latest version"}') + return + } + activeSaves++ + maxConcurrentSaves = Math.max(maxConcurrentSaves, activeSaves) + await new Promise(resolve => setTimeout(resolve, delaySaves)) + const { version, ...data } = body + Object.assign(target, data) + if ('content' in body) target.version++ + activeSaves-- + } + result = pages.get(id) + } else if (url.pathname === '/api/bookmarks') { + const shared = url.searchParams.get('shared') === 'true' + const matching = shared ? [] : bookmarks.filter(b => b.title.includes(url.searchParams.get('search') || '')) + const offset = Number(url.searchParams.get('cursor') || 0) + const limit = Number(url.searchParams.get('limit') || 200) + // Mirror the Go permission-specific projections (PRIV-02): shared + // readers get the narrowed allowlist shape, owners the full row. + const project = b => shared ? (({ contact, contactId, sharedWith, alertAt, alertSent, imapQuery, imapWatchEnabled, kanbanColumnId, kanbanPosition, ...rest }) => rest)(b) : b + result = matching.slice(offset, offset + limit).map(project) + if (offset + limit < matching.length) res.setHeader('X-Next-Cursor', String(offset + limit)) + if (offset) paginatedRequests++ + } else if (url.pathname === '/api/tags') result = tags + else if (url.pathname === '/api/kanban/columns') result = kanbanColumns + else if (/^\/api\/kanban\/cards\/[^/]+$/.test(url.pathname) && req.method === 'PUT') { + const bookmark = bookmarks.find(b => b.id === url.pathname.split('/')[4]) + if (!bookmark) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end('{"error":"Not found"}') + return + } + if (body.version === undefined) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end('{"error":"version is required when moving a card"}') + return + } + if (body.version !== bookmark.version) { + res.writeHead(409, { 'Content-Type': 'application/json' }) + res.end('{"error":"Bookmark was modified elsewhere"}') + return + } + const column = kanbanColumns.find(c => c.id === body.columnId) + if (!column) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end('{"error":"Invalid columnId"}') + return + } + const target = bookmarks + .filter(b => b.id !== bookmark.id && b.kanbanColumnId === column.id) + .sort((a, b) => a.kanbanPosition - b.kanbanPosition || b.updatedAt.localeCompare(a.updatedAt)) + const position = Math.max(0, Math.min(Number(body.position) || 0, target.length)) + target.splice(position, 0, bookmark) + bookmark.kanbanColumnId = column.id + bookmark.version++ + target.forEach((b, i) => { b.kanbanPosition = i }) + kanbanMoves.push({ id: bookmark.id, columnId: column.id, position, version: body.version }) + result = bookmark + } else if (/^\/api\/bookmarks\/[^/]+$/.test(url.pathname)) { + const bookmark = bookmarks.find(b => b.id === url.pathname.split('/')[3]) + if (req.method === 'PUT') { + // Review CR-04: mirror Go's bookmark concurrency contract — version + // is required (400), a stale base is 409 with the row untouched, + // success bumps the revision. + if (body.version === undefined) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end('{"error":"version is required when updating a bookmark; refetch it and retry"}') + return + } + if (body.version !== bookmark.version) { + res.writeHead(409, { 'Content-Type': 'application/json' }) + res.end('{"error":"Bookmark was modified elsewhere; reload to see the latest version"}') + return + } + const { tagIds, version, ...data } = body + Object.assign(bookmark, data) + if (tagIds) bookmark.tags = tagIds.map(id => ({ tag: tags.find(tag => tag.id === id) })) + bookmark.version++ + } + result = bookmark + } else if (url.pathname.endsWith('/signals')) result = { sources: [], signals: [], types: [] } + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(result)) + } catch (error) { res.writeHead(500); res.end(String(error)) } +}) +await new Promise(resolve => api.listen(0, '127.0.0.1', resolve)) +// The free-port probe is TOCTOU-racy: another process can grab the port +// between close and `next start`. Retry up to three ports before giving up. +let base = '' +let web = null +let logs = '' +for (let attempt = 1; attempt <= 3; attempt++) { + const portProbe = http.createServer() + await new Promise(resolve => portProbe.listen(0, '127.0.0.1', resolve)) + const port = portProbe.address().port + await new Promise(resolve => portProbe.close(resolve)) + base = `http://localhost:${port}` + logs = '' + web = spawn(process.execPath, ['node_modules/next/dist/bin/next', 'start', '--port', String(port)], { + cwd: fileURLToPath(new URL('../web/', import.meta.url)), + env: { ...process.env, AUTH_SECRET: 'isolated-browser-fixture-secret', NEXTAUTH_SECRET: 'isolated-browser-fixture-secret', NEXTAUTH_URL: base, API_BASE_URL: `http://127.0.0.1:${api.address().port}`, OIDC_ISSUER: '', OIDC_CLIENT_ID: '', OIDC_CLIENT_SECRET: '' }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + web.stdout.on('data', data => logs += data) + web.stderr.on('data', data => logs += data) + let started = false + for (let i = 0; i < 150; i++) { + try { if ((await fetch(`${base}/login`)).ok) { started = true; break } } catch { /* not up yet */ } + if (/EADDRINUSE/.test(logs) || web.exitCode !== null) break + await new Promise(resolve => setTimeout(resolve, 100)) + } + if (started) break + web.kill('SIGKILL') + if (attempt === 3) assert.fail(`Next server did not start after 3 ports: ${logs}`) +} +const attacker = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/html') + res.end(`
`) +}) +await new Promise(resolve => attacker.listen(0, '127.0.0.1', resolve)) +async function eventually(check, label) { + for (let i = 0; i < 100; i++) { + if (await check()) return + await new Promise(resolve => setTimeout(resolve, 100)) + } + assert.fail(label) +} +let browser +try { + await eventually(async () => { try { return (await fetch(`${base}/login`)).ok } catch { return false } }, `Next server did not start: ${logs}`) + browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', headless: true, args: ['--no-sandbox'] }) + const context = await browser.newContext({ timezoneId: 'America/Chicago' }) + const page = await context.newPage() + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + page.on('dialog', dialog => dialog.accept()) + await page.goto(`http://127.0.0.1:${attacker.address().port}`) + await page.waitForURL(`${base}/api/auth/login`) + assert.equal((await context.request.get(`${base}/api/auth/session`).then(r => r.json()))?.user, undefined) + for (const headers of [{ Origin: 'https://attacker.invalid' }, {}]) { + const response = await context.request.post(`${base}/api/auth/login`, { headers, data: { email: 'attacker@computer.test', password: 'password' } }) + assert.equal(response.status(), 403) + } + const plain = await context.request.post(`${base}/api/auth/login`, { headers: { Origin: base, 'Content-Type': 'text/plain' }, data: '{"email":"attacker@computer.test"}' }) + assert.equal(plain.status(), 415) + const login = await context.request.post(`${base}/api/auth/login`, { headers: { Origin: base }, data: { email: 'fixture@computer.test', password: 'password' } }) + assert.equal(login.status(), 200) + console.log('PASS: forged logins rejected; legitimate login works') + + // Review CR-14: an oversized chunked body (no usable Content-Length) must + // be rejected by the proxy's streaming limit — counted while read and + // cancelled at the cap, never fully buffered first. + const chunkedStatus = await new Promise((resolve) => { + const payload = Buffer.alloc(MAX_PROXY_BODY + 1, 0x61) + const u = new URL(`${base}/api/pages`) + const req = http.request({ + host: u.hostname, port: u.port, path: u.pathname, method: 'POST', + headers: { 'Content-Type': 'application/json', 'Transfer-Encoding': 'chunked' }, + }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)) }) + req.on('error', () => resolve(-1)) + for (let i = 0; i < payload.length; i += 65536) req.write(payload.subarray(i, Math.min(i + 65536, payload.length))) + req.end() + }) + assert.equal(chunkedStatus, 413, 'oversized chunked body was not rejected at the proxy') + console.log('PASS: oversized chunked request bodies are rejected while streaming') + + const editor = page.getByPlaceholder('Write Markdown notes… type / for blocks, paste links, lists, thoughts.') + await page.goto(`${base}/pages?id=a`) + await editor.fill('A edits before switching') + await page.getByRole('button', { name: 'Page B', exact: true }).click() + await eventually(() => page.getByRole('textbox', { name: 'Page title' }).inputValue().then(v => v === 'Page B'), 'Page B not selected') + await editor.fill('B edits') + await eventually(() => pages.get('b').content === 'B edits', 'Page B not saved') + assert.equal(pages.get('a').content, 'A edits before switching') + console.log('PASS: rapid page switching preserves both pages') + + delaySaves = 900 + maxConcurrentSaves = 0 + await editor.fill('First in-flight edit') + await eventually(() => activeSaves === 1, 'Save did not start') + await editor.fill('Newest edit wins') + await eventually(() => pages.get('b').content === 'Newest edit wins' && activeSaves === 0, 'Newer edit did not save') + assert.equal(maxConcurrentSaves, 1) + delaySaves = 0 + console.log('PASS: saves are serialized and newer edits win') + + failSaves = true + await editor.fill('Recover this failed draft') + await page.getByRole('button', { name: 'Page A', exact: true }).click() + await page.getByText('Could not save page. Your draft is kept in this tab; retry saving.').first().waitFor() + assert.equal(await page.getByRole('textbox', { name: 'Page title' }).inputValue(), 'Page B') + await page.reload() + await eventually(() => editor.inputValue().then(value => value === 'Recover this failed draft'), 'Draft not recovered after reload') + failSaves = false + await page.getByRole('button', { name: 'Save now', exact: true }).click() + await eventually(() => pages.get('b').content === 'Recover this failed draft', 'Retry did not persist draft') + console.log('PASS: failed saves block switching, survive reload, and can be retried') + + // Cross-session conflict (review DATA-04/TEST-01): after another "device" + // saves a newer version, the 409 pauses autosave in a distinct conflict + // state. Typing must not schedule another save; only the explicit + // overwrite button may replace the remote content. + await page.goto(`${base}/pages?id=a`) + await page.getByRole('button', { name: 'Edit', exact: true }).click() + await editor.fill('Local draft before conflict') + await eventually(() => pages.get('a').content === 'Local draft before conflict', 'Draft did not save before the conflict') + pages.get('a').content = 'Remote edit wins' + pages.get('a').version++ + await editor.fill('Edit after remote change') + await page.getByText('This page was changed elsewhere', { exact: true }).waitFor() + const attemptsAtConflict = patchAttempts + await editor.fill('Typing must not overwrite') + await new Promise(resolve => setTimeout(resolve, 1200)) + assert.equal(patchAttempts, attemptsAtConflict, 'Typing during a conflict scheduled another save') + assert.equal(pages.get('a').content, 'Remote edit wins', 'Conflict silently overwrote the remote content') + await page.getByRole('button', { name: 'Overwrite with my draft', exact: true }).click() + await eventually(() => pages.get('a').content === 'Typing must not overwrite', 'Explicit overwrite did not persist') + console.log('PASS: conflicts pause autosave until an explicit overwrite') + + // Recovery after reload (review DATA-03/TEST-01): a failed draft remembers + // the server version it was based on. When another session saved in the + // meantime, recovery enters the conflict state instead of silently + // rebasing the stale draft onto the newer version. + failSaves = true + await page.goto(`${base}/pages?id=b`) + await page.getByRole('button', { name: 'Edit', exact: true }).click() + await editor.fill('Draft with an older base') + await page.getByText('Could not save page. Your draft is kept in this tab; retry saving.').first().waitFor() + pages.get('b').content = 'Remote saved while draft failed' + pages.get('b').version++ + failSaves = false + await page.reload() + await eventually(() => editor.inputValue().then(value => value === 'Draft with an older base'), 'Draft not recovered after reload') + await page.getByText('This page was changed elsewhere', { exact: true }).waitFor() + // Snapshot AFTER recovery: the reload itself lets the departing page flush + // its dirty draft once (the fixture rejects it with a 409 against the newer + // version). What must not happen is any save from the recovered session. + const attemptsAfterRecovery = patchAttempts + await new Promise(resolve => setTimeout(resolve, 1000)) + assert.equal(patchAttempts, attemptsAfterRecovery, 'Recovered draft saved without an explicit resolution') + assert.equal(pages.get('b').content, 'Remote saved while draft failed', 'Recovery rebased onto and clobbered the newer version') + await page.getByRole('button', { name: 'Discard my draft and load latest', exact: true }).click() + await eventually(() => editor.inputValue().then(value => value === 'Remote saved while draft failed'), 'Discard did not load the remote content') + console.log('PASS: recovered drafts keep their original base and conflict instead of rebasing') + + // Unknown-provenance draft (review DATA-06): a legacy plain-string draft + // carries no baseVersion, so recovery cannot prove it predates the remote + // content. It must pause in the review-required conflict state instead of + // saving over whatever the server holds. + await page.goto(`${base}/pages?id=a`) + await eventually(() => page.getByRole('textbox', { name: 'Page title' }).inputValue().then(v => v === 'Page A'), 'Page A did not open') + await page.evaluate(() => sessionStorage.setItem('computer:page-draft:a', JSON.stringify('Legacy draft of unknown origin'))) + const attemptsBeforeLegacy = patchAttempts + await page.reload() + await eventually(() => editor.inputValue().then(value => value === 'Legacy draft of unknown origin'), 'Legacy draft not recovered') + await page.getByText('This page was changed elsewhere', { exact: true }).waitFor() + await new Promise(resolve => setTimeout(resolve, 1000)) + assert.equal(patchAttempts, attemptsBeforeLegacy, 'Unknown-base draft saved without review') + assert.equal(pages.get('a').content, 'Typing must not overwrite', 'Unknown-base draft clobbered the remote content') + await page.getByRole('button', { name: 'Discard my draft and load latest', exact: true }).click() + await eventually(() => editor.inputValue().then(value => value === 'Typing must not overwrite'), 'Discard did not load the remote content') + console.log('PASS: unknown-base legacy drafts pause for review instead of saving') + + // Envelope refresh (review DATA-07): when a later save in the same serial + // loop fails, the stored recovery envelope must carry the newest content + // AND the base advanced by the earlier success — otherwise reload shows a + // spurious conflict for a draft that was already cleanly rebased. + delaySaves = 900 + failContent = 'Envelope part two' + await page.goto(`${base}/pages?id=b`) + await page.getByRole('button', { name: 'Edit', exact: true }).click() + await editor.fill('Envelope part one') + await eventually(() => activeSaves === 1, 'First envelope save did not start') + await editor.fill('Envelope part two') + await page.getByText('Could not save page. Your draft is kept in this tab; retry saving.').first().waitFor() + assert.equal(pages.get('b').content, 'Envelope part one', 'First save of the loop did not land') + await page.reload() + await eventually(() => editor.inputValue().then(value => value === 'Envelope part two'), 'Newest loop content not recovered') + assert.equal(await page.getByText('This page was changed elsewhere', { exact: true }).count(), 0, + 'Recovery hit a spurious conflict — the stored base was not refreshed after the successful save') + delaySaves = 0 + failContent = null + await page.getByRole('button', { name: 'Save now', exact: true }).click() + await eventually(() => pages.get('b').content === 'Envelope part two', 'Retry did not persist the recovered draft') + console.log('PASS: recovery envelope keeps the latest content and refreshed base') + + // Metadata mutations must not rebase a stale draft (review CR-17): a + // rename/pin PATCH response carries the CURRENT content revision but never + // acknowledges the draft's base. Adopting it would let a stale draft + // overwrite newer remote content without any conflict. + await page.goto(`${base}/pages?id=a`) + await page.getByRole('button', { name: 'Edit', exact: true }).click() + await editor.fill('Metadata rebase draft') + await eventually(() => pages.get('a').content === 'Metadata rebase draft', 'Draft did not save before the metadata mutation') + pages.get('a').content = 'Remote content stays' + pages.get('a').version++ + await page.locator('button[title="Pin to sidebar"]').click() + await eventually(() => pages.get('a').pinned === true, 'Pin metadata patch did not land') + await editor.fill('Stale draft after metadata patch') + await page.getByText('This page was changed elsewhere', { exact: true }).waitFor() + assert.equal(pages.get('a').content, 'Remote content stays', 'Metadata response rebased the stale draft over the remote content') + await page.getByRole('button', { name: 'Discard my draft and load latest', exact: true }).click() + await eventually(() => editor.inputValue().then(value => value === 'Remote content stays'), 'Discard did not load the remote content') + console.log('PASS: metadata mutations do not rebase stale content drafts') + + await page.goto(`${base}/bookmarks`) + await eventually(() => page.locator('tbody tr').count().then(n => n === 205), 'Collection truncated at 200') + assert.ok(paginatedRequests > 0, 'Cursor not forwarded through proxy') + await page.getByText('Fixture 000', { exact: true }).click() + const sheet = page.getByRole('dialog') + const fillSheet = async () => { + await sheet.getByLabel('URL', { exact: true }).fill('https://127.0.0.1/changed') + await sheet.getByLabel('Description', { exact: true }).fill('Edited description') + await sheet.getByLabel('First tag', { exact: true }).uncheck() + await sheet.getByLabel('Second tag', { exact: true }).check() + await sheet.locator('input[type="datetime-local"]').fill('2026-10-08T10:30') + } + await fillSheet() + // Review CR-04: a concurrent edit elsewhere bumps the revision while this + // form is open. The save must surface a conflict, preserve the unsaved + // draft, and leave the remote state untouched — no silent overwrite. + bookmarks[0].version++ + bookmarks[0].notes = 'Remote edit wins' + await sheet.getByRole('button', { name: 'Save Changes', exact: true }).click() + await page.getByText('This bookmark was changed elsewhere', { exact: false }).first().waitFor() + assert.equal(bookmarks[0].url, 'https://127.0.0.1/test', 'Stale bookmark save overwrote the remote state') + assert.equal(bookmarks[0].notes, 'Remote edit wins', 'Stale bookmark save clobbered the remote notes') + // The sheet keeps the unsaved draft and offers an explicit resolution. + await sheet.getByRole('button', { name: 'Load latest version', exact: true }).click() + await eventually(() => sheet.getByLabel('URL', { exact: true }).inputValue().then(v => v === 'https://127.0.0.1/test'), 'Sheet did not reload the latest version') + await fillSheet() + await sheet.getByRole('button', { name: 'Save Changes', exact: true }).click() + await eventually(() => bookmarks[0].url === 'https://127.0.0.1/changed', 'Bookmark update missing after conflict resolution') + assert.equal(bookmarks[0].description, 'Edited description') + assert.deepEqual(bookmarks[0].tags.map(({ tag }) => tag.id), ['two']) + assert.equal(bookmarks[0].alertAt, '2026-10-08T15:30:00.000Z') + assert.equal(bookmarks[0].notes, 'Remote edit wins', 'Conflict resolution lost the remote notes') + assert.deepEqual(errors, [], 'Browser runtime errors') + console.log('PASS: 205 bookmarks displayed; stale saves conflict visibly; URL/description/tags editable; local alert time converted to UTC') + + // Kanban drag-and-drop persists within-column ordering and cross-column + // moves through the versioned card endpoint. + await page.goto(`${base}/kanban`) + await page.getByRole('heading', { name: 'Kanban Board' }).waitFor() + const inboxColumn = page.locator('[data-column-id="inbox"]') + const todoColumn = page.locator('[data-column-id="todo"]') + await inboxColumn.locator('[data-bookmark-id="bookmark-2"]').dragTo( + inboxColumn.locator('[data-bookmark-id="bookmark-0"]'), + { targetPosition: { x: 20, y: 5 } }, + ) + await eventually(() => kanbanMoves.length === 1, 'Within-column drag did not reach the API') + assert.deepEqual(kanbanMoves[0], { id: 'bookmark-2', columnId: 'inbox', position: 0, version: 0 }) + await inboxColumn.locator('[data-bookmark-id="bookmark-3"]').dragTo(todoColumn) + await eventually(() => kanbanMoves.length === 2, 'Cross-column drag did not reach the API') + assert.deepEqual(kanbanMoves[1], { id: 'bookmark-3', columnId: 'todo', position: 0, version: 0 }) + await todoColumn.locator('[data-bookmark-id="bookmark-3"]').waitFor() + // Touch/keyboard fallback: the card menu moves without a drag. + await todoColumn.locator('[data-bookmark-id="bookmark-3"]').getByRole('button', { name: /options/i }).click() + await page.getByRole('menuitem', { name: 'Move to Inbox' }).click() + await eventually(() => kanbanMoves.length === 3, 'Card menu move did not reach the API') + assert.deepEqual(kanbanMoves[2], { id: 'bookmark-3', columnId: 'inbox', position: 204, version: 1 }) + await inboxColumn.locator('[data-bookmark-id="bookmark-3"]').waitFor() + console.log('PASS: kanban drag reorders within a column and moves cards between columns') + + // Page tabs share the persisted position with the sidebar; dragging a tab + // issues the complete order permutation and both surfaces reload. Reset the + // pin from the earlier metadata test so both pages are in one reorder group. + pages.get('a').pinned = false + await page.goto(`${base}/pages`) + await page.locator('[data-page-tab="a"]').waitFor() + await page.locator('[data-page-tab="b"] [data-page-tab-drag]').dragTo( + page.locator('[data-page-tab="a"]'), + { targetPosition: { x: 5, y: 5 } }, + ) + await eventually(() => pages.get('b')?.position === 0 && pages.get('a')?.position === 1, 'Page tab drag did not reorder') + // The sidebar uses the same complete order permutation; drag a back above b. + await page.locator('[data-page-id="a"] [data-page-drag-handle]').dragTo( + page.locator('[data-page-id="b"]'), + { targetPosition: { x: 10, y: 5 } }, + ) + await eventually(() => pages.get('a')?.position === 0 && pages.get('b')?.position === 1, 'Sidebar page drag did not reorder') + console.log('PASS: page tabs and sidebar persist drag reordering') + + // Revocation failure path (review CR-02): when Go's revocation fails, the + // local sign-out still completes, but the UI must say so — local cookie + // removal is not confirmed global revocation, and other sessions survive. + await page.goto(`${base}/bookmarks`) + await page.keyboard.press('Escape') // close any detail sheet so the top bar is reachable + failSignout = true + const failedRevoke = page.waitForResponse(r => r.url().includes('/api/session/revoke') && r.request().method() === 'POST') + await page.getByRole('button', { name: 'Sign out', exact: true }).click() + await page.getByText('global revocation failed', { exact: false }).first().waitFor() + assert.equal((await (await failedRevoke).json()).revoked, false, 'Revoke route reported success despite the backend failure') + await page.waitForURL(`${base}/login`) + failSignout = false + console.log('PASS: failed global revocation is reported and local sign-out still completes') + + // Browser sign-out must reach Go's revocation (review AUTH-LIFECYCLE): the + // explicit /api/session/revoke call (with the NextAuth signOut event as + // backstop) posts to Go /api/auth/signout with a signed assertion, so the + // tokenVersion bump kills Go cookie sessions and any previously minted + // assertions — not just the local NextAuth JWT. + await context.request.post(`${base}/api/auth/login`, { headers: { Origin: base }, data: { email: 'fixture@computer.test', password: 'password' } }) + await page.goto(`${base}/pages`) + const revoked = page.waitForResponse(r => r.url().includes('/api/session/revoke') && r.request().method() === 'POST') + await page.getByRole('button', { name: 'Sign out', exact: true }).click() + assert.equal((await (await revoked).json()).revoked, true, 'Revoke route did not confirm global revocation') + await page.waitForURL(`${base}/login`) + await eventually(() => signouts.length > 0, 'Go signout was not called during browser sign-out') + const authHeader = signouts[signouts.length - 1] + assert.ok(authHeader.startsWith('Bearer '), 'Signout call carried no bearer assertion') + const claim = JSON.parse(Buffer.from(authHeader.slice('Bearer '.length).split('.')[1], 'base64url').toString()) + assert.equal(claim.uid, 'fixture-user') + assert.equal(claim.typ, 'assertion') + assert.equal(claim.ver, 1) + assert.deepEqual(errors, [], 'Browser runtime errors during sign-out') + console.log('PASS: browser sign-out revokes sessions through the Go backend') +} finally { + await browser?.close() + web.kill('SIGTERM') + api.closeAllConnections(); api.close() + attacker.closeAllConnections(); attacker.close() +} diff --git a/scripts/server-deploy.sh b/scripts/server-deploy.sh new file mode 100755 index 0000000..81af8cb --- /dev/null +++ b/scripts/server-deploy.sh @@ -0,0 +1,354 @@ +#!/usr/bin/env bash +# Server-side half of the deployment, run as root on the target server by +# scripts/deploy.sh: +# ssh -t HOST sudo bash .../server-deploy.sh DIR DOMAIN EMAIL USER [REVISION] [STAGE] +# +# Provisions missing packages (docker, compose plugin, nginx, certbot, +# rsync), then — under a server-wide deployment lock (review CR-11) — +# publishes the per-deploy STAGE directory into the shared release DIR, +# writes .env on first deploy, backs up the database, builds/starts the +# Compose stack, waits for health, installs the nginx reverse proxy with a +# Let's Encrypt cert, and records the release marker only after everything +# passed. Overlapping deployments serialize on the lock; their rsync targets +# are unique staging directories, so shared release inputs never change +# mid-flight. +set -euo pipefail + +REMOTE_DIR="${1:?usage: server-deploy.sh DIR DOMAIN EMAIL RUN_USER [REVISION] [STAGE]}" +DOMAIN="$2" +EMAIL="$3" +RUN_USER="$4" +REVISION="${5:-}" +STAGE="${6:-}" +# Host-side ports for the Compose bindings. The defaults from docker-compose.yml +# (8080/3000) collide with other apps on shared servers, so production pins +# these via .env; override here if 18080/13000 are ever taken too. +APP_HOST_PORT="${APP_HOST_PORT:-18080}" +WEB_HOST_PORT="${WEB_HOST_PORT:-13000}" +# Pinned upstream tool versions (review OPS-02): fallback downloads install +# exactly these releases and verify them against the checksums Docker +# publishes with the release. Overrides must be deliberate. +COMPOSE_VERSION="${COMPOSE_VERSION:-v5.6.0}" +BUILDX_VERSION="${BUILDX_VERSION:-v0.38.0}" + +log() { printf '\033[1;34m[server]==>\033[0m %s\n' "$*"; } +die() { printf '\033[1;31m[server] error:\033[0m %s\n' "$*" >&2; exit 1; } + +[ "$(id -u)" = "0" ] || die "must run as root (the local deploy.sh does: ssh -t ... sudo bash $0)" + +# --- 0. Serialize deployments (review CR-11) ------------------------------------ +# One server-wide lock held for the whole critical section: publishing the +# staged tree, the Compose build/release, nginx configuration and the release +# marker must never interleave with a second deployment. A waiter blocks (up +# to 30 minutes) instead of mixing source and release state; the lock dies +# with this process, so a crashed deploy cannot wedge the server. +DEPLOY_LOCK="${DEPLOY_LOCK:-/var/lock/computer-deploy.lock}" +exec 9>"$DEPLOY_LOCK" +log "Waiting for the deployment lock ($DEPLOY_LOCK)" +flock -w 1800 9 || die "another deployment held $DEPLOY_LOCK for more than 30 minutes; refusing to overlap" +log "Deployment lock acquired" + +# --- 1. Provision packages ---------------------------------------------------- +log "Checking required packages" +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq + +# Install one package, but only if apt actually has it (mirrors differ). +install_pkg() { + apt-cache show "$1" >/dev/null 2>&1 || return 1 + apt-get install -y -qq "$1" +} + +command -v docker >/dev/null 2>&1 || install_pkg docker.io || die "docker not installed and docker.io unavailable via apt" +if ! docker compose version >/dev/null 2>&1; then + install_pkg docker-compose-v2 || install_pkg docker-compose-plugin || true +fi +command -v nginx >/dev/null 2>&1 || install_pkg nginx || die "nginx not installed and unavailable via apt" +command -v rsync >/dev/null 2>&1 || install_pkg rsync || die "rsync not installed and unavailable via apt (needed to publish the staged release)" +command -v flock >/dev/null 2>&1 || die "flock is required to serialize deployments (util-linux)" +if ! command -v certbot >/dev/null 2>&1; then + install_pkg certbot || die "certbot unavailable via apt" + install_pkg python3-certbot-nginx || die "python3-certbot-nginx unavailable via apt" +fi +if ! docker compose version >/dev/null 2>&1; then + log "Compose plugin not in apt; installing pinned upstream $COMPOSE_VERSION (checksum-verified)" + # GitHub release assets use uname -m names (x86_64/aarch64), not dpkg arches. + arch="$(uname -m)" + case "$arch" in + x86_64 | aarch64 | armv7l | s390x | ppc64le) ;; + *) die "no upstream Compose binary for architecture '$arch'" ;; + esac + asset="docker-compose-linux-$arch" + tmp="$(mktemp -d)" + curl -fsSL "https://github.com/docker/compose/releases/download/$COMPOSE_VERSION/$asset" -o "$tmp/$asset" + curl -fsSL "https://github.com/docker/compose/releases/download/$COMPOSE_VERSION/$asset.sha256" -o "$tmp/$asset.sha256" + (cd "$tmp" && sha256sum -c "$asset.sha256") || die "Compose $COMPOSE_VERSION checksum verification failed; refusing to install" + mkdir -p /usr/local/libexec/docker/cli-plugins + install -m 755 "$tmp/$asset" /usr/local/libexec/docker/cli-plugins/docker-compose + rm -rf "$tmp" + docker compose version >/dev/null 2>&1 || die "docker compose still unavailable" +fi + +# Upstream compose builds via buildx and requires >= 0.17.0; Debian's +# docker.io ships an older (or no) buildx, so install the upstream plugin. +buildx_ok() { + docker buildx version >/dev/null 2>&1 || return 1 + local v + v="$(docker buildx version | sed -n 's/.* v\([0-9][0-9.]*\).*/\1/p' | head -n1)" + [ -n "$v" ] || return 1 + [ "$(printf '%s\n0.17.0\n' "$v" | sort -V | head -n1)" = "0.17.0" ] +} +if ! buildx_ok; then + log "Installing pinned upstream buildx $BUILDX_VERSION (compose build needs >= 0.17.0; checksum-verified)" + case "$(uname -m)" in + x86_64) barch=amd64 ;; + aarch64) barch=arm64 ;; + armv7l) barch=arm ;; + *) die "no upstream buildx binary for architecture '$(uname -m)'" ;; + esac + asset="buildx-$BUILDX_VERSION.linux-$barch" + tmp="$(mktemp -d)" + curl -fsSL "https://github.com/docker/buildx/releases/download/$BUILDX_VERSION/$asset" -o "$tmp/$asset" + curl -fsSL "https://github.com/docker/buildx/releases/download/$BUILDX_VERSION/checksums.txt" -o "$tmp/checksums.txt" + # checksums.txt covers every asset; verify exactly the pinned one (exact + # field match — a substring grep would also catch .provenance/.sbom assets + # and fail the check). + awk -v name="*$asset" '$2 == name' "$tmp/checksums.txt" > "$tmp/$asset.sha256" + [ -s "$tmp/$asset.sha256" ] || die "no published checksum for $asset" + (cd "$tmp" && sha256sum -c "$asset.sha256") || die "buildx $BUILDX_VERSION checksum verification failed; refusing to install" + mkdir -p /usr/local/libexec/docker/cli-plugins + install -m 755 "$tmp/$asset" /usr/local/libexec/docker/cli-plugins/docker-buildx + rm -rf "$tmp" + buildx_ok || die "docker buildx still unavailable or too old" +fi +# Record what provisioning settled on (review OPS-02). +log "compose plugin: $(docker compose version 2>/dev/null | head -n1)" +log "buildx plugin: $(docker buildx version 2>/dev/null | head -n1)" +systemctl enable --now docker nginx +# Let RUN_USER drive docker without sudo for day-to-day commands. +id -nG "$RUN_USER" | tr ' ' '\n' | grep -qx docker || usermod -aG docker "$RUN_USER" + +# --- 1b. Publish the staged release into the shared directory (under lock) ------- +# Review CR-11: the workstation rsynced a UNIQUE staging directory, so this +# mirror is the only moment shared release inputs change — and it happens +# atomically-ish under the deployment lock, never mid-build of another run. +if [ -n "$STAGE" ]; then + [ -d "$STAGE" ] || die "staging directory $STAGE is missing" + mkdir -p "$REMOTE_DIR" + log "Publishing staged release $STAGE -> $REMOTE_DIR" + # The server's own .env (secrets) and the last successful release marker + # survive the mirror; everything else becomes exactly the staged revision. + rsync -a --delete \ + --exclude '.env' \ + --exclude '.env.*' \ + --exclude '.deployed-revision' \ + "$STAGE/" "$REMOTE_DIR/" + rm -rf "$STAGE" + # Best-effort prune of stages abandoned by failed deploys (older than 7 days). + find "$(dirname "$STAGE")" -maxdepth 1 -mindepth 1 -mtime +7 -exec rm -rf {} + 2>/dev/null || true +fi +[ -f "$REMOTE_DIR/docker-compose.yml" ] || die "$REMOTE_DIR/docker-compose.yml missing - rsync first" + +# --- 2. .env (created once; never touch AUTH_SECRET afterwards) --------------- +cd "$REMOTE_DIR" +if [ ! -f .env ]; then + # A lost .env while the data volume remains would generate a fresh + # POSTGRES_PASSWORD that the existing database does not have, locking the + # app out of its own data. Refuse instead. + if docker volume inspect computer_pgdata >/dev/null 2>&1; then + die ".env is missing but the computer_pgdata volume exists - restore the .env backup (it holds POSTGRES_PASSWORD and AUTH_SECRET); refusing to regenerate" + fi + log "Creating .env with generated secrets (back it up with the database!)" + umask 077 + cat > .env </dev/null)" ]; then + port_busy "$APP_PORT" && die "port $APP_PORT is already in use by another process - set APP_HOST_PORT and re-run" + port_busy "$WEB_PORT" && die "port $WEB_PORT is already in use by another process - set WEB_HOST_PORT and re-run" +fi + +# --- 2b. Back up the database before touching the stack ------------------------- +# The volume survives rebuilds, but a volume is not a backup: take a pg_dump +# before every deploy and keep the 15 most recent dumps plus matching .env +# secret snapshots (review OPS-03: AUTH_SECRET/ENCRYPTION_KEY are required to +# decrypt integration data when restoring). Skipping is never silent: an +# existing database that is not running gets started for the backup, and a +# failed dump aborts the deploy. Dumps are 0600 in a 0700 directory; copy +# them off-host — a same-disk backup does not survive disk loss. +BACKUP_DIR="${BACKUP_DIR:-/var/backups/computer}" +pg_volume="$(sed -n 's/^POSTGRES_VOLUME=//p' .env 2>/dev/null | tail -n1)"; pg_volume="${pg_volume:-computer_pgdata}" +have_existing_db() { + [ -n "$(docker compose ps -aq db 2>/dev/null)" ] || docker volume inspect "$pg_volume" >/dev/null 2>&1 +} +if have_existing_db; then + if ! docker compose ps --status running -q db 2>/dev/null | grep -q .; then + log "Database exists but is not running; starting it so the pre-deploy backup actually happens" + docker compose up -d db + db_deadline=$((SECONDS + 90)) + until [ "$(docker inspect -f '{{.State.Health.Status}}' "$(docker compose ps -q db)" 2>/dev/null)" = "healthy" ]; do + [ $SECONDS -lt $db_deadline ] || die "database never became healthy for the pre-deploy backup" + sleep 3 + done + fi + log "Backing up database to $BACKUP_DIR" + mkdir -p "$BACKUP_DIR" + chmod 700 "$BACKUP_DIR" + stamp="$(date -u +%Y%m%dT%H%M%SZ)" + dump="$BACKUP_DIR/computer-$stamp.sql.gz" + if docker compose exec -T db pg_dump -U computer -d computer | gzip > "$dump"; then + chmod 600 "$dump" + # Capture the secrets required to decrypt/restore integration data. + cp -p .env "$BACKUP_DIR/env-$stamp.bak" + chmod 600 "$BACKUP_DIR/env-$stamp.bak" + ls -1t "$BACKUP_DIR"/computer-*.sql.gz 2>/dev/null | tail -n +16 | xargs -r rm -f + ls -1t "$BACKUP_DIR"/env-*.bak 2>/dev/null | tail -n +16 | xargs -r rm -f + log "Backup written: $dump ($(du -h "$dump" | cut -f1)) + env-$stamp.bak; copy both off-host" + else + rm -f "$dump" + die "pg_dump failed - aborting deploy to protect the database" + fi +else + log "No existing database container or volume ($pg_volume) found - first deploy, nothing to back up" +fi + +# --- 3. Build and start the stack --------------------------------------------- +log "docker compose up -d --build (first build takes several minutes)" +docker compose up -d --build +docker compose ps -a +# Retain a named artifact of this release for rollback (review OPS-04): +# tag the built images with the deployed revision when deploy.sh passed one. +if [ -n "$REVISION" ]; then + for svc in app web; do + img="$(docker compose images -q "$svc" 2>/dev/null | head -n1)" + if [ -n "$img" ]; then + docker tag "$img" "computer-$svc:$REVISION" + log "tagged computer-$svc:$REVISION" + fi + done +fi + +# --- 4. Wait for health --------------------------------------------------------- +log "Waiting for app health and web /login" +deadline=$((SECONDS + 300)) +app_id="$(docker compose ps -q app)" +until [ -n "$app_id" ] && [ "$(docker inspect -f '{{.State.Health.Status}}' "$app_id" 2>/dev/null)" = "healthy" ]; do + [ $SECONDS -lt $deadline ] || { docker compose ps -a; docker compose logs --tail=50 app web; die "app never became healthy"; } + sleep 3 +done +until curl -fsS -o /dev/null "http://127.0.0.1:$WEB_PORT/login"; do + [ $SECONDS -lt $deadline ] || die "web never answered on 127.0.0.1:$WEB_PORT"; + sleep 3 +done +log "Stack is healthy" + +# --- 5. nginx reverse proxy ------------------------------------------------------- +log "Installing nginx config for $DOMAIN" +TEMPLATE="$REMOTE_DIR/deploy/nginx/computer.conf.template" +[ -f "$TEMPLATE" ] || die "missing $TEMPLATE (should have been rsynced)" +# Render the generic template with this deployment's domain and the server's +# actual WEB_HOST_PORT binding. +RENDERED="/tmp/$DOMAIN.$$.conf" +sed -e "s|__DOMAIN__|$DOMAIN|g" -e "s|__WEB_PORT__|$WEB_PORT|g" "$TEMPLATE" > "$RENDERED" +if [ -d /etc/nginx/sites-available ]; then + install -m 644 "$RENDERED" "/etc/nginx/sites-available/$DOMAIN" + ln -sfn "/etc/nginx/sites-available/$DOMAIN" "/etc/nginx/sites-enabled/$DOMAIN" + SITE_CONF="/etc/nginx/sites-available/$DOMAIN" +else + install -m 644 "$RENDERED" "/etc/nginx/conf.d/$DOMAIN.conf" + SITE_CONF="/etc/nginx/conf.d/$DOMAIN.conf" +fi +rm -f "$RENDERED" + +if [ ! -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]; then + # HTTP-01 needs $DOMAIN to resolve to this server before certbot runs. + server_ip="$(curl -fsS --max-time 10 https://api.ipify.org || true)" + dns_ip="$(getent ahostsv4 "$DOMAIN" | awk '{print $1; exit}')" + if [ -n "$server_ip" ] && [ "$dns_ip" != "$server_ip" ]; then + die "$DOMAIN resolves to '$dns_ip' but this server is '$server_ip' - fix DNS, then re-run" + fi + log "No certificate yet: bootstrapping via HTTP-only config + certbot" + # Park the TLS config (nginx -t fails on missing cert files) and serve :80. + mv "$SITE_CONF" "/etc/nginx/$DOMAIN.conf.pending" + rm -f "/etc/nginx/sites-enabled/$DOMAIN" + cat > "/etc/nginx/conf.d/$DOMAIN-bootstrap.conf" < /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh + chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh + fi +fi + +nginx -t +systemctl reload nginx + +# --- 6. End-to-end check through nginx -------------------------------------------- +log "Verifying https://$DOMAIN/login via nginx" +# Retry briefly: right after a reload, early connections can still hit the +# previous config's default 443 vhost (wrong cert / connection refused). +ok="" +for _ in $(seq 10); do + if curl -fsS -o /dev/null --resolve "$DOMAIN:443:127.0.0.1" "https://$DOMAIN/login"; then + ok=1; break + fi + sleep 3 +done +[ -n "$ok" ] || die "nginx -> web TLS check failed after retries" + +# --- 7. Release marker (review CR-11/OPS-04) -------------------------------------- +# Recorded only now that every check passed, so .deployed-revision always +# names the revision actually serving; a failed deploy leaves the previous +# marker (and the previous revision-tagged images) intact for rollback. +if [ -n "$REVISION" ]; then + printf '%s\n' "$REVISION" > "$REMOTE_DIR/.deployed-revision" + log "Recorded deployed revision $REVISION" +fi +log "Server-side deploy complete" diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..ab6c6da --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.env* +.git +out +*.log diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..c3ef74f --- /dev/null +++ b/web/.env.example @@ -0,0 +1,21 @@ +# Copy to .env for local (non-Docker) development of the web app only. +# The Docker stack is configured from the repository-root .env instead — +# see ../.env.example and ../RUNNING.md. The frontend has no database +# connection; all persistence goes through the Go API. + +# Shared secret used to verify NextAuth sessions and sign the 60-second +# identity assertions for the Go API. Must equal the root AUTH_SECRET. +AUTH_SECRET="" + +# Public URL the app is served on (used for auth callbacks). +NEXTAUTH_URL="http://localhost:3000" + +# Go API base URL. Server-side only: inside Docker Compose this is the +# service DNS name http://app:8080. For local `next dev` use the host URL. +API_BASE_URL="http://localhost:8080" + +# Authelia OIDC: stubbed until the client integration is complete +# (see docs/AUTHELIA.md). Setting values enables the config display only. +OIDC_ISSUER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..923233d --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,22 @@ +# Pinned patch level for reproducible builds. +FROM node:22.23.3-alpine3.24 AS deps +WORKDIR /app +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile --ignore-scripts + +FROM node:22.23.3-alpine3.24 AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN yarn build + +FROM node:22.23.3-alpine3.24 AS runtime +WORKDIR /app +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0 +COPY --from=build --chown=node:node /app/.next/standalone ./ +COPY --from=build --chown=node:node /app/.next/static ./.next/static +COPY --from=build --chown=node:node /app/public ./public +USER node +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..9bb1103 --- /dev/null +++ b/web/README.md @@ -0,0 +1,106 @@ +# computer — web UI + +This app runs inside the root Compose stack. The root [../README.md](../README.md) +is the single authoritative deployment/operations guide; [../API.md](../API.md) +documents the Go API contract. This file covers only what a frontend +developer needs. + +Ownership boundary: the Go API owns persistence, previews, integrations and +authentication semantics; this Next.js server owns browser sessions +(NextAuth), the trusted API bridge (`lib/api.ts` signs short-lived +assertions — the browser never supplies a trusted identity), and rendering. +Data requests go through `app/api/[...path]/route.ts`, not local Prisma +routes; the pre-port Prisma layer is gone. Schema and seeding live in the Go +stack (`../db/schema.sql`, `../db/migrations/`, `make seed`). + +## Requirements + +- Node.js 20.9 or newer +- Yarn (Corepack: `corepack enable`) +- A running Go API (`API_BASE_URL` — server-only Docker DNS, e.g. + `http://app:8080`); the browser uses relative `/api/*` URLs + +## Setup + +```bash +cp .env.example .env # fill in AUTH_SECRET, NEXTAUTH_URL, API_BASE_URL +yarn install +``` + +## Run + +```bash +yarn dev # development: http://localhost:3000 +yarn build && yarn start # production +``` + +Production deployments run behind the Compose stack and the nginx template +in `../deploy/nginx/`. The Content-Security-Policy is emitted per response +by `middleware.ts` (nonce-based, so Next's inline bootstrap scripts run); +do not add a second `script-src` policy at the proxy — browsers intersect +multiple CSPs and the nonce would be stripped. + +## Authentication + +- **Email and password** work out of the box. Sign up at `/signup`. Signup + responses are uniform (`{ok:true}`) for fresh, duplicate and reserved + addresses — account existence is never disclosed. +- **Authelia OIDC is fail-closed.** Setting `OIDC_ISSUER`, + `OIDC_CLIENT_ID` and `OIDC_CLIENT_SECRET` only makes the provider appear + in configuration; the Go login/callback endpoints deliberately answer + **501** until verified external identities are mapped to local users with + explicit account-linking rules, and `auth.ts` rejects Authelia sign-ins + until then. Configured variables are NOT working sign-in. See + [docs/AUTHELIA.md](docs/AUTHELIA.md) and the root `AGENTS.md` boundary. +- Sign-out means **sign out everywhere**: the UI first calls + `/api/session/revoke` (Go tokenVersion bump) and reports honestly when + global revocation fails; the NextAuth `signOut` event is the backstop. + Don't remove either path. + +## Encryption and key retention (operator-relevant) + +IMAP mailbox passwords are encrypted with AES-256-GCM. The key derives from +the dedicated `ENCRYPTION_KEY` when set, otherwise from the auth secret +(`NEXTAUTH_SECRET`/`AUTH_SECRET`). Rows encrypted before `ENCRYPTION_KEY` +existed decrypt via the auth-secret fallback and re-encrypt transparently +(compare-and-swap — a concurrent password change always wins). Consequences: + +- Back up `ENCRYPTION_KEY`/`AUTH_SECRET` **with** the database; without the + matching keys, stored mailbox credentials are unrecoverable. +- Rotating an already-dedicated `ENCRYPTION_KEY` needs old-key-aware + migration, not just a changed environment variable. Keep the old secret + until every credential row has been re-encrypted and verified. + +## Project layout + +| Path | Contents | +|---|---| +| `app/(app)/` | Signed-in screens: pages (primary workspace), bookmark filters (all, calendar, tags, contacts, kanban), collections, settings; `/dashboard` redirects to `/bookmarks` | +| `app/api/` | Next routes only: the Go proxy (`[...path]`), NextAuth (`auth/*`), session revoke; everything else is Go's | +| `app/share/bookmark/[id]` | Public read-only bookmark page | +| `components/` | UI components | +| `lib/` | Server-side API bridge (`api.ts`), OIDC config, autosave/pagination helpers | +| `middleware.ts` | Per-response nonce CSP (review CR-10) | +| `auth.ts` | Auth.js configuration | +| `docs/` | Integration docs | + +## Notes + +- URL previews are fetched **server-side by the Go API** + (`internal/preview`, guarded by `internal/netguard` and a shared + per-user/global budget) when a bookmark is saved or its URL changes — + not by this app and not by `open-graph-scraper`. +- Date alerts are polled by the browser every 5 minutes and shown as + browser notifications; email signal ingestion is Go-side (IMAP). + +## Admins + +Administrator provisioning is operator-driven: list addresses in +`ADMIN_EMAILS` and run `make seed` (it never changes existing accounts). +As a bootstrap convenience the FIRST signup on a fresh installation may be +promoted once (AppSetting `adminBootstrap`, disable with +`ALLOW_BOOTSTRAP_SIGNUP=0`); after bootstrap completes, signup never +auto-promotes again — even with no admins left. Promotion of an existing +allowlisted user requires `emailVerified`; DB roles remain authoritative. +Admins can open any account via Admin → "View as" (mutations are blocked +while impersonating). diff --git a/web/STYLE_GUIDE.md b/web/STYLE_GUIDE.md new file mode 100644 index 0000000..f4bed6c --- /dev/null +++ b/web/STYLE_GUIDE.md @@ -0,0 +1,211 @@ +## Layout + +The root layout, `app/layout.tsx`, is the single place for app-wide providers and global infrastructure. + +**Do not remove any existing entries without a reason.** Current infrastructure in the layout: + +| Entry | Purpose | +|-------|---------| +| `ThemeProvider` | Light/dark mode via `next-themes` | +| `Toaster` | Global toast notifications via Sonner | +| `ChunkLoadErrorHandler` | Required — prevents known ChunkLoadError race condition bug | + +--- + +## Typography + +| Role | Font | Tailwind Class | Default Usage | +|------|------|---------------|-------| +| Body | DM Sans | `font-sans` | All body text, labels, descriptions | +| Display | Plus Jakarta Sans | `font-display` | Page titles, hero headings, section headers | +| Mono | JetBrains Mono | `font-mono` | Code snippets, numeric data, IDs, timestamps | + +**Size hierarchy:** Use Tailwind's scale. Headings: `text-4xl`→`text-3xl`→`text-2xl`→`text-xl`. Body: `text-base`→`text-sm`. Captions: `text-xs`. + +Always use `tracking-tight` on large headings (`text-2xl` and above). + +--- + +## Color System (Design Tokens) + +All colors use CSS variables — **never hardcode color values**. + +| Token | Purpose | +|-------|---------| +| `background` / `foreground` | Page-level bg and text | +| `card` / `card-foreground` | Card surfaces | +| `primary` / `primary-foreground` | Brand buttons, links, accents | +| `secondary` / `secondary-foreground` | Secondary buttons, subtle highlights | +| `muted` / `muted-foreground` | Disabled states, helper text, subtle backgrounds | +| `accent` / `accent-foreground` | Hover states, active nav items | +| `destructive` / `destructive-foreground` | Errors, delete actions | +| `border` | Borders and dividers | +| `input` | Form input borders | +| `ring` | Focus rings | + +Usage: `bg-primary`, `text-muted-foreground`, `border-border`, etc. + +--- + +## Spacing Scale + +Based on an 8px grid. Use these CSS variables or Tailwind equivalents: + +| Token | Value | Tailwind | +|-------|-------|----------| +| `--spacing-xs` | 4px | `p-1`, `gap-1` | +| `--spacing-sm` | 8px | `p-2`, `gap-2` | +| `--spacing-md` | 16px | `p-4`, `gap-4` | +| `--spacing-lg` | 24px | `p-6`, `gap-6` | +| `--spacing-xl` | 32px | `p-8`, `gap-8` | +| `--spacing-2xl` | 48px | `p-12`, `gap-12` | +| `--spacing-3xl` | 64px | `p-16`, `gap-16` | + +**Vary spacing rhythm** — don't use the same gap everywhere. Hero → large gap → content → medium gap → footer. + +--- + +## Shadow Scale + +| Token | Default Usage | +|-------|-------| +| `--shadow-sm` | Subtle card lift, input focus | +| `--shadow-md` | Cards, dropdowns, popovers | +| `--shadow-lg` | Modals, elevated panels | + +These are CSS variables only — use them directly in inline styles or custom CSS as `var(--shadow-sm)` etc. They are not mapped to Tailwind's `shadow-*` utilities. + +--- + +## Border Radius + +| Token | Value | Default Usage | +|-------|-------|-------| +| `--radius` | 0.625rem | Default (buttons, inputs, cards) | +| `--radius-sm` | calc(var(--radius) - 4px) | Small elements (badges, chips) | +| `--radius-lg` | calc(var(--radius) + 4px) | Large containers, hero cards | +| `--radius-full` | 9999px | Avatars, pills, circular buttons | + +--- + +## Animation Timing + +| Token | Value | Tailwind Class | Default Usage | +|-------|-------|---------------|-------| +| `--duration-fast` | 150ms | `duration-fast` | Hover states, toggles | +| `--duration-normal` | 250ms | `duration-normal` | Page transitions, reveals | +| `--duration-slow` | 350ms | `duration-slow` | Complex animations, modals | + +--- + +## Layout Components + +The live app shell is `@/components/app-shell` (sidebar + top bar + main +content, wired through `app/(app)/layout.tsx`); screens render their own +headers inline. Login/signup use a centered card built directly in their +route files. The generic `components/layouts/*` wrappers documented here +previously were never imported and have been removed — reintroduce shared +layout primitives only when a second consumer exists. + +--- + +## Animation Components — `@/components/ui/animate` + +| Component | Default Usage | Key Props | +|-----------|-------|-----------| +| `FadeIn` | Reveal content on scroll | `delay`, `duration` | +| `ScaleIn` | Pop-in effect | `delay` | +| `SlideIn` | Directional entrance | `from` (`top`\|`bottom`\|`left`\|`right`), `delay` | +| `Stagger` + `StaggerItem` | Sequential reveal for lists/grids | `staggerDelay` | +| `HoverLift` | Lift effect on hover (cards, links) | — | +| `PressScale` | Press-down feedback for buttons | — | +| `SkeletonPulse` | Loading placeholder | `className` (set width/height) | + +**Pattern:** Wrap page sections in `FadeIn`, list items in `Stagger`/`StaggerItem`, interactive cards in `HoverLift`. + +--- + +## UI Components — `@/components/ui/` + +### Core +| Component | Import | Key Props | +|-----------|--------|-----------| +| `Button` | `@/components/ui/button` | `variant` (`default`\|`secondary`\|`outline`\|`ghost`\|`destructive`\|`link`\|`glass-dark`\|`glass-light`), `size` (`default`\|`xs`\|`sm`\|`lg`\|`icon`\|`icon-sm`), `loading` (boolean). **`glass-dark`**: for dark/vivid backgrounds. **`glass-light`**: for light/pale backgrounds. **Link**: focus uses underline, not ring. | +| `Badge` | `@/components/ui/badge` | `variant` (`default`\|`secondary`\|`outline`\|`destructive`) | +| `Card` | `@/components/ui/card` | `variant` (`default`\|`interactive`\|`glass-dark`\|`glass-dark-interactive`\|`glass-light`\|`glass-light-interactive`\|`ghost`). Composed: `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`. **Interactive**: always wrap in `` or ` + + + ))} + {users.length === 0 &&

No users found.

} + + + ) +} diff --git a/web/app/(app)/admin/page.tsx b/web/app/(app)/admin/page.tsx new file mode 100644 index 0000000..a3ceb9a --- /dev/null +++ b/web/app/(app)/admin/page.tsx @@ -0,0 +1,11 @@ +export const dynamic = 'force-dynamic' +import { redirect } from 'next/navigation' +import { getRealUser } from '@/lib/auth-guard' +import { AdminClient } from './client' + +export default async function AdminPage() { + const user = await getRealUser() + if (!user) redirect('/login') + if (user.role !== 'ADMIN') redirect('/pages') + return +} diff --git a/web/app/(app)/bookmarks/client.tsx b/web/app/(app)/bookmarks/client.tsx new file mode 100644 index 0000000..94fdffb --- /dev/null +++ b/web/app/(app)/bookmarks/client.tsx @@ -0,0 +1,192 @@ +'use client' + +import { useState, useEffect, useCallback, useRef } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { BookmarkDetailSheet } from '@/components/bookmark-detail-sheet' +import { ExternalLink, Search, Trash2, ChevronUp, ChevronDown, Inbox, Share2 } from 'lucide-react' +import { toast } from 'sonner' +import type { Bookmark } from '@/components/app-shell' +import { fetchBookmarks } from '@/lib/bookmarks' + +export function BookmarksClient({ initialSearch = null, initialOpen = null }: { initialSearch?: string | null; initialOpen?: string | null }) { + const [bookmarks, setBookmarks] = useState([]) + const [sharedBookmarks, setSharedBookmarks] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [selected, setSelected] = useState(null) + // Review CR-04: the sheet must receive the CURRENT row after mutations — + // its conflict banner ("Load latest version") and successful saves need + // the fresh version, or the next save would send a stale base. + const selectedRef = useRef(null) + selectedRef.current = selected + const refreshSelected = useCallback(async () => { + const id = selectedRef.current?.id + if (!id) return + try { + const res = await fetch(`/api/bookmarks/${encodeURIComponent(id)}`, { cache: 'no-store' }) + if (res.ok) setSelected(await res.json()) + } catch { /* the list load surfaces errors */ } + }, []) + const [tab, setTab] = useState<'mine' | 'shared'>('mine') + const [sortField, setSortField] = useState<'title' | 'updatedAt' | 'kanbanStatus'>('updatedAt') + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc') + + // Debounced, abortable, sequence-guarded load: a slow response for an older + // query can never overwrite newer results (same discipline as pages client). + const loadSeq = useRef(0) + const abortRef = useRef(null) + const load = useCallback(() => { + abortRef.current?.abort() + const ac = new AbortController() + abortRef.current = ac + const seq = ++loadSeq.current + setLoading(true) + const params = new URLSearchParams() + if (search) params.set('search', search) + ;(async () => { + try { + const mine = await fetchBookmarks(params, ac.signal) + if (seq !== loadSeq.current) return + setBookmarks(mine ?? []) + } catch { + if (seq === loadSeq.current && !ac.signal.aborted) toast.error('Could not load bookmarks') + } finally { + if (seq === loadSeq.current) setLoading(false) + } + })() + }, [search]) + + // The shared list is not search-filtered; load it once and on changes only. + const loadShared = useCallback(async () => { + try { setSharedBookmarks(await fetchBookmarks(new URLSearchParams({ shared: 'true' })) ?? []) } + catch { /* the mine-tab load surfaces errors */ } + }, []) + + useEffect(() => { + const timer = setTimeout(load, search ? 250 : 0) + const reload = () => { load(); void loadShared() } + window.addEventListener('computer:bookmarks-changed', reload) + return () => { clearTimeout(timer); window.removeEventListener('computer:bookmarks-changed', reload); abortRef.current?.abort() } + }, [load, loadShared, search]) + useEffect(() => { const timer = setTimeout(loadShared, 0); return () => clearTimeout(timer) }, [loadShared]) + // Quick Find and the dashboard redirect land here with ?search=; the prop + // comes from the server component so query changes apply without a remount. + useEffect(() => { + const timer = setTimeout(() => setSearch(initialSearch || ''), 0) + return () => clearTimeout(timer) + }, [initialSearch]) + // Quick Find deep-links straight to a bookmark's detail sheet via ?open=. + useEffect(() => { + if (!initialOpen) return + const timer = setTimeout(async () => { + try { + const res = await fetch(`/api/bookmarks/${encodeURIComponent(initialOpen)}`) + if (res.ok) setSelected(await res.json()) + } catch { /* stale or inaccessible id; the list still renders */ } + }, 0) + return () => clearTimeout(timer) + }, [initialOpen]) + + const displayBookmarks = tab === 'mine' ? bookmarks : sharedBookmarks + const sorted = [...(displayBookmarks ?? [])].sort((a: any, b: any) => { + const va = a?.[sortField] ?? '' + const vb = b?.[sortField] ?? '' + return sortDir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va)) + }) + + const toggleSort = (field: typeof sortField) => { + if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc') + else { setSortField(field); setSortDir('asc') } + } + + const sortIcon = (field: typeof sortField) => { + if (sortField !== field) return null + return sortDir === 'asc' ? : + } + + return ( +
+
+

All Bookmarks

+

Every saved link. Use the sidebar to filter by calendar, tags, board, or contacts.

+
+
+
+ + ) => setSearch(e.target.value)} className="pl-9" /> +
+
+ + +
+
+
+
+ + + + + + + + + + + + + {loading ? ( + + ) : sorted.length === 0 ? ( + + ) : sorted.map((b: Bookmark) => ( + setSelected(b)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelected(b) } }}> + + + + + + + + ))} + +
toggleSort('title')}> + Title {sortIcon('title')} + URLTags toggleSort('kanbanStatus')}> + Status {sortIcon('kanbanStatus')} + toggleSort('updatedAt')}> + Updated {sortIcon('updatedAt')} + Actions
Loading...
No bookmarks found
+
+ {b.favicon && { e.target.style.display = 'none' }} />} + {b.title ?? 'Untitled'} +
+
+ e.stopPropagation()} className="text-primary hover:underline truncate max-w-[200px] block"> + {(() => { try { return new URL(b.url).hostname } catch { return b.url } })()} + + +
+ {(b.tags ?? []).slice(0, 2).map((bt: any) => ( + {bt?.tag?.name} + ))} +
+
{b.kanbanStatus?.replace('_', ' ')}{new Date(b.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })} +
e.stopPropagation()}> + + {tab === 'mine' && ( + + )} +
+
+
+
+ setSelected(null)} onUpdate={() => { load(); void refreshSelected() }} /> +
+ ) +} diff --git a/web/app/(app)/bookmarks/page.tsx b/web/app/(app)/bookmarks/page.tsx new file mode 100644 index 0000000..12d2a81 --- /dev/null +++ b/web/app/(app)/bookmarks/page.tsx @@ -0,0 +1,8 @@ +import { BookmarksClient } from './client' + +// Quick Find deep-links (?open=, ?search=) arrive through searchParams +// so the client reacts to query changes without remounting. +export default async function BookmarksPage({ searchParams }: { searchParams: Promise<{ search?: string; open?: string }> }) { + const { search, open } = await searchParams + return +} diff --git a/web/app/(app)/calendar/client.tsx b/web/app/(app)/calendar/client.tsx new file mode 100644 index 0000000..ae739c0 --- /dev/null +++ b/web/app/(app)/calendar/client.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { Button } from '@/components/ui/button' +import { BookmarkCard } from '@/components/bookmark-card' +import { BookmarkDetailSheet } from '@/components/bookmark-detail-sheet' +import { ChevronLeft, ChevronRight, Share2 } from 'lucide-react' +import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, subMonths, getDay } from 'date-fns' +import type { Bookmark } from '@/components/app-shell' +import { Input } from '@/components/ui/input' +import { toast } from 'sonner' +import { fetchBookmarks } from '@/lib/bookmarks' + +const INITIAL_DATE = new Date(2026, 9, 1) + +export function CalendarClient() { + const [currentMonth, setCurrentMonth] = useState(INITIAL_DATE) + const [today, setToday] = useState(INITIAL_DATE) + const [bookmarks, setBookmarks] = useState([]) + const [selectedDay, setSelectedDay] = useState(null) + const [selectedBookmark, setSelectedBookmark] = useState(null) + const [loading, setLoading] = useState(true) + const [sharedDates, setSharedDates] = useState([]) + const [shareQuery, setShareQuery] = useState('') + const [shareUsers, setShareUsers] = useState([]) + + // Debounced user search; failures keep the previous results silently. + useEffect(() => { + const q = shareQuery.trim() + const t = setTimeout(async () => { + if (q.length < 2) { setShareUsers([]); return } + try { + const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`) + if (res.ok) setShareUsers(await res.json()) + } catch { /* transient; retried on next keystroke */ } + }, q.length < 2 ? 0 : 250) + return () => clearTimeout(t) + }, [shareQuery]) + + useEffect(() => { + const timer = setTimeout(() => { const now = new Date(); setCurrentMonth(now); setToday(now) }, 0) + return () => clearTimeout(timer) + }, []) + + const load = useCallback(async () => { + setLoading(true) + try { + const [mine, shared, dates] = await Promise.all([fetchBookmarks(), fetchBookmarks(new URLSearchParams({ shared: 'true' })), fetch('/api/shared-dates')]) + setBookmarks([...mine, ...shared]) + if (dates.ok) setSharedDates(await dates.json()) + } catch { toast.error('Could not load calendar') } + finally { setLoading(false) } + }, []) + + useEffect(() => { const timer = setTimeout(load, 0); window.addEventListener('computer:bookmarks-changed', load); return () => { clearTimeout(timer); window.removeEventListener('computer:bookmarks-changed', load) } }, [load]) + + const days = useMemo(() => { + const start = startOfMonth(currentMonth) + const end = endOfMonth(currentMonth) + return eachDayOfInterval({ start, end }) + }, [currentMonth]) + + const startDayOfWeek = getDay(startOfMonth(currentMonth)) + + const bookmarksByDay = useMemo(() => { + const map: Record = {} + for (const b of bookmarks) { + if (b?.dueDate) { + const key = b.dueDate.slice(0, 10) + if (!map[key]) map[key] = [] + map[key].push(b) + } + } + return map + }, [bookmarks]) + + const selectedBookmarks = useMemo(() => { + if (!selectedDay) return [] + const key = format(selectedDay, 'yyyy-MM-dd') + return bookmarksByDay[key] ?? [] + }, [selectedDay, bookmarksByDay]) + + return ( +
+
+
+

Calendar

+

Bookmarks organized by due date

+
+
+ + {format(currentMonth, 'MMMM yyyy')} + +
+
+ +
+ {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(d => ( +
{d}
+ ))} + {Array.from({ length: startDayOfWeek }).map((_, i) => ( +
+ ))} + {days.map(day => { + const key = format(day, 'yyyy-MM-dd') + const dayBookmarks = bookmarksByDay[key] ?? [] + const isSelected = selectedDay ? isSameDay(day, selectedDay) : false + const isToday = isSameDay(day, today) + + return ( +
setSelectedDay(day)} + onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedDay(day) } }} + className={`bg-card min-h-[80px] p-1.5 cursor-pointer transition-colors hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-primary ${ + isSelected ? 'ring-2 ring-primary ring-inset' : '' + }`} + > + + {format(day, 'd')} + +
+ {sharedDates.filter(s => s.date.slice(0, 10) === key).map(s =>

Shared: {s.creator.name || s.creator.email}

)} + {dayBookmarks.slice(0, 2).map((b: Bookmark) => ( +
{ e.stopPropagation(); setSelectedBookmark(b) }} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); setSelectedBookmark(b) } }}> + {b.title ?? 'Untitled'} +
+ ))} + {dayBookmarks.length > 2 && ( +
+{dayBookmarks.length - 2} more
+ )} +
+
+ ) + })} +
+ + {selectedDay && ( +
+
+

{format(selectedDay, 'EEEE, MMMM d, yyyy')}

+
setShareQuery(e.target.value)} />
+
+ {shareUsers.map(u => )} + {sharedDates.filter(s => s.date.slice(0, 10) === format(selectedDay, 'yyyy-MM-dd')).map(s =>
{s.creator.name || s.creator.email} shared with {s.recipient.name || s.recipient.email}
)} + {selectedBookmarks.length === 0 ? ( +

No bookmarks on this date

+ ) : ( +
+ {selectedBookmarks.map((b: Bookmark) => ( + setSelectedBookmark(b)} /> + ))} +
+ )} +
+ )} + + setSelectedBookmark(null)} onUpdate={load} /> +
+ ) +} diff --git a/web/app/(app)/calendar/page.tsx b/web/app/(app)/calendar/page.tsx new file mode 100644 index 0000000..d382714 --- /dev/null +++ b/web/app/(app)/calendar/page.tsx @@ -0,0 +1,5 @@ +import { CalendarClient } from './client' + +export default function CalendarPage() { + return +} diff --git a/web/app/(app)/collections/client.tsx b/web/app/(app)/collections/client.tsx new file mode 100644 index 0000000..152ac03 --- /dev/null +++ b/web/app/(app)/collections/client.tsx @@ -0,0 +1,883 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import Link from 'next/link' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { CalendarDays, Columns3, Filter, GripVertical, Kanban, Layers, List, Plus, Table2, Trash2, X } from 'lucide-react' +import { toast } from 'sonner' +import type { Bookmark } from '@/components/app-shell' +import { cn } from '@/lib/utils' + +// Collections workspace (Notion-like upgrades phase 2). Contextual workflow +// state over existing bookmarks: the same link can be "Done" in one +// collection and "Unread" in another, and removing a membership never +// deletes the bookmark. Every mutating PATCH carries the expected version; +// a 409 reloads instead of clobbering (the DATA-05 contract). + +type PropertyOption = { id: string; label: string; color?: string } +type CollectionProperty = { + id: string; name: string + type: 'TEXT' | 'NUMBER' | 'SELECT' | 'MULTI_SELECT' | 'DATE' | 'CHECKBOX' | 'URL' + position: number; config: { options?: PropertyOption[]; mode?: 'date' | 'datetime' }; version: number +} +type FilterClause = { propertyId: string; op: string; value?: unknown } +type CollectionView = { + id: string; name: string; type: 'TABLE' | 'BOARD' | 'CALENDAR' | 'LIST'; position: number + filters: { operator: 'AND' | 'OR'; clauses: FilterClause[] } + sorts: { field: string; dir: string }[] + groupByPropertyId: string | null; displaySettings: Record; version: number +} +type CollectionItem = { + id: string; collectionId: string; bookmarkId: string + propertyValues: Record; position: number; version: number + addedAt: string; updatedAt: string; bookmark: Bookmark +} +type CollectionSummary = { + id: string; name: string; description: string | null; icon: string | null + version: number; createdAt: string; updatedAt: string + _count: { items: number; properties: number; views: number } +} +type CollectionDetail = CollectionSummary & { properties: CollectionProperty[]; views: CollectionView[] } + +const VIEW_ICONS = { TABLE: Table2, BOARD: Columns3, CALENDAR: CalendarDays, LIST: List } as const +const OP_LABELS: Record = { + eq: 'is', ne: 'is not', contains: 'contains', gt: 'after/above', gte: '≥', lt: 'before/below', lte: '≤', + empty: 'is empty', notEmpty: 'is not empty', +} +// Mirrors the server allowlists in internal/handlers/collections.go. +const FILTER_OPS: Record = { + TEXT: ['eq', 'ne', 'contains', 'empty', 'notEmpty'], + URL: ['eq', 'ne', 'contains', 'empty', 'notEmpty'], + NUMBER: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'empty', 'notEmpty'], + DATE: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'empty', 'notEmpty'], + SELECT: ['eq', 'ne', 'empty', 'notEmpty'], + CHECKBOX: ['eq', 'ne', 'empty', 'notEmpty'], + MULTI_SELECT: ['contains', 'empty', 'notEmpty'], +} +const PROPERTY_TYPES = ['TEXT', 'NUMBER', 'SELECT', 'MULTI_SELECT', 'DATE', 'CHECKBOX', 'URL'] as const +const NONE = '__none__' + +async function json(res: Response): Promise { return res.json().catch(() => null) } + +// datetime-local inputs need local "YYYY-MM-DDTHH:mm"; the server stores a +// fixed-width UTC RFC3339 string, so convert through Date on both ends. +function toLocalInput(stored: unknown): string { + if (typeof stored !== 'string' || !stored) return '' + const d = new Date(stored) + if (Number.isNaN(d.getTime())) return '' + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +function BookmarkCell({ bookmark }: { bookmark: Bookmark }) { + const host = (() => { try { return new URL(bookmark.url).hostname } catch { return bookmark.url } })() + return ( + + {bookmark.favicon + // eslint-disable-next-line @next/next/no-img-element -- remote favicons stay no-referrer (web/AGENTS.md) + ? + : } + + {bookmark.title || bookmark.url} + {host} + + + ) +} + +const asText = (v: unknown) => (typeof v === 'string' || typeof v === 'number' ? String(v) : '') + +// Per-type inline value editor. Saves immediately on commit gestures +// (change/blur/Enter); failures toast and the prop value re-renders the +// server state, so a rejected edit never sticks locally. +function ValueEditor({ prop, value, onSave }: { prop: CollectionProperty; value: unknown; onSave: (v: unknown) => Promise }) { + const [text, setText] = useState(() => asText(value)) + const [seen, setSeen] = useState(value) + // Adjusting state when a prop changes: the React-documented render-time + // pattern (guarded, converges immediately) instead of a cascading effect. + if (value !== seen) { + setSeen(value) + setText(asText(value)) + } + const [open, setOpen] = useState(false) + const commitText = async () => { + const next = text.trim() + const current = asText(value) + if (next === current) return + if (next === '') { await onSave(null); return } + await onSave(prop.type === 'NUMBER' ? Number(next) : next) + } + + switch (prop.type) { + case 'CHECKBOX': + return { void onSave(c === true) }} /> + case 'SELECT': { + const options = prop.config.options ?? [] + return ( + + ) + } + case 'MULTI_SELECT': { + const options = prop.config.options ?? [] + const selected = new Set(Array.isArray(value) ? (value as string[]) : []) + const toggle = async (id: string) => { + const next = new Set(selected) + if (next.has(id)) next.delete(id); else next.add(id) + await onSave([...next]) + } + return ( + + + + + + {options.map((o) => ( + + ))} + {options.length === 0 &&

No options yet

} +
+
+ ) + } + case 'DATE': + return prop.config.mode === 'datetime' + ? { const v = e.target.value; void onSave(v ? new Date(v).toISOString() : null) }} /> + : { void onSave(e.target.value || null) }} /> + case 'NUMBER': + case 'TEXT': + case 'URL': + default: + return ( + setText(e.target.value)} + onBlur={() => { void commitText() }} + onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} + /> + ) + } +} + +export function CollectionsClient() { + const [collections, setCollections] = useState([]) + const [activeId, setActiveId] = useState(null) + const [detail, setDetail] = useState(null) + const [items, setItems] = useState([]) + const [cursor, setCursor] = useState(null) + const [draggedItemId, setDraggedItemId] = useState(null) + const [boardDropKey, setBoardDropKey] = useState(null) + const [viewId, setViewId] = useState(null) + const [newName, setNewName] = useState('') + const [nameDraft, setNameDraft] = useState('') + const [addOpen, setAddOpen] = useState(false) + const [addQuery, setAddQuery] = useState('') + const [addResults, setAddResults] = useState([]) + const [propOpen, setPropOpen] = useState(false) + const [propName, setPropName] = useState('') + const [propType, setPropType] = useState<(typeof PROPERTY_TYPES)[number]>('SELECT') + const [propOptions, setPropOptions] = useState<{ label: string; color: string }[]>([]) + const [propMode, setPropMode] = useState<'date' | 'datetime'>('date') + const [viewOpen, setViewOpen] = useState(false) + const [viewName, setViewName] = useState('') + const [viewType, setViewType] = useState<'TABLE' | 'BOARD' | 'CALENDAR' | 'LIST'>('TABLE') + const [filterOpen, setFilterOpen] = useState(false) + const [fProp, setFProp] = useState('') + const [fOp, setFOp] = useState('eq') + const [fValue, setFValue] = useState('') + + const loadCollections = useCallback(async () => { + try { + const res = await fetch('/api/collections') + if (res.ok) setCollections(await res.json()) + } catch { toast.error('Could not load collections') } + }, []) + + const loadDetail = useCallback(async (id: string) => { + try { + const res = await fetch(`/api/collections/${id}`) + if (!res.ok) { setDetail(null); return } + const data: CollectionDetail = await res.json() + setDetail(data) + setNameDraft(data.name) + setViewId((current) => (current && data.views.some((v) => v.id === current) ? current : data.views[0]?.id ?? null)) + } catch { toast.error('Could not load collection') } + }, []) + + const loadItems = useCallback(async (id: string, view: string | null, append?: string | null) => { + try { + const params = new URLSearchParams({ limit: '200' }) + if (view) params.set('viewId', view) + if (append) params.set('cursor', append) + const res = await fetch(`/api/collections/${id}/items?${params}`) + if (!res.ok) return + const rows: CollectionItem[] = await res.json() + setItems((current) => (append ? [...current, ...rows] : rows)) + setCursor(res.headers.get('X-Next-Cursor')) + } catch { toast.error('Could not load items') } + }, []) + + useEffect(() => { const t = setTimeout(loadCollections, 0); return () => clearTimeout(t) }, [loadCollections]) + useEffect(() => { + const t = setTimeout(() => { + if (!activeId && collections.length) setActiveId(collections[0].id) + }, 0) + return () => clearTimeout(t) + }, [collections, activeId]) + useEffect(() => { + const t = setTimeout(() => { + if (!activeId) { setDetail(null); return } + loadDetail(activeId) + }, 0) + return () => clearTimeout(t) + }, [activeId, loadDetail]) + useEffect(() => { + const t = setTimeout(() => { + if (!activeId) { setItems([]); return } + loadItems(activeId, viewId) + }, 0) + return () => clearTimeout(t) + }, [activeId, viewId, loadItems]) + + // Versioned mutation helper: 409 means another session moved first — the + // server state wins, we reload, and nothing is clobbered (DATA-05 lesson). + const mutate = async (res: Response, reload: () => void, okMessage?: string): Promise => { + if (res.ok) { if (okMessage) toast.success(okMessage); return true } + if (res.status === 409) { + const body = await json(res) + toast.error(body?.error || 'Changed elsewhere; reloaded the latest version') + reload() + return false + } + const body = await json(res) + toast.error(body?.error || 'Request failed') + return false + } + const patch = (url: string, body: Record) => + fetch(url, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + + const activeView = detail?.views.find((v) => v.id === viewId) ?? null + const groupProp = activeView?.groupByPropertyId ? detail?.properties.find((p) => p.id === activeView.groupByPropertyId) : null + + const createCollection = async () => { + const name = newName.trim() + if (!name) return + const res = await fetch('/api/collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }) + if (!res.ok) { await mutate(res, loadCollections); return } + const created = await res.json().catch(() => null) + toast.success('Collection created') + setNewName('') + await loadCollections() + if (created?.id) setActiveId(created.id) + } + + const saveName = async () => { + if (!detail) return + const name = nameDraft.trim() + if (!name || name === detail.name) { setNameDraft(detail.name); return } + const res = await patch(`/api/collections/${detail.id}`, { version: detail.version, name }) + if (await mutate(res, () => loadDetail(detail.id), 'Renamed')) { + setDetail((d) => (d ? { ...d, name, version: d.version + 1 } : d)) + loadCollections() + } else setNameDraft(detail.name) + } + + const deleteCollection = async () => { + if (!detail || !confirm(`Delete collection "${detail.name}"? Bookmarks are kept.`)) return + const res = await fetch(`/api/collections/${detail.id}`, { method: 'DELETE' }) + if (await mutate(res, loadCollections, 'Collection deleted')) { + setActiveId(null) + setDetail(null) + loadCollections() + } + } + + const createProperty = async () => { + if (!detail) return + const name = propName.trim() + if (!name) return + const config: Record = {} + if (propType === 'SELECT' || propType === 'MULTI_SELECT') { + config.options = propOptions.filter((o) => o.label.trim()).map((o) => ({ label: o.label.trim(), ...(o.color ? { color: o.color } : {}) })) + } + if (propType === 'DATE') config.mode = propMode + const res = await fetch(`/api/collections/${detail.id}/properties`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, type: propType, config }), + }) + if (await mutate(res, () => loadDetail(detail.id), 'Property added')) { + setPropOpen(false); setPropName(''); setPropOptions([]) + loadDetail(detail.id) + } + } + + const deleteProperty = async (prop: CollectionProperty) => { + if (!detail || !confirm(`Delete property "${prop.name}"? Its values are removed from every item.`)) return + const res = await fetch(`/api/collections/${detail.id}/properties/${prop.id}`, { method: 'DELETE' }) + if (await mutate(res, () => loadDetail(detail.id), 'Property deleted')) { + loadDetail(detail.id) + loadItems(detail.id, viewId) + } + } + + const createView = async () => { + if (!detail) return + const name = viewName.trim() + if (!name) return + const res = await fetch(`/api/collections/${detail.id}/views`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, type: viewType }), + }) + if (!res.ok) { await mutate(res, () => loadDetail(detail.id)); return } + const created = await res.json().catch(() => null) + toast.success('View added') + setViewOpen(false); setViewName('') + await loadDetail(detail.id) + if (created?.id) setViewId(created.id) + } + + const deleteView = async () => { + if (!detail || !activeView || !confirm(`Delete view "${activeView.name}"?`)) return + const res = await fetch(`/api/collections/${detail.id}/views/${activeView.id}`, { method: 'DELETE' }) + if (await mutate(res, () => loadDetail(detail.id), 'View deleted')) { + setViewId(null) + loadDetail(detail.id) + } + } + + const patchView = async (changes: Record) => { + if (!detail || !activeView) return + const res = await patch(`/api/collections/${detail.id}/views/${activeView.id}`, { version: activeView.version, ...changes }) + if (res.ok) { + const updated: CollectionView = await res.json() + setDetail((d) => (d ? { ...d, views: d.views.map((v) => (v.id === updated.id ? updated : v)) } : d)) + loadItems(detail.id, updated.id) + } else await mutate(res, () => loadDetail(detail.id)) + } + + const addItem = async (bookmarkId: string) => { + if (!detail) return + const res = await fetch(`/api/collections/${detail.id}/items`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bookmarkId }), + }) + if (res.ok) { + loadItems(detail.id, viewId) + loadCollections() + } else if (res.status === 409) toast.error('Already in this collection') + else { const body = await json(res); toast.error(body?.error || 'Could not add bookmark') } + } + + const removeItem = async (item: CollectionItem) => { + if (!detail) return + const res = await fetch(`/api/collections/${detail.id}/items/${item.id}`, { method: 'DELETE' }) + if (await mutate(res, () => loadItems(detail.id, viewId))) { + setItems((current) => current.filter((i) => i.id !== item.id)) + loadCollections() + } + } + + const saveValue = async (item: CollectionItem, propId: string, value: unknown) => { + if (!detail) return + const res = await patch(`/api/collections/${detail.id}/items/${item.id}`, { + version: item.version, propertyValues: { [propId]: value }, + }) + if (res.ok) { + const updated: CollectionItem = await res.json() + setItems((current) => current.map((i) => (i.id === updated.id ? updated : i))) + } else await mutate(res, () => loadItems(detail.id, viewId)) + } + + // Board drag-and-drop and the per-card select both go through the same + // versioned membership PATCH. Update optimistically, then reconcile with the + // server; any failure reloads so a rejected value never sticks locally. + const moveItemToGroup = async (item: CollectionItem, next: string | null) => { + if (!detail || !groupProp) return + const current = item.propertyValues?.[groupProp.id] + const normalized = current === undefined || current === '' ? null : current + if (normalized === next) return + setItems((current) => current.map((i) => i.id === item.id ? { + ...i, propertyValues: { ...i.propertyValues, [groupProp.id]: next }, + } : i)) + const res = await patch(`/api/collections/${detail.id}/items/${item.id}`, { + version: item.version, propertyValues: { [groupProp.id]: next }, + }) + if (res.ok) { + const updated: CollectionItem = await res.json() + setItems((current) => current.map((i) => (i.id === updated.id ? updated : i))) + return + } + const handled = await mutate(res, () => loadItems(detail.id, viewId)) + if (!handled && res.status !== 409) await loadItems(detail.id, viewId) + } + + // Debounced bookmark search for the add dialog. + useEffect(() => { + if (!addOpen) return + const q = addQuery.trim() + const t = setTimeout(async () => { + try { + const params = new URLSearchParams({ limit: '8' }) + if (q) params.set('search', q) + const res = await fetch(`/api/bookmarks?${params}`) + if (res.ok) setAddResults((await res.json()).slice(0, 8)) + } catch { /* transient; retried on next keystroke */ } + }, 250) + return () => clearTimeout(t) + }, [addQuery, addOpen]) + + const filterClauses = activeView?.filters?.clauses ?? [] + const setClause = (clauses: FilterClause[]) => { void patchView({ filters: { operator: activeView?.filters?.operator ?? 'AND', clauses } }) } + const addClause = () => { + if (!fProp || !fOp) return + const prop = detail?.properties.find((p) => p.id === fProp) + if (!prop) return + let value: unknown + if (!['empty', 'notEmpty'].includes(fOp)) { + if (prop.type === 'NUMBER') value = Number(fValue) + else if (prop.type === 'CHECKBOX') value = fValue === 'true' + else value = fValue + if (fValue === '' || (prop.type === 'NUMBER' && Number.isNaN(value))) { toast.error('Filter value required'); return } + } + setClause([...filterClauses, value === undefined ? { propertyId: fProp, op: fOp } : { propertyId: fProp, op: fOp, value }]) + setFilterOpen(false); setFValue('') + } + const clauseLabel = (c: FilterClause) => { + const prop = detail?.properties.find((p) => p.id === c.propertyId) + if (!prop) return 'unknown' + let shown = c.value + if (prop.type === 'SELECT' && typeof c.value === 'string') shown = prop.config.options?.find((o) => o.id === c.value)?.label ?? c.value + if (prop.type === 'CHECKBOX') shown = c.value === true ? 'checked' : 'unchecked' + return `${prop.name} ${OP_LABELS[c.op] ?? c.op}${shown !== undefined ? ` ${shown}` : ''}` + } + + const renderTable = () => ( +
+ + + + Bookmark + {(detail?.properties ?? []).map((p) => ( + + + {p.name} + + + + ))} + + + + + {items.map((item) => ( + + + {(detail?.properties ?? []).map((p) => ( + saveValue(item, p.id, v)} /> + ))} + + + + + ))} + {items.length === 0 && ( + + + No items{filterClauses.length ? " match this view's filters" : ''}. Add existing bookmarks — nothing is duplicated. + + + )} + +
+
+ ) + + const renderBoard = () => { + if (!groupProp || groupProp.type !== 'SELECT') { + return ( +
+ +

Board views group by a select property.

+
+ +
+
+ ) + } + const options = groupProp.config.options ?? [] + const columns = [...options.map((o) => ({ key: o.id, label: o.label, color: o.color })), { key: NONE, label: 'No value', color: undefined }] + return ( +
+ {columns.map((col) => { + const colItems = items.filter((i) => { + const v = i.propertyValues?.[groupProp.id] + return col.key === NONE ? (v === undefined || v === null) : v === col.key + }) + const isDropTarget = !!draggedItemId && boardDropKey === col.key + return ( +
{ + if (!draggedItemId) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + setBoardDropKey(col.key) + }} + onDragLeave={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setBoardDropKey(null) + }} + onDrop={(e) => { + e.preventDefault() + const item = items.find((i) => i.id === draggedItemId) + if (item) void moveItemToGroup(item, col.key === NONE ? null : col.key) + setDraggedItemId(null) + setBoardDropKey(null) + }} + className={cn( + 'w-64 shrink-0 rounded-lg border border-border/60 bg-muted/30 p-2 transition-[box-shadow,background-color,border-color] duration-150', + isDropTarget && 'border-primary/40 bg-primary/[0.04] ring-2 ring-primary/40', + )} + > +
+ {col.color && } + {col.label} + {colItems.length} +
+
+ {colItems.map((item) => ( +
+
+ { + setDraggedItemId(item.id) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', item.id) + }} + onDragEnd={() => { + setDraggedItemId(null) + setBoardDropKey(null) + }} + className="mt-0.5 cursor-grab text-muted-foreground/50 transition-colors hover:text-muted-foreground active:cursor-grabbing" + title="Drag to another group" + aria-label={`Drag ${item.bookmark.title ?? 'bookmark'}`} + > + + +
+ +
+
+
+ + +
+
+ ))} + {colItems.length === 0 && ( +

+ {isDropTarget ? 'Drop here' : 'Empty'} +

+ )} +
+
+ ) + })} +
+ ) + } + + return ( +
+
+ {/* Collection list */} +
+

+ Collections +

+
+ setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') createCollection() }} placeholder="New collection" className="h-8 text-sm" aria-label="New collection name" /> + +
+
+ {collections.map((c) => ( + + ))} + {collections.length === 0 && ( +

+ Collections hold workflow state per project. The same bookmark can live in many — nothing is duplicated. +

+ )} +
+
+ + {/* Detail */} +
+ {!detail ? ( +
+ +

{collections.length ? 'Select a collection' : 'No collections yet'}

+

+ A collection is a saved workflow over existing bookmarks: per-collection status, priority, dates — independent everywhere the link appears. +

+
+ ) : ( + <> +
+ setNameDraft(e.target.value)} onBlur={saveName} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} aria-label="Collection name" className="w-72 bg-transparent font-display text-2xl font-bold tracking-tight" /> + {detail._count?.items ?? 0} item{(detail._count?.items ?? 0) === 1 ? '' : 's'} +
+ + + +
+
+ + {/* View tabs */} +
+ {detail.views.map((v) => { + const Icon = VIEW_ICONS[v.type] ?? Table2 + return ( + + ) + })} + + {activeView && ( +
+ { setFilterOpen(o); if (o) { setFProp(detail.properties[0]?.id ?? ''); setFOp(FILTER_OPS[detail.properties[0]?.type ?? 'TEXT']?.[0] ?? 'eq') } }}> + + + + + + + + {!['empty', 'notEmpty'].includes(fOp) && (() => { + const p = detail.properties.find((x) => x.id === fProp) + if (p?.type === 'SELECT') return ( + + ) + if (p?.type === 'CHECKBOX') return ( + + ) + return setFValue(e.target.value)} placeholder="Value" aria-label="Filter value" className="h-8 text-sm" /> + })()} + + + + +
+ )} +
+ + {/* Active filter chips */} + {filterClauses.length > 0 && ( +
+ {activeView?.filters?.operator ?? 'AND'}: + {filterClauses.map((c, i) => ( + + {clauseLabel(c)} + + + ))} +
+ )} + + {activeView?.type === 'BOARD' ? renderBoard() : renderTable()} + {activeView && (activeView.type === 'CALENDAR' || activeView.type === 'LIST') && ( +

Calendar and list layouts render as a table in this phase.

+ )} + {cursor && ( + + )} + + )} +
+
+ + {/* Add bookmarks dialog */} + { setAddOpen(o); if (!o) setAddQuery('') }}> + + Add existing bookmarks +

Membership is contextual — adding here never duplicates or re-owns the bookmark.

+ setAddQuery(e.target.value)} placeholder="Search bookmarks…" aria-label="Search bookmarks" /> +
+ {addResults.map((b) => { + const member = items.some((i) => i.bookmarkId === b.id) + return ( +
+
+

{b.title || b.url}

+

{b.url}

+
+ +
+ ) + })} + {addResults.length === 0 &&

No bookmarks found

} +
+
+
+ + {/* New property dialog */} + + + New property +
+ setPropName(e.target.value)} placeholder="Name (e.g. Status)" aria-label="Property name" /> + +

Type is permanent — conversions that could destroy values are refused by design.

+ {(propType === 'SELECT' || propType === 'MULTI_SELECT') && ( +
+ + {propOptions.map((o, i) => ( +
+ setPropOptions(propOptions.map((x, j) => (j === i ? { ...x, color: e.target.value } : x)))} className="h-8 w-10 shrink-0 p-1" /> + setPropOptions(propOptions.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} placeholder="Option label" aria-label="Option label" className="h-8 text-sm" /> + +
+ ))} + +
+ )} + {propType === 'DATE' && ( + + )} + +
+
+
+ + {/* New view dialog */} + + + New view +
+ setViewName(e.target.value)} placeholder="View name" aria-label="View name" /> + +

A view is a saved query — filters and grouping never copy records.

+ +
+
+
+
+ ) +} diff --git a/web/app/(app)/collections/page.tsx b/web/app/(app)/collections/page.tsx new file mode 100644 index 0000000..63bdac8 --- /dev/null +++ b/web/app/(app)/collections/page.tsx @@ -0,0 +1,5 @@ +import { CollectionsClient } from './client' + +export default function CollectionsPage() { + return +} diff --git a/web/app/(app)/contacts/client.tsx b/web/app/(app)/contacts/client.tsx new file mode 100644 index 0000000..8c91888 --- /dev/null +++ b/web/app/(app)/contacts/client.tsx @@ -0,0 +1,121 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { Button } from '@/components/ui/button' +import { BookmarkCard } from '@/components/bookmark-card' +import { BookmarkDetailSheet } from '@/components/bookmark-detail-sheet' +import { Users, RefreshCw, Mail, Phone } from 'lucide-react' +import { toast } from 'sonner' +import type { Bookmark } from '@/components/app-shell' +import { fetchBookmarks } from '@/lib/bookmarks' + +interface Contact { + id: string + uid: string + displayName: string + email: string | null + phone: string | null + _count?: { bookmarks: number } +} + +export function ContactsClient() { + const [contacts, setContacts] = useState([]) + const [selectedContact, setSelectedContact] = useState(null) + const [bookmarks, setBookmarks] = useState([]) + const [selectedBookmark, setSelectedBookmark] = useState(null) + const [syncing, setSyncing] = useState(false) + const [loading, setLoading] = useState(true) + + const loadContacts = useCallback(async () => { + setLoading(true) + try { + const res = await fetch('/api/contacts') + if (res.ok) setContacts(await res.json()) + } catch { toast.error('Could not load contacts') } + finally { setLoading(false) } + }, []) + + const loadBookmarks = useCallback(async (contactId: string) => { + try { setBookmarks(await fetchBookmarks(new URLSearchParams({ contactId }))) } + catch { toast.error('Could not load bookmarks') } + }, []) + + useEffect(() => { const timer = setTimeout(loadContacts, 0); return () => clearTimeout(timer) }, [loadContacts]) + useEffect(() => { const timer = setTimeout(() => { if (selectedContact) loadBookmarks(selectedContact) }, 0); return () => clearTimeout(timer) }, [selectedContact, loadBookmarks]) + useEffect(() => { const reload = () => { loadContacts(); if (selectedContact) loadBookmarks(selectedContact) }; window.addEventListener('computer:bookmarks-changed', reload); return () => window.removeEventListener('computer:bookmarks-changed', reload) }, [loadContacts, loadBookmarks, selectedContact]) + + const syncContacts = async () => { + setSyncing(true) + toast.info('Configure your Nextcloud settings in Settings → Nextcloud to sync contacts') + setSyncing(false) + } + + return ( +
+
+
+

Contacts

+ +
+ {loading ? ( +
{Array.from({ length: 5 }).map((_, i) =>
)}
+ ) : contacts.length === 0 ? ( +
+ +

No contacts yet

+

Sync from Nextcloud in Settings

+
+ ) : ( +
+ {contacts.map((c: Contact) => ( +
setSelectedContact(c.id)} + onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedContact(c.id) } }} + className={`flex items-center gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition-colors focus-visible:outline-2 focus-visible:outline-primary ${ + selectedContact === c.id ? 'bg-accent' : 'hover:bg-muted' + }`} + > +
+ {(c.displayName ?? '?')[0]?.toUpperCase()} +
+
+

{c.displayName}

+ {c.email &&

{c.email}

} +
+ {c._count?.bookmarks ?? 0} +
+ ))} +
+ )} +
+ +
+ {selectedContact ? ( +
+

Bookmarks for {contacts.find(c => c.id === selectedContact)?.displayName}

+ {bookmarks.length === 0 ? ( +

No bookmarks linked to this contact

+ ) : ( +
+ {bookmarks.map((b: Bookmark) => setSelectedBookmark(b)} />)} +
+ )} +
+ ) : ( +
+ +

Select a contact to view linked bookmarks

+
+ )} +
+ + setSelectedBookmark(null)} onUpdate={() => { if (selectedContact) loadBookmarks(selectedContact) }} /> +
+ ) +} diff --git a/web/app/(app)/contacts/page.tsx b/web/app/(app)/contacts/page.tsx new file mode 100644 index 0000000..c4ac7c0 --- /dev/null +++ b/web/app/(app)/contacts/page.tsx @@ -0,0 +1,5 @@ +import { ContactsClient } from './client' + +export default function ContactsPage() { + return +} diff --git a/web/app/(app)/dashboard/page.tsx b/web/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..c45345c --- /dev/null +++ b/web/app/(app)/dashboard/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from 'next/navigation' + +// The dashboard was folded into the "All Bookmarks" filter view. +export default async function DashboardPage({ searchParams }: { searchParams: Promise<{ search?: string }> }) { + const { search } = await searchParams + redirect(search ? `/bookmarks?search=${encodeURIComponent(search)}` : '/bookmarks') +} diff --git a/web/app/(app)/kanban/client.tsx b/web/app/(app)/kanban/client.tsx new file mode 100644 index 0000000..592bef8 --- /dev/null +++ b/web/app/(app)/kanban/client.tsx @@ -0,0 +1,444 @@ +'use client' + +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useSession } from 'next-auth/react' +import { BookmarkDetailSheet } from '@/components/bookmark-detail-sheet' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { ExternalLink, Calendar, GripVertical, Plus, MoreHorizontal, Pencil, Trash2, ArrowLeft, ArrowRight, Check, X } from 'lucide-react' +import { toast } from 'sonner' +import type { Bookmark } from '@/components/app-shell' +import { fetchBookmarks } from '@/lib/bookmarks' +import { invalidateReference } from '@/lib/reference-data' +import { cn } from '@/lib/utils' + +type Column = { id: string; label: string; color: string; position: number; key: string | null } +type CardDrop = { columnId: string; index: number } +const PALETTE = ['#64748b', '#3b82f6', '#f59e0b', '#22c55e', '#8b5cf6', '#ec4899', '#ef4444', '#14b8a6'] + +function columnFor(b: Bookmark, cols: Column[]) { + const explicit = (b as any).kanbanColumnId as string | null + if (explicit && cols.some(c => c.id === explicit)) return explicit + return cols.find(c => c.key === b.kanbanStatus)?.id ?? cols[0]?.id +} + +function sortCards(a: Bookmark, b: Bookmark, currentUserId?: string) { + // Owned cards keep their persisted board order. Shared cards are read-only + // and sort after owned ones so they cannot disturb the owner's positions. + if (currentUserId) { + const aOwned = a.ownerId === currentUserId + const bOwned = b.ownerId === currentUserId + if (aOwned !== bOwned) return aOwned ? -1 : 1 + } + const ap = a.kanbanPosition ?? 0 + const bp = b.kanbanPosition ?? 0 + if (ap !== bp) return ap - bp + return b.updatedAt.localeCompare(a.updatedAt) || b.id.localeCompare(a.id) +} + +export function KanbanClient() { + const { data: session } = useSession() + const currentUserId = session?.user?.id + const [bookmarks, setBookmarks] = useState([]) + const [columns, setColumns] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedBookmark, setSelectedBookmark] = useState(null) + const [draggedId, setDraggedId] = useState(null) + const [draggedColumnId, setDraggedColumnId] = useState(null) + const [cardDrop, setCardDrop] = useState(null) + const [columnDropId, setColumnDropId] = useState(null) + const [editingId, setEditingId] = useState(null) + const [editLabel, setEditLabel] = useState('') + const [adding, setAdding] = useState(false) + const [newLabel, setNewLabel] = useState('') + const suppressClick = useRef(false) + + const load = useCallback(async () => { + setLoading(true) + try { + const [bookmarks, cRes] = await Promise.all([fetchBookmarks(), fetch('/api/kanban/columns')]) + setBookmarks(bookmarks) + if (cRes.ok) setColumns(await cRes.json()) + } catch (e) { console.error('Kanban load failed', e) } + setLoading(false) + }, []) + + useEffect(() => { const timer = setTimeout(load, 0); window.addEventListener('computer:bookmarks-changed', load); return () => { clearTimeout(timer); window.removeEventListener('computer:bookmarks-changed', load) } }, [load]) + + const isOwned = useCallback((b: Bookmark) => !currentUserId || b.ownerId === currentUserId, [currentUserId]) + + const cardsByColumn = useMemo(() => { + const groups: Record = {} + for (const col of columns) groups[col.id] = [] + for (const b of bookmarks) { + const columnId = columnFor(b, columns) + if (columnId && groups[columnId]) groups[columnId].push(b) + } + for (const list of Object.values(groups)) list.sort((a, b) => sortCards(a, b, currentUserId)) + return groups + }, [bookmarks, columns, currentUserId]) + + const clearDrag = useCallback(() => { + setDraggedId(null) + setDraggedColumnId(null) + setCardDrop(null) + setColumnDropId(null) + }, []) + + const finishDrag = useCallback(() => { + clearDrag() + // Drag gestures can still emit a click in some browsers; do not open the + // detail sheet when the pointer was dragging a card. + setTimeout(() => { suppressClick.current = false }, 0) + }, [clearDrag]) + + const saveColumnOrder = useCallback(async (next: Column[]) => { + setColumns(next) + const res = await fetch('/api/kanban/columns', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: next.map(c => c.id) }) }) + if (res.ok) invalidateReference('columns') + else { toast.error('Could not reorder columns'); load() } + }, [load]) + + const reorderColumn = useCallback((sourceId: string, targetId: string) => { + if (sourceId === targetId) return + const from = columns.findIndex(c => c.id === sourceId) + const to = columns.findIndex(c => c.id === targetId) + if (from < 0 || to < 0) return + const next = [...columns] + const [moved] = next.splice(from, 1) + // Drop before the target column; this is the least surprising direction + // for both leftward and rightward moves. + next.splice(from < to ? to - 1 : to, 0, moved) + saveColumnOrder(next) + }, [columns, saveColumnOrder]) + + // Position among the owner's cards, ignoring read-only shared cards and the + // card being dragged. The server normalizes the target column in this order. + const ownedDropIndex = useCallback((columnId: string, rawIndex: number, movedId: string) => { + const list = cardsByColumn[columnId] ?? [] + let index = 0 + for (let i = 0; i < Math.min(rawIndex, list.length); i++) { + const card = list[i] + if (card.id === movedId || !isOwned(card)) continue + index++ + } + return index + }, [cardsByColumn, isOwned]) + + const moveCardTo = useCallback(async (bookmarkId: string, columnId: string, position: number) => { + const current = bookmarks.find(b => b.id === bookmarkId) + if (!current) return + if (currentUserId && current.ownerId !== currentUserId) { + toast.error('Only the owner can move a shared bookmark') + return + } + const target = (cardsByColumn[columnId] ?? []).filter(b => b.id !== bookmarkId && isOwned(b)) + const clamped = Math.max(0, Math.min(position, target.length)) + const optimistic = [...target] + optimistic.splice(clamped, 0, { ...current, kanbanColumnId: columnId }) + const positions = new Map(optimistic.map((b, i) => [b.id, i])) + setBookmarks(prev => prev.map(b => { + if (b.id === bookmarkId) return { ...b, kanbanColumnId: columnId, kanbanPosition: clamped } + const next = positions.get(b.id) + return next === undefined ? b : { ...b, kanbanPosition: next } + })) + try { + const res = await fetch(`/api/kanban/cards/${bookmarkId}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + // Optimistic concurrency (review CR-04): the move carries the card's + // version; a card edited or moved elsewhere is a 409, and the board + // reloads the true state instead of silently clobbering it. + body: JSON.stringify({ columnId, position: clamped, version: current.version ?? 0 }), + }) + if (res.status === 409) { + toast.error('This bookmark was changed elsewhere — the board reloaded without your move') + load() + return + } + if (!res.ok) throw new Error(await res.text()) + const saved = await res.json().catch(() => null) + if (saved) setBookmarks(prev => prev.map(b => b.id === bookmarkId ? saved : b)) + } catch (e) { console.error(e); toast.error('Failed to move'); load() } + }, [bookmarks, cardsByColumn, currentUserId, isOwned, load]) + + const addColumn = async () => { + if (!newLabel.trim()) return + const res = await fetch('/api/kanban/columns', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label: newLabel, color: PALETTE[columns.length % PALETTE.length] }), + }) + if (res.ok) { const col = await res.json(); setColumns(c => [...c, col]); setNewLabel(''); setAdding(false); invalidateReference('columns') } + else toast.error('Could not add column') + } + + const saveColumn = async (id: string, data: Partial) => { + const res = await fetch(`/api/kanban/columns/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) + if (res.ok) { const col = await res.json(); setColumns(cs => cs.map(c => c.id === id ? col : c)); invalidateReference('columns') } + else toast.error('Could not update column') + setEditingId(null) + } + + const deleteColumn = async (col: Column) => { + if (columns.length <= 1) { toast.error('A board needs at least one column'); return } + const dest = columns.find(c => c.id !== col.id) + if (!confirm(`Delete "${col.label}"? Its bookmarks will move to "${dest?.label}".`)) return + const res = await fetch(`/api/kanban/columns/${col.id}`, { method: 'DELETE' }) + if (res.ok) { toast.success('Column deleted'); invalidateReference('columns'); load() } else toast.error((await res.json())?.error ?? 'Could not delete') + } + + const moveColumn = (index: number, dir: -1 | 1) => { + const next = [...columns] + const j = index + dir + if (j < 0 || j >= next.length) return + ;[next[index], next[j]] = [next[j], next[index]] + saveColumnOrder(next) + } + + // Keyboard alternative to drag-and-drop: focus a card, Arrow Left/Right + // moves it to the adjacent column. + const moveCard = (b: Bookmark, dir: -1 | 1) => { + if (!isOwned(b)) { toast.error('Only the owner can move a shared bookmark'); return } + const current = columnFor(b, columns) + const idx = columns.findIndex(c => c.id === current) + const j = idx + dir + if (idx < 0 || j < 0 || j >= columns.length) return + moveCardTo(b.id, columns[j].id, cardsByColumn[columns[j].id]?.filter(c => c.id !== b.id && isOwned(c)).length ?? 0) + toast.success(`Moved to ${columns[j].label}`) + } + + const handleCardDragOver = (e: React.DragEvent, col: Column, index: number) => { + if (!draggedId || draggedColumnId) return + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'move' + const rect = e.currentTarget.getBoundingClientRect() + const before = e.clientY < rect.top + rect.height / 2 + setCardDrop({ columnId: col.id, index: before ? index : index + 1 }) + } + + const handleColumnDragOver = (e: React.DragEvent, col: Column) => { + if (draggedColumnId) { + if (draggedColumnId === col.id) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + setColumnDropId(col.id) + setCardDrop(null) + return + } + if (!draggedId) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + // The pointer is over the column body, not a card: append to the end. + setCardDrop(prev => prev?.columnId === col.id ? prev : { columnId: col.id, index: (cardsByColumn[col.id] ?? []).length }) + } + + const handleColumnDrop = (e: React.DragEvent, col: Column) => { + e.preventDefault() + if (draggedColumnId) { + reorderColumn(draggedColumnId, col.id) + finishDrag() + return + } + if (draggedId) { + const rawIndex = cardDrop?.columnId === col.id ? cardDrop.index : (cardsByColumn[col.id] ?? []).length + moveCardTo(draggedId, col.id, ownedDropIndex(col.id, rawIndex, draggedId)) + finishDrag() + } + } + + const handleColumnDragLeave = (e: React.DragEvent) => { + if (e.currentTarget.contains(e.relatedTarget as Node | null)) return + if (draggedColumnId) setColumnDropId(null) + if (draggedId) setCardDrop(null) + } + + return ( +
+
+
+

Kanban Board

+

Drag cards within or between columns, drag a column by its grip to reorder it, or focus a card and press Arrow Left/Right. Touch users can use each card’s menu.

+
+ {!adding ? ( + + ) : ( +
+ setNewLabel(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addColumn(); if (e.key === 'Escape') setAdding(false) }} placeholder="Column name" className="h-8 w-40" /> + + +
+ )} +
+ +
+ {columns.map((col, idx) => { + const colBookmarks = cardsByColumn[col.id] ?? [] + const isCardTarget = !!draggedId && cardDrop?.columnId === col.id + const isColumnTarget = !!draggedColumnId && columnDropId === col.id && draggedColumnId !== col.id + return ( +
handleColumnDragOver(e, col)} + onDragLeave={handleColumnDragLeave} + onDrop={e => handleColumnDrop(e, col)} + > +
+
+ { + setDraggedColumnId(col.id) + setDraggedId(null) + setCardDrop(null) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', col.id) + }} + onDragEnd={finishDrag} + className="cursor-grab active:cursor-grabbing text-muted-foreground/40 hover:text-muted-foreground transition-colors" + title="Drag to reorder column" + aria-label={`Drag ${col.label} column`} + > + + + {editingId === col.id ? ( + setEditLabel(e.target.value)} onBlur={() => saveColumn(col.id, { label: editLabel })} + onKeyDown={e => { if (e.key === 'Enter') saveColumn(col.id, { label: editLabel }); if (e.key === 'Escape') setEditingId(null) }} className="h-7 text-sm" /> + ) : ( +

+ {col.label} +

+ )} +
+
+ {colBookmarks.length} + + + + + + { setEditingId(col.id); setEditLabel(col.label) }}>Rename + moveColumn(idx, -1)}>Move left + moveColumn(idx, 1)}>Move right + +
+ {PALETTE.map(c => ( +
+ + deleteColumn(col)}>Delete column +
+
+
+
+
+ {loading ? ( + Array.from({ length: 3 }).map((_, i) =>
) + ) : colBookmarks.length === 0 ? ( +
+ {draggedId ? 'Drop card here' : 'No cards yet'} +
+ ) : colBookmarks.map((b, index) => { + const canMove = isOwned(b) + const showLine = isCardTarget && cardDrop?.index === index + const currentColumn = columnFor(b, columns) + return ( + + {showLine &&
} +
{ + if (!canMove) { e.preventDefault(); return } + suppressClick.current = true + setDraggedId(b.id) + setDraggedColumnId(null) + setColumnDropId(null) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', b.id) + }} + onDragEnd={finishDrag} + onDragOver={e => handleCardDragOver(e, col, index)} + onClick={() => { if (!suppressClick.current) setSelectedBookmark(b) }} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedBookmark(b) } + else if (canMove && e.key === 'ArrowLeft') { e.preventDefault(); moveCard(b, -1) } + else if (canMove && e.key === 'ArrowRight') { e.preventDefault(); moveCard(b, 1) } + }} + className={cn( + 'rounded-lg border bg-card p-3 shadow-sm transition-[transform,box-shadow,opacity] duration-150 group focus-visible:outline-2 focus-visible:outline-primary', + canMove ? 'cursor-grab active:cursor-grabbing hover:-translate-y-0.5 hover:shadow-md' : 'cursor-default', + draggedId === b.id && 'opacity-40 scale-[0.98]', + )} + > +
+ +
+
+ {b.favicon && { e.target.style.display = 'none' }} />} +

{b.title ?? 'Untitled'}

+ {!canMove && Shared} +
+
+ {(b.tags ?? []).slice(0, 2).map((bt: any) => ( + {bt?.tag?.name} + ))} +
+ {b.dueDate && ( +

+ + {new Date(b.dueDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })} +

+ )} +
+
{ e.preventDefault(); e.stopPropagation() }}> + e.stopPropagation()} className="rounded p-0.5" aria-label="Open link"> + + + + + + + + setSelectedBookmark(b)}>Open details + {canMove && columns.length > 1 && } + {canMove && columns.filter(c => c.id !== currentColumn).map(c => ( + moveCardTo(b.id, c.id, cardsByColumn[c.id]?.filter(x => x.id !== b.id && isOwned(x)).length ?? 0)}> + Move to {c.label} + + ))} + + +
+
+
+ + ) + })} + {!loading && isCardTarget && cardDrop?.index === colBookmarks.length && colBookmarks.length > 0 && ( +
+ )} +
+
+ ) + })} +
+ + setSelectedBookmark(null)} onUpdate={load} /> +
+ ) +} diff --git a/web/app/(app)/kanban/page.tsx b/web/app/(app)/kanban/page.tsx new file mode 100644 index 0000000..233aeba --- /dev/null +++ b/web/app/(app)/kanban/page.tsx @@ -0,0 +1,5 @@ +import { KanbanClient } from './client' + +export default function KanbanPage() { + return +} diff --git a/web/app/(app)/layout.tsx b/web/app/(app)/layout.tsx new file mode 100644 index 0000000..0c24486 --- /dev/null +++ b/web/app/(app)/layout.tsx @@ -0,0 +1,11 @@ +import { auth } from '@/auth' +import { redirect } from 'next/navigation' +import { AppShellWrapper } from '@/components/app-shell-wrapper' + +export default async function AppLayout({ children }: { children: React.ReactNode }) { + const session = await auth() + if (!session?.user) redirect('/login') + // Pass the server session down so the shell and screens render during SSR + // instead of being discarded and refetched on the client. + return {children} +} diff --git a/web/app/(app)/pages/client.tsx b/web/app/(app)/pages/client.tsx new file mode 100644 index 0000000..163c1c0 --- /dev/null +++ b/web/app/(app)/pages/client.tsx @@ -0,0 +1,699 @@ +'use client' + +import { useCallback, useEffect, useRef, useState, type ClipboardEvent } from 'react' +import { useRouter } from 'next/navigation' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { toast } from 'sonner' +import { + ChevronLeft, ChevronRight, Code, Download, Eye, FileText, GripVertical, Heading1, Heading2, Heading3, + Link2, List, ListOrdered, ListTodo, Minus, Pencil, Pin, PinOff, Plus, Quote, Search, Trash2, Type, X, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { PageAutosave, PageConflictError, type PageSaveState } from '@/lib/page-autosave' +import { PAGES_CHANGED, nextUntitledTitle, notifyActivePage, notifyPagesChanged, type PageSummary } from '@/lib/pages' +import { reorderPages } from '@/lib/page-order' +import { fetchLinkPreview } from '@/lib/link-preview' +import { LinkPreviewCard } from '@/components/link-preview-card' +import type { Element, ElementContent } from 'hast' + +export { PAGES_CHANGED } + +// Notion-style slash menu: typing "/" at the start of a line offers blocks +// that insert their Markdown equivalent. The textarea stays the source of +// truth, so autosave and conflict handling are untouched. +const SLASH_BLOCKS = [ + { key: 'text', label: 'Text', desc: 'Plain paragraph', icon: Type, prefix: '' }, + { key: 'h1', label: 'Heading 1', desc: 'Large section heading', icon: Heading1, prefix: '# ' }, + { key: 'h2', label: 'Heading 2', desc: 'Medium section heading', icon: Heading2, prefix: '## ' }, + { key: 'h3', label: 'Heading 3', desc: 'Small section heading', icon: Heading3, prefix: '### ' }, + { key: 'todo', label: 'To-do list', desc: 'Track tasks with a checkbox', icon: ListTodo, prefix: '- [ ] ' }, + { key: 'bullet', label: 'Bulleted list', desc: 'Simple bulleted list', icon: List, prefix: '- ' }, + { key: 'num', label: 'Numbered list', desc: 'List with numbering', icon: ListOrdered, prefix: '1. ' }, + { key: 'quote', label: 'Quote', desc: 'Capture a quote', icon: Quote, prefix: '> ' }, + { key: 'div', label: 'Divider', desc: 'Visual section break', icon: Minus, prefix: '---\n' }, + { key: 'code', label: 'Code', desc: 'Code snippet block', icon: Code, prefix: '```\n' }, +] as const + +type LinkedBookmark = { id: string; url: string; title: string | null; favicon: string | null; ogImage: string | null; ogTitle: string | null; ogDescription: string | null; description: string | null } +type PageDetail = PageSummary & { content: string; version: number; bookmarks: { bookmark: LinkedBookmark }[] } + +const notify = notifyPagesChanged +const host = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, '') } catch { return u } } + +const textOf = (nodes: ElementContent[]): string => + nodes.map((n) => (n.type === 'text' ? n.value : n.type === 'element' ? textOf(n.children) : '')).join('') + +// A paragraph whose only content is a single external link — a bare URL +// autolinked by GFM or a lone [label](url) — renders as a Notion-style +// preview card instead of a plain

. Links woven into sentences stay +// inline text; the Markdown source remains the source of truth either way. +function standaloneLink(node: Element | undefined): { href: string; label: string } | null { + if (!node) return null + const kids = node.children.filter((c) => !(c.type === 'text' && c.value.trim() === '')) + if (kids.length !== 1) return null + const a = kids[0] + if (a.type !== 'element' || a.tagName !== 'a') return null + const href = a.properties?.href + if (typeof href !== 'string' || !/^https?:\/\//i.test(href)) return null + return { href, label: textOf(a.children) } +} + +export function PagesClient({ initialId }: { initialId: string | null }) { + const router = useRouter() + const [pages, setPages] = useState([]) + const [activeId, setActiveId] = useState(initialId) + const [page, setPage] = useState(null) + const [filter, setFilter] = useState('') + const [mode, setMode] = useState<'edit' | 'preview'>('preview') + const [title, setTitle] = useState('') + const [content, setContent] = useState('') + const [saveStates, setSaveStates] = useState>({}) + const [linkQuery, setLinkQuery] = useState('') + const [results, setResults] = useState([]) + const [linkOpen, setLinkOpen] = useState(false) + const [slash, setSlash] = useState<{ query: string; start: number } | null>(null) + const [slashIndex, setSlashIndex] = useState(0) + const [tabDrop, setTabDrop] = useState<{ id: string; after: boolean } | null>(null) + const textareaRef = useRef(null) + const pendingCaret = useRef(null) + const suppressTabClick = useRef(false) + const draggedTabRef = useRef(null) + const activePage = useRef(initialId) + const loadSequence = useRef(0) + const selectionSequence = useRef(0) + // Optimistic concurrency (review DATA-01): PageAutosave tracks the server + // revision per page and sends it with content saves, so a newer save from + // another tab or device answers 409 instead of being silently overwritten. + const persistContent = useCallback(async (id: string, content: string, version: number | undefined) => { + const body = JSON.stringify(typeof version === 'number' ? { content, version } : { content }) + let res: Response + try { + res = await fetch(`/api/pages/${id}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body, + keepalive: new TextEncoder().encode(body).length < 60000, + }) + } catch (error) { + toast.error('Could not save page. Your draft is kept in this tab; retry saving.') + throw error + } + if (res.status === 409) { + // Another session saved first. Review DATA-04: the queue enters a + // paused conflict state instead of adopting the remote version, so + // nothing overwrites the newer content until the user explicitly + // chooses to overwrite, discard, or copy the draft. + let serverVersion: number | undefined + try { + const latest = await fetch(`/api/pages/${id}`, { cache: 'no-store' }).then(r => (r.ok ? r.json() : null)) + if (latest && typeof latest.version === 'number') serverVersion = latest.version + } catch { /* keep the stale base; the conflict state pauses autosave either way */ } + toast.error('This page was saved in another tab or device. Autosave is paused so nothing is overwritten until you resolve the conflict.') + throw new PageConflictError(serverVersion) + } + if (!res.ok) { + toast.error('Could not save page. Your draft is kept in this tab; retry saving.') + throw new Error('Save failed') + } + const saved = await res.json().catch(() => null) + return saved && typeof saved.version === 'number' ? saved.version : undefined + }, []) + const [autosave] = useState(() => new PageAutosave(persistContent, (id, state) => setSaveStates(previous => ({ ...previous, [id]: state })))) + const saveState = activeId ? saveStates[activeId] ?? 'saved' : 'saved' + + useEffect(() => { + const beforeUnload = (event: BeforeUnloadEvent) => { + if (autosave.hasUnsaved()) { event.preventDefault(); event.returnValue = '' } + } + const flush = () => { void autosave.flushAll() } + window.addEventListener('beforeunload', beforeUnload) + window.addEventListener('pagehide', flush) + return () => { + window.removeEventListener('beforeunload', beforeUnload) + window.removeEventListener('pagehide', flush) + flush() + } + }, [autosave]) + + const loadPages = useCallback(async () => { + const res = await fetch('/api/pages') + if (!res.ok) return [] as PageSummary[] + const data: PageSummary[] = await res.json() + setPages(data) + return data + }, []) + + const loadPage = useCallback(async (id: string) => { + const sequence = ++loadSequence.current + const res = await fetch(`/api/pages/${id}`) + if (sequence !== loadSequence.current || activePage.current !== id) return + if (!res.ok) { setPage(null); notifyActivePage(null); return } + const data: PageDetail = await res.json() + if (sequence !== loadSequence.current || activePage.current !== id) return + if (typeof data.version === 'number') autosave.setVersion(data.id, data.version) + // Review DATA-03: recovery compares the draft's original base version + // with the freshly fetched server version; a mismatch pauses autosave in + // the conflict state instead of silently rebasing onto the newer save. + const draft = autosave.restore(id, data.version) + setPage(data) + setTitle(data.title) + setContent(draft ?? data.content) + notifyActivePage(data.title) + if (draft !== undefined || !data.content) setMode('edit') + }, [autosave]) + + useEffect(() => { + const timer = setTimeout(() => loadPages().then((data) => { + if (!activePage.current && data.length) { activePage.current = data[0].id; setActiveId(data[0].id) } + }).catch((e) => console.error('Failed to load pages', e)), 0) + return () => clearTimeout(timer) + }, [loadPages]) + + useEffect(() => { + const timer = setTimeout(() => { + if (activeId) loadPage(activeId).catch((e) => console.error('Failed to load page', e)) + else setPage(null) + }, 0) + return () => clearTimeout(timer) + }, [activeId, loadPage]) + + const select = async (id: string) => { + const sequence = ++selectionSequence.current + if (activePage.current && !await autosave.flush(activePage.current)) return + if (sequence !== selectionSequence.current) return + activePage.current = id + setActiveId(id) + router.replace(`/pages?id=${id}`) + } + + // The sidebar can create or open pages while this workspace stays mounted. + useEffect(() => { + if (!initialId || initialId === activePage.current) return + void loadPages() + void select(initialId) + }, [initialId]) + + // Sidebar mutations (rename, pin, delete) announce themselves; keep the + // page list in step and drop the detail view when the open page is gone. + useEffect(() => { + const reload = () => { + void loadPages().then((data) => { + if (activePage.current && !data.some((p) => p.id === activePage.current)) { + activePage.current = null + setActiveId(null) + setPage(null) + notifyActivePage(null) + router.replace('/pages') + } + }) + } + window.addEventListener(PAGES_CHANGED, reload) + return () => window.removeEventListener(PAGES_CHANGED, reload) + }, [loadPages, router]) + + // Leaving the pages workspace clears the breadcrumb title. + useEffect(() => () => { notifyActivePage(null) }, []) + + const patch = async (body: Record) => { + if (!activeId || page?.id !== activeId) return null + const res = await fetch(`/api/pages/${page.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + if (!res.ok) { toast.error('Could not save page'); return null } + // Review CR-17: metadata responses (rename/pin/link changes) carry the + // CURRENT content revision but never require or acknowledge a content + // base — adopting it here would silently rebase an in-flight stale draft + // onto another editor's save and authorize overwriting it. Only content + // saves (persistContent) and fresh page loads may move the version map. + return res + } + + const createPage = async () => { + if (activePage.current && !await autosave.flush(activePage.current)) return + const res = await fetch('/api/pages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: nextUntitledTitle(pages.map((p) => p.title)) }) }) + if (!res.ok) { toast.error('Could not create page'); return } + const p = await res.json() + await loadPages() + notify() + setMode('edit') + select(p.id) + } + + const onContentChange = (v: string) => { + if (!page || page.id !== activeId) return + setContent(v) + autosave.edit(page.id, v) + } + + // Show the block menu only while the caret sits on a bare "/token" line. + const updateSlash = (text: string, caret: number) => { + const lineStart = text.lastIndexOf('\n', caret - 1) + 1 + const line = text.slice(lineStart, caret) + const m = /^\/([\w-]*)$/.exec(line) + if (m) { setSlash({ query: m[1].toLowerCase(), start: lineStart }); setSlashIndex(0) } + else setSlash(null) + } + + const slashFiltered = SLASH_BLOCKS.filter((b) => !slash?.query || b.key.includes(slash.query) || b.label.toLowerCase().includes(slash.query)) + + const applyBlock = (block: (typeof SLASH_BLOCKS)[number]) => { + const ta = textareaRef.current + if (!slash || !ta) return + const caret = ta.selectionStart + const next = content.slice(0, slash.start) + block.prefix + content.slice(caret) + pendingCaret.current = slash.start + block.prefix.length + onContentChange(next) + setSlash(null) + } + + // Restore the caret after a programmatic content rewrite (slash insert, + // "[] " shortcut). Runs once the new content has reached the textarea. + useEffect(() => { + if (pendingCaret.current === null || mode !== 'edit') return + const ta = textareaRef.current + if (!ta) return + const c = pendingCaret.current + pendingCaret.current = null + ta.focus() + ta.setSelectionRange(c, c) + }, [content, mode]) + + const saveTitle = async () => { + if (!page) return + const t = title.trim() + // Keep the generated "Untitled N" name until the user gives a real one. + if (!t) { setTitle(page.title); return } + if (t === page.title) return + if (await patch({ title: t })) { + setPage(current => current?.id === page.id ? { ...current, title: t } : current) + notifyActivePage(t) + await loadPages(); notify() + } + } + + const togglePin = async () => { + if (!page) return + if (await patch({ pinned: !page.pinned })) { + setPage(current => current?.id === page.id ? { ...current, pinned: !page.pinned } : current) + await loadPages(); notify() + } + } + + const move = async (dir: -1 | 1) => { + if (!activeId) return + const idx = pages.findIndex((p) => p.id === activeId) + const target = pages[idx + dir] + if (idx < 0 || !target) return + const next = reorderPages(pages, activeId, target.id, dir > 0) + if (next === 'blocked') { toast.error('Pinned and unpinned pages keep separate groups'); return } + if (next) await savePageOrder(next) + } + + const savePageOrder = async (next: PageSummary[]) => { + setPages(next) + const res = await fetch('/api/pages', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: next.map((p) => p.id) }) }) + if (!res.ok) { toast.error('Could not reorder pages'); await loadPages(); return } + await loadPages() + notify() + } + + const dropTab = (target: PageSummary, after: boolean) => { + const source = draggedTabRef.current + draggedTabRef.current = null + setTabDrop(null) + if (!source) return + const next = reorderPages(pages, source, target.id, after) + if (next === 'blocked') { toast.error('Pinned and unpinned pages keep separate groups'); return } + if (next) void savePageOrder(next) + } + + const download = async () => { + if (!activeId) return + // A paused conflict can never flush; exporting the server copy is still + // useful, so only ordinary save failures block the download. + if (autosave.conflictVersion(activeId) === null && !await autosave.flush(activeId)) return + const a = document.createElement('a') + a.href = `/api/pages/${activeId}/markdown` + a.download = '' + document.body.appendChild(a); a.click(); a.remove() + } + + // Explicit conflict resolutions (review DATA-04). Typing is never consent; + // each path is a deliberate click. + const overwriteRemote = async () => { + if (!activeId) return + if (await autosave.resolveOverwrite(activeId)) toast.success('Your draft replaced the newer version') + else toast.error('Could not save page. Your draft is kept in this tab; retry saving.') + } + const discardDraft = async () => { + if (!activeId) return + autosave.discard(activeId) + await loadPage(activeId) + } + const copyDraft = async () => { + const res = await fetch('/api/pages', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: `${page?.title ?? 'Untitled'} (draft copy)`, content }), + }) + if (!res.ok) { toast.error('Could not create the copy'); return } + toast.success('Draft copied to a new page') + await loadPages(); notify() + } + + const remove = async () => { + if (!page || !confirm(`Delete page "${page.title}"? Bookmarks are kept.`)) return + if (!await autosave.flush(page.id)) return + const res = await fetch(`/api/pages/${page.id}`, { method: 'DELETE' }) + if (!res.ok) { toast.error('Could not delete page'); return } + autosave.forget(page.id) + const data = await loadPages(); notify() + if (data.length) select(data[0].id) + else { activePage.current = null; setActiveId(null); router.replace('/pages') } + } + + useEffect(() => { + if (!linkOpen) return + const q = linkQuery.trim() + const t = setTimeout(async () => { + try { + const params = new URLSearchParams({ limit: '8' }) + if (q) params.set('search', q) + const res = await fetch(`/api/bookmarks?${params}`) + if (res.ok) setResults((await res.json()).slice(0, 8)) + } catch { /* transient; retried on next keystroke */ } + }, 250) + return () => clearTimeout(t) + }, [linkQuery, linkOpen]) + + const addBookmark = async (bookmarkId: string) => { + if (activeId && !await autosave.flush(activeId)) return + if (await patch({ addBookmarkId: bookmarkId })) { + setLinkOpen(false); setLinkQuery('') + if (activeId) await loadPage(activeId) + await loadPages() + } + } + + const addUrl = async () => { + let url = linkQuery.trim() + if (!url) return + if (!/^https?:\/\//i.test(url)) url = `https://${url}` + const res = await fetch('/api/bookmarks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }) + if (!res.ok) { toast.error('Could not save URL'); return } + const b = await res.json() + await addBookmark(b.id) + toast.success('Link added') + } + + const removeBookmark = async (bookmarkId: string) => { + if (await patch({ removeBookmarkId: bookmarkId })) { + if (page) setPage({ ...page, bookmarks: page.bookmarks.filter((x) => x.bookmark.id !== bookmarkId) }) + await loadPages() + } + } + + // Notion-style paste: a bare URL lands in the textarea untouched, and once + // its preview resolves we offer to upgrade it to a titled Markdown link. + // Nothing is rewritten without an explicit click, so undo stays intact. + const offerTitleForPastedUrl = (url: string) => { + void fetchLinkPreview(url).then((p) => { + const raw = p?.ogTitle || p?.title + if (!raw) return + const title = raw.replace(/[\[\]\n]/g, ' ').trim() + if (!title || title === url) return + toast.info('Paste as titled link?', { + description: title, + duration: 10000, + action: { + label: 'Use title', + onClick: () => { + const ta = textareaRef.current + if (!ta) return + const idx = ta.value.indexOf(url) + if (idx < 0) return + const markdown = `[${title}](${url})` + pendingCaret.current = idx + markdown.length + onContentChange(ta.value.slice(0, idx) + markdown + ta.value.slice(idx + url.length)) + }, + }, + }) + }) + } + + const onPaste = (e: ClipboardEvent) => { + const text = e.clipboardData.getData('text/plain').trim() + if (/^https?:\/\/\S+$/i.test(text)) offerTitleForPastedUrl(text) + } + + const visible = pages.filter((p) => p.title.toLowerCase().includes(filter.toLowerCase())) + const looksLikeUrl = /^(https?:\/\/)?[\w-]+(\.[\w-]+)+/i.test(linkQuery.trim()) + + return ( +

+
+
+ + setFilter(e.target.value)} placeholder="Filter pages" className="h-8 pl-7 text-sm" /> +
+
+ {visible.map((p) => ( +
{ + if (!draggedTabRef.current || draggedTabRef.current === p.id) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + const rect = e.currentTarget.getBoundingClientRect() + setTabDrop({ id: p.id, after: e.clientX > rect.left + rect.width / 2 }) + }} + onDragLeave={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setTabDrop(null) + }} + onDrop={(e) => { + e.preventDefault() + const rect = e.currentTarget.getBoundingClientRect() + dropTab(p, e.clientX > rect.left + rect.width / 2) + }} + title={filter.trim() ? 'Clear the filter to reorder pages' : 'Drag the grip to reorder page'} + className={cn( + 'group/tab flex shrink-0 items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors', + p.id === activeId ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground', + tabDrop?.id === p.id && 'ring-1 ring-primary/50', + )} + > + {!filter.trim() && ( + { + suppressTabClick.current = true + draggedTabRef.current = p.id + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', p.id) + }} + onDragEnd={() => { + draggedTabRef.current = null + setTabDrop(null) + setTimeout(() => { suppressTabClick.current = false }, 0) + }} + className="cursor-grab text-muted-foreground/40 opacity-0 transition-opacity hover:text-muted-foreground active:cursor-grabbing group-hover/tab:opacity-100" + aria-label={`Drag page ${p.title}`} + > + + + )} + +
+ ))} +
+ +
+ + {!page || page.id !== activeId ? ( +
+ +

{pages.length ? 'Select a page' : 'No pages yet'}

+

Pages are Markdown notes that collect links into a workspace. A bookmark can live on many pages.

+ +
+ ) : ( +
+
+ setTitle(e.target.value)} onBlur={saveTitle} onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()} className="min-w-0 flex-1 bg-transparent font-display text-3xl font-bold outline-none" aria-label="Page title" /> + {saveState === 'saving' ? 'Saving…' : saveState === 'dirty' ? 'Unsaved' : saveState === 'conflict' ? 'Conflict' : 'Saved'} + {saveState === 'dirty' && } + + + + + + +
+ + {saveState === 'conflict' && ( +
+

This page was changed elsewhere

+

+ Your local draft is based on an older version — or its base version is unknown — + so autosave is paused and nothing is overwritten until you choose. Your draft + stays in the editor below. +

+
+ + + +
+
+ )} + + {mode === 'edit' ? ( +
+