initial commit on Federted servers
release gate / check (push) Failing after 8m35s

This commit is contained in:
Andrew S Erwin
2026-10-19 16:23:55 -05:00
commit b9721ff13f
217 changed files with 31534 additions and 0 deletions
+11
View File
@@ -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
+43
View File
@@ -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
+82
View File
@@ -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
+207
View File
@@ -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.
+17
View File
@@ -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"]
+66
View File
@@ -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
+196
View File
@@ -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).
+315
View File
@@ -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: `<PUBLIC_APP_URL>/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=<plain-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='<chosen-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.
+335
View File
@@ -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 <rev> && ./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.
+87
View File
@@ -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)"
+68
View File
@@ -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"
Executable
+95
View File
@@ -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"
+68
View File
@@ -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;
+78
View File
@@ -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");
@@ -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 $$;
+7
View File
@@ -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;
+8
View File
@@ -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;
@@ -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);
+10
View File
@@ -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);
+351
View File
@@ -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;
+50
View File
@@ -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;
}
}
+3
View File
@@ -0,0 +1,3 @@
# Compatibility alias: there is now only one complete stack definition.
include:
- docker-compose.yml
+139
View File
@@ -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
+22
View File
@@ -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
)
+43
View File
@@ -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=
+20
View File
@@ -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"`
}
+19
View File
@@ -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
}
+263
View File
@@ -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})
}
+137
View File
@@ -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)
})
}
+302
View File
@@ -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)
}
}
}
+245
View File
@@ -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()
}
+180
View File
@@ -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")
+113
View File
@@ -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")
}
}
+38
View File
@@ -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 <ok@computer.test>", "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())
}
})
}
}
+4
View File
@@ -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
+88
View File
@@ -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})
}
+113
View File
@@ -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"
}
+50
View File
@@ -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")
}
}
+155
View File
@@ -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" }
+137
View File
@@ -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
}
+44
View File
@@ -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))`
+153
View File
@@ -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()
}
+97
View File
@@ -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) }
+438
View File
@@ -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)
}
File diff suppressed because it is too large Load Diff
+264
View File
@@ -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
}
+74
View File
@@ -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)
}
+269
View File
@@ -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)
}
+560
View File
@@ -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 := `<?xml version="1.0"?><card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><card:address-data/></d:prop></card:addressbook-query>`
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)
}
+120
View File
@@ -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)
}
}
+203
View File
@@ -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"))
}
+15
View File
@@ -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)
}
+118
View File
@@ -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"))
}
+96
View File
@@ -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"))
}
+25
View File
@@ -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"))
}
+420
View File
@@ -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)
}
}
+510
View File
@@ -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, `<html><head><title>Preview Target</title><meta name="description" content="Described"></head><body>x</body></html>`)
}))
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)
}
}
+220
View File
@@ -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)
}
+251
View File
@@ -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 := `<d:response><d:href>/card.vcf</d:href><d:propstat><d:prop><card:address-data>BEGIN:VCARD
UID:contact-1
FN:Contact Name
EMAIL:last@computer.test
EMAIL;TYPE=PREF:preferred@computer.test
TEL:1234
END:VCARD</card:address-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`
card2 := `<d:response><d:href>/card2.vcf</d:href><d:propstat><d:prop><card:address-data>BEGIN:VCARD
UID:contact-2
FN:Second Contact
EMAIL:second@computer.test
END:VCARD</card:address-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`
card2Withheld := `<d:response><d:href>/card2.vcf</d:href><d:propstat><d:prop/><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response>`
wrap := func(responses ...string) string {
return `<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">` + strings.Join(responses, "") + `</d:multistatus>`
}
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)
}
+123
View File
@@ -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)
}
+73
View File
@@ -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")
}
}
+68
View File
@@ -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")
}
}
+63
View File
@@ -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)
}
+256
View File
@@ -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)
}
}
+181
View File
@@ -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"
}
+68
View File
@@ -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)
}
+59
View File
@@ -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 ""
}
+77
View File
@@ -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<!-- exported from computer " + text(p, "updatedAt") + " -->\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))
}
+196
View File
@@ -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
}
+212
View File
@@ -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
}
+113
View File
@@ -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()
}
+73
View File
@@ -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"
+96
View File
@@ -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
}
+267
View File
@@ -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:<id>"). 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
}
+85
View File
@@ -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(`<META content='A &amp; B' property='og:title'><meta property="og:image" content="/cover.png"><link href="../icon.png" rel="shortcut icon">`), 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(`<html><head><title>`+title+`</title><meta name="description" content="`+desc+`"></head></html>`), 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()
}
}
+36
View File
@@ -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
}
+347
View File
@@ -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: <msg-" + fmt.Sprint(i) + "@computer.test>\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)
}
}
+153
View File
@@ -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<<shift) != 0
}
func (s *Scheduler) checkUser(ctx context.Context, d *db.DB, id string) {
// Database-backed lease held on a dedicated connection for the duration
// of the pass: a second poller instance skips this user instead of
// repeating the same external mailbox work.
conn, e := d.Conn(ctx)
if e != nil {
s.note(id, false)
log.Printf("watchers: lease connection for user %s failed: %v", id, e)
return
}
defer conn.Close()
var locked bool
if e := conn.QueryRowContext(ctx, `SELECT pg_try_advisory_lock(hashtextextended($1,0))`, "signal-poll:"+id).Scan(&locked); e != nil {
s.note(id, false)
log.Printf("watchers: lease for user %s failed: %v", id, e)
return
}
if !locked {
return // another instance owns this user's pass
}
defer conn.ExecContext(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock(hashtextextended($1,0))`, "signal-poll:"+id)
job, cancel := context.WithTimeout(ctx, perUserTimeout)
defer cancel()
res, e := RunSignalChecks(job, d, id, "")
if e != nil {
s.note(id, false)
log.Printf("watchers: check failed for user %s: %v", id, e)
return
}
if res.Failed > 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)
}
}
}
+443
View File
@@ -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
}
+31
View File
@@ -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)
}
}
+78
View File
@@ -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
}
+107
View File
@@ -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 <rev> && ./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 <<EOF
warning: https://$DOMAIN/login is not reachable from this machine even though
the server-side checks passed. This usually means DNS for $DOMAIN does not
point at the server from here, or a firewall blocks 80/443. Verify with:
dig +short $DOMAIN
EOF
exit 1
fi
+229
View File
@@ -0,0 +1,229 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { chromium } from 'playwright'
// Run with the Docker stack up. Only uniquely created QA accounts are cleaned
// up; existing users, bookmarks and database tables are never reset.
const base = process.env.BASE_URL || 'http://localhost:3000'
// Review TEST-03: the cleanup path deletes accounts through the LOCAL compose
// database, so the runner must prove the browser target is that same stack
// before creating anything. Non-loopback targets are refused unless the
// operator explicitly acknowledges (E2E_ALLOW_REMOTE=1) that no cleanup is
// possible and accounts will persist (E2E_KEEP_DATA=1).
const repoRoot = new URL('..', import.meta.url)
const baseUrl = new URL(base)
const loopback = ['localhost', '127.0.0.1', '::1'].includes(baseUrl.hostname)
const keepData = process.env.E2E_KEEP_DATA === '1'
const localDbRunning = () => {
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
}
}
}
+59
View File
@@ -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"
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"private": true,
"type": "module",
"scripts": { "test": "node e2e.mjs", "test:regressions": "node regressions.mjs" },
"devDependencies": { "playwright": "1.58.2" }
}
+500
View File
@@ -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(`<form method="POST" action="${base}/api/auth/login" enctype="text/plain"><input name='{"email":"attacker@computer.test","password":"password","extra":"' value='"}'></form><script>document.forms[0].submit()</script>`)
})
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()
}
+354
View File
@@ -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 <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 24)
AUTH_SECRET=$(openssl rand -hex 32)
NEXTAUTH_URL=https://$DOMAIN
COOKIE_SECURE=1
ADMIN_EMAILS=
POSTGRES_VOLUME=computer_pgdata
POSTGRES_VOLUME_EXTERNAL=false
APP_HOST_PORT=$APP_HOST_PORT
WEB_HOST_PORT=$WEB_HOST_PORT
OIDC_ISSUER=REPLACE_ME
OIDC_CLIENT_ID=REPLACE_ME
OIDC_CLIENT_SECRET=REPLACE_ME
EOF
umask 022
else
log "Keeping existing .env"
grep -q '^AUTH_SECRET=..*' .env || die ".env exists but AUTH_SECRET is empty"
# Pin the host ports even on an .env created before port overrides existed.
grep -q '^APP_HOST_PORT=' .env || echo "APP_HOST_PORT=$APP_HOST_PORT" >> .env
grep -q '^WEB_HOST_PORT=' .env || echo "WEB_HOST_PORT=$WEB_HOST_PORT" >> .env
url="$(sed -n 's/^NEXTAUTH_URL=//p' .env | tail -n1)"
secure="$(sed -n 's/^COOKIE_SECURE=//p' .env | tail -n1)"
[ "$url" = "https://$DOMAIN" ] || log "warning: NEXTAUTH_URL is '$url', expected 'https://$DOMAIN'"
[ "$secure" = "1" ] || log "warning: COOKIE_SECURE is '$secure', expected '1' behind HTTPS"
fi
# Day-to-day `docker compose` runs as RUN_USER, which must be able to read .env.
chown "$RUN_USER:" .env
chmod 600 .env
# Effective ports are whatever .env says (they may predate this run).
APP_PORT="$(sed -n 's/^APP_HOST_PORT=//p' .env | tail -n1)"; APP_PORT="${APP_PORT:-8080}"
WEB_PORT="$(sed -n 's/^WEB_HOST_PORT=//p' .env | tail -n1)"; WEB_PORT="${WEB_PORT:-3000}"
# Fail early with a clear message if another process squats the ports while
# our stack is not the thing holding them.
port_busy() { ss -tln | awk '{print $4}' | grep -qE "[:.]$1\$"; }
if [ -z "$(docker compose ps -q app 2>/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" <<BOOT
server {
listen 80;
listen [::]:80;
server_name $DOMAIN;
location / { proxy_pass http://127.0.0.1:$WEB_PORT; proxy_set_header Host \$host; }
}
BOOT
nginx -t
systemctl reload nginx
certbot certonly --nginx -d "$DOMAIN" --non-interactive --agree-tos -m "$EMAIL" --keep-until-expiring
rm -f "/etc/nginx/conf.d/$DOMAIN-bootstrap.conf"
mv "/etc/nginx/$DOMAIN.conf.pending" "$SITE_CONF"
[ -d /etc/nginx/sites-available ] && ln -sfn "$SITE_CONF" "/etc/nginx/sites-enabled/$DOMAIN"
# Reload nginx after each successful renewal.
if [ ! -e /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh ]; then
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
printf '#!/bin/sh\nsystemctl reload nginx\n' > /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"
+6
View File
@@ -0,0 +1,6 @@
node_modules
.next
.env*
.git
out
*.log
+21
View File
@@ -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="<SAME_RANDOM_SECRET_AS_ROOT_ENV>"
# 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=
+22
View File
@@ -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"]
+106
View File
@@ -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).
+211
View File
@@ -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 `<a>` or `<button>` for keyboard access. |
| `Separator` | `@/components/ui/separator` | `orientation` (`horizontal`\|`vertical`) |
### Forms
| Component | Import |
|-----------|--------|
| `Input` | `@/components/ui/input` | `variant` (`default`\|`error`\|`success`\|`ghost`), `size` (`default`\|`sm`\|`lg`) |
| `Textarea` | `@/components/ui/textarea` | `variant` (`default`\|`error`\|`success`\|`ghost`) |
| `Label` | `@/components/ui/label` |
| `Select` | `@/components/ui/select` |
| `Checkbox` | `@/components/ui/checkbox` |
| `RadioGroup` | `@/components/ui/radio-group` |
| `Switch` | `@/components/ui/switch` |
| `Slider` | `@/components/ui/slider` |
| `Calendar` | `@/components/ui/calendar` |
| `DateRangePicker` | `@/components/ui/date-range-picker` |
| `InputOTP` | `@/components/ui/input-otp` |
| `Form` | `@/components/ui/form` (react-hook-form integration) |
### Navigation & Layout
| Component | Import |
|-----------|--------|
| `Tabs` | `@/components/ui/tabs` — `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` |
| `Accordion` | `@/components/ui/accordion` — `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent` |
| `NavigationMenu` | `@/components/ui/navigation-menu` |
| `Breadcrumb` | `@/components/ui/breadcrumb` |
| `Pagination` | `@/components/ui/pagination` |
| `Menubar` | `@/components/ui/menubar` |
| `ScrollArea` | `@/components/ui/scroll-area` |
| `Resizable` | `@/components/ui/resizable` |
### Overlays & Feedback
| Component | Import |
|-----------|--------|
| `Dialog` | `@/components/ui/dialog` |
| `AlertDialog` | `@/components/ui/alert-dialog` |
| `Sheet` | `@/components/ui/sheet` — side-panel overlay |
| `Drawer` | `@/components/ui/drawer` — bottom sheet (great for mobile) |
| `Popover` | `@/components/ui/popover` |
| `Tooltip` | `@/components/ui/tooltip` |
| `HoverCard` | `@/components/ui/hover-card` |
| `ContextMenu` | `@/components/ui/context-menu` |
| `DropdownMenu` | `@/components/ui/dropdown-menu` |
| `Command` | `@/components/ui/command` — command palette / searchable list |
| `Alert` | `@/components/ui/alert` |
| `toast` | `import { toast } from 'sonner'` — `toast.success()`, `toast.error()` |
### Data Display
| Component | Import |
|-----------|--------|
| `Table` | `@/components/ui/table` — `Table`, `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell` |
| `Avatar` | `@/components/ui/avatar` |
| `Progress` | `@/components/ui/progress` |
| `Skeleton` | `@/components/ui/skeleton` |
| `AspectRatio` | `@/components/ui/aspect-ratio` |
| `Carousel` | `@/components/ui/carousel` |
### Misc
| Component | Import |
|-----------|--------|
| `Toggle` | `@/components/ui/toggle` |
| `ToggleGroup` | `@/components/ui/toggle-group` |
| `Collapsible` | `@/components/ui/collapsible` |
| `ThemeToggle` | `@/components/theme-toggle` — light/dark mode switch |
## SSR / Hydration Safety — `@/components/client-only`, `@/components/safe-format`
Server-rendered HTML must match the client's first render. An automated SSR lint
(`eslint.ssr.config.mjs` — do not delete) runs after every build and fails it on unsafe
patterns. Use these primitives instead of hand-rolling fixes:
| Primitive | Import | Use for |
|-----------|--------|---------|
| `ClientOnly` | `@/components/client-only` | Anything browser-only or non-deterministic: `window`/`localStorage` reads, live clocks, random values, third-party widgets. Pass a `fallback` sized like the content. |
| `useMounted()` | `@/components/client-only` | Hook variant when you need the boolean directly. |
| `SafeDate` / `SafeTime` | `@/components/safe-format` | Dates/times — formats with explicit locale + UTC so SSR matches client. `localize` re-renders in the visitor's timezone after mount. Props: `date` (Date\|string\|number), `options?` (Intl.DateTimeFormatOptions), `locale?`, `localize?`, `className?` — there is no `format`/`value` prop; e.g. `<SafeDate date={post.createdAt} options={{ dateStyle: 'medium' }} />`. |
| `SafeNumber` | `@/components/safe-format` | Numbers/currency, same guarantees. Props: `value` (number), `options?` (Intl.NumberFormatOptions), `currency?` (`currency="USD"`), `locale?`, `localize?`, `className?`. |
Rules of thumb: never touch `window`/`document` at module scope; never seed `useState` with
`Date.now()`/`Math.random()`/`new Date()`; never call `toLocaleString`-style methods without
an explicit locale (and `timeZone` for dates).
+52
View File
@@ -0,0 +1,52 @@
'use client'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Eye, Shield } from 'lucide-react'
import { Button } from '@/components/ui/button'
type U = { id: string; name: string | null; email: string; role: string; _count?: { bookmarks: number; pages: number; tags: number } }
export function AdminClient({ currentUserId }: { currentUserId: string }) {
const [users, setUsers] = useState<U[]>([])
const load = () =>
fetch('/api/admin/users')
.then((r) => (r.ok ? r.json() : []))
.then((d) => setUsers(Array.isArray(d) ? d : d?.users ?? []))
.catch((e) => console.error('Failed to load users', e))
useEffect(() => { load() }, [])
const viewAs = async (userId: string) => {
const res = await fetch('/api/admin/view-as', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) })
if (!res.ok) return toast.error('Could not switch account')
window.location.href = '/pages'
}
const toggleRole = async (u: U) => {
const res = await fetch('/api/admin/users', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: u.id, role: u.role === 'ADMIN' ? 'USER' : 'ADMIN' }) })
if (!res.ok) return toast.error((await res.json().catch(() => null))?.error ?? 'Could not change role')
load()
}
return (
<div className="mx-auto max-w-4xl">
<h1 className="font-display text-2xl font-bold">Admin</h1>
<p className="mb-6 text-sm text-muted-foreground">All accounts in the group. “View as” opens an account with a visible banner; exit any time.</p>
<div className="divide-y divide-border/60 rounded-xl bg-card shadow-sm ring-1 ring-border/50">
{users.map((u) => (
<div key={u.id} className="flex flex-wrap items-center gap-3 p-4">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{u.name || u.email} {u.role === 'ADMIN' && <Shield className="ml-1 inline h-3.5 w-3.5 text-primary" />}</p>
<p className="truncate text-xs text-muted-foreground">{u.email} · {u._count?.bookmarks ?? 0} bookmarks · {u._count?.pages ?? 0} pages</p>
</div>
<Button size="sm" variant="outline" disabled={u.id === currentUserId} onClick={() => toggleRole(u)}>{u.role === 'ADMIN' ? 'Make user' : 'Make admin'}</Button>
<Button size="sm" disabled={u.id === currentUserId} onClick={() => viewAs(u.id)}><Eye className="mr-1 h-4 w-4" />View as</Button>
</div>
))}
{users.length === 0 && <p className="p-6 text-center text-sm text-muted-foreground">No users found.</p>}
</div>
</div>
)
}
+11
View File
@@ -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 <AdminClient currentUserId={user.id} />
}
+192
View File
@@ -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<Bookmark[]>([])
const [sharedBookmarks, setSharedBookmarks] = useState<Bookmark[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<Bookmark | null>(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<Bookmark | null>(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<AbortController | null>(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=<id>.
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' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />
}
return (
<div className="space-y-6">
<div>
<h1 className="font-display text-2xl font-bold tracking-tight">All Bookmarks</h1>
<p className="text-sm text-muted-foreground mt-1">Every saved link. Use the sidebar to filter by calendar, tags, board, or contacts.</p>
</div>
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Filter..." value={search} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)} className="pl-9" />
</div>
<div className="flex rounded-lg border overflow-hidden">
<button onClick={() => setTab('mine')} className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${tab === 'mine' ? 'bg-primary text-primary-foreground' : 'bg-card hover:bg-muted'}`}>
<Inbox className="h-3.5 w-3.5" /> Mine
</button>
<button onClick={() => setTab('shared')} className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${tab === 'shared' ? 'bg-primary text-primary-foreground' : 'bg-card hover:bg-muted'}`}>
<Share2 className="h-3.5 w-3.5" /> Shared
</button>
</div>
</div>
<div className="rounded-lg border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 text-left">
<th className="px-4 py-3 font-medium cursor-pointer" onClick={() => toggleSort('title')}>
<span className="flex items-center gap-1">Title {sortIcon('title')}</span>
</th>
<th className="px-4 py-3 font-medium">URL</th>
<th className="px-4 py-3 font-medium">Tags</th>
<th className="px-4 py-3 font-medium cursor-pointer" onClick={() => toggleSort('kanbanStatus')}>
<span className="flex items-center gap-1">Status {sortIcon('kanbanStatus')}</span>
</th>
<th className="px-4 py-3 font-medium cursor-pointer" onClick={() => toggleSort('updatedAt')}>
<span className="flex items-center gap-1">Updated {sortIcon('updatedAt')}</span>
</th>
<th className="px-4 py-3 font-medium w-20">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">Loading...</td></tr>
) : sorted.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">No bookmarks found</td></tr>
) : sorted.map((b: Bookmark) => (
<tr key={b.id} tabIndex={0} className="hover:bg-muted/30 cursor-pointer focus-visible:outline-2 focus-visible:outline-primary" onClick={() => setSelected(b)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelected(b) } }}>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{b.favicon && <img src={b.favicon} alt="" referrerPolicy="no-referrer" className="h-4 w-4 rounded" onError={(e: any) => { e.target.style.display = 'none' }} />}
<span className="truncate max-w-[200px]">{b.title ?? 'Untitled'}</span>
</div>
</td>
<td className="px-4 py-3">
<a href={b.url} target="_blank" rel="noopener noreferrer" onClick={(e: React.MouseEvent) => e.stopPropagation()} className="text-primary hover:underline truncate max-w-[200px] block">
{(() => { try { return new URL(b.url).hostname } catch { return b.url } })()}
</a>
</td>
<td className="px-4 py-3">
<div className="flex gap-1">
{(b.tags ?? []).slice(0, 2).map((bt: any) => (
<Badge key={bt?.tag?.id} variant="secondary" className="text-[10px] px-1.5 py-0" style={{ backgroundColor: `${bt?.tag?.color}20`, color: bt?.tag?.color }}>{bt?.tag?.name}</Badge>
))}
</div>
</td>
<td className="px-4 py-3"><Badge variant="outline" className="text-[10px]">{b.kanbanStatus?.replace('_', ' ')}</Badge></td>
<td className="px-4 py-3 text-xs text-muted-foreground">{new Date(b.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}</td>
<td className="px-4 py-3">
<div className="flex gap-1" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<a href={b.url} target="_blank" rel="noopener noreferrer"><ExternalLink className="h-4 w-4 text-muted-foreground hover:text-primary" /></a>
{tab === 'mine' && (
<button onClick={async () => { if (!confirm('Delete?')) return; try { const res = await fetch(`/api/bookmarks/${b.id}`, { method: 'DELETE' }); if (res.ok) { toast.success('Deleted'); load() } else toast.error('Could not delete bookmark') } catch { toast.error('Could not delete bookmark') } }}><Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" /></button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<BookmarkDetailSheet bookmark={selected} onClose={() => setSelected(null)} onUpdate={() => { load(); void refreshSelected() }} />
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
import { BookmarksClient } from './client'
// Quick Find deep-links (?open=<id>, ?search=<q>) 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 <BookmarksClient initialSearch={search ?? null} initialOpen={open ?? null} />
}
+171
View File
@@ -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<Date>(INITIAL_DATE)
const [today, setToday] = useState<Date>(INITIAL_DATE)
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [selectedDay, setSelectedDay] = useState<Date | null>(null)
const [selectedBookmark, setSelectedBookmark] = useState<Bookmark | null>(null)
const [loading, setLoading] = useState(true)
const [sharedDates, setSharedDates] = useState<any[]>([])
const [shareQuery, setShareQuery] = useState('')
const [shareUsers, setShareUsers] = useState<any[]>([])
// 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<string, Bookmark[]> = {}
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-bold tracking-tight">Calendar</h1>
<p className="text-sm text-muted-foreground mt-1">Bookmarks organized by due date</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon-sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="font-medium text-sm min-w-[140px] text-center">{format(currentMonth, 'MMMM yyyy')}</span>
<Button variant="outline" size="icon-sm" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-7 gap-px bg-border rounded-lg overflow-hidden">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(d => (
<div key={d} className="bg-muted/50 px-2 py-2 text-center text-xs font-medium text-muted-foreground">{d}</div>
))}
{Array.from({ length: startDayOfWeek }).map((_, i) => (
<div key={`empty-${i}`} className="bg-card min-h-[80px]" />
))}
{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 (
<div
key={key}
role="button"
tabIndex={0}
aria-pressed={isSelected}
aria-label={format(day, 'EEEE, MMMM d, yyyy')}
onClick={() => 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' : ''
}`}
>
<span className={`text-xs font-medium inline-flex h-6 w-6 items-center justify-center rounded-full ${
isToday ? 'bg-primary text-primary-foreground' : 'text-foreground'
}`}>
{format(day, 'd')}
</span>
<div className="space-y-0.5 mt-0.5">
{sharedDates.filter(s => s.date.slice(0, 10) === key).map(s => <p key={s.id} className="truncate text-[10px] text-primary" title={s.note || ''}>Shared: {s.creator.name || s.creator.email}</p>)}
{dayBookmarks.slice(0, 2).map((b: Bookmark) => (
<div key={b.id} role="button" tabIndex={0} className="text-[10px] truncate rounded bg-primary/10 text-primary px-1 py-0.5 cursor-pointer focus-visible:outline-1 focus-visible:outline-primary" onClick={(e: React.MouseEvent) => { e.stopPropagation(); setSelectedBookmark(b) }} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); setSelectedBookmark(b) } }}>
{b.title ?? 'Untitled'}
</div>
))}
{dayBookmarks.length > 2 && (
<div className="text-[10px] text-muted-foreground px-1">+{dayBookmarks.length - 2} more</div>
)}
</div>
</div>
)
})}
</div>
{selectedDay && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-medium">{format(selectedDay, 'EEEE, MMMM d, yyyy')}</h2>
<div className="flex gap-2"><Input placeholder="Share date with user…" value={shareQuery} onChange={e => setShareQuery(e.target.value)} /></div>
</div>
{shareUsers.map(u => <Button key={u.id} size="sm" variant="outline" onClick={async () => { const res = await fetch('/api/shared-dates', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: format(selectedDay, 'yyyy-MM-dd'), recipientIds: [u.id] }) }); if (!res.ok) { toast.error('Could not share date'); return } toast.success('Date shared'); setShareQuery(''); setShareUsers([]); load() }}>Share with {u.name || u.email}</Button>)}
{sharedDates.filter(s => s.date.slice(0, 10) === format(selectedDay, 'yyyy-MM-dd')).map(s => <div key={s.id} className="flex items-center gap-2 text-xs text-muted-foreground"><span>{s.creator.name || s.creator.email} shared with {s.recipient.name || s.recipient.email}</span><Button size="xs" variant="ghost" onClick={async () => { const res = await fetch(`/api/shared-dates/${s.id}`, { method: 'DELETE' }); if (!res.ok) { toast.error('Only the owner can stop sharing'); return } load() }}>Stop sharing</Button></div>)}
{selectedBookmarks.length === 0 ? (
<p className="text-sm text-muted-foreground">No bookmarks on this date</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{selectedBookmarks.map((b: Bookmark) => (
<BookmarkCard key={b.id} bookmark={b} compact onClick={() => setSelectedBookmark(b)} />
))}
</div>
)}
</div>
)}
<BookmarkDetailSheet bookmark={selectedBookmark} onClose={() => setSelectedBookmark(null)} onUpdate={load} />
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { CalendarClient } from './client'
export default function CalendarPage() {
return <CalendarClient />
}
+883
View File
@@ -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<string, unknown>; version: number
}
type CollectionItem = {
id: string; collectionId: string; bookmarkId: string
propertyValues: Record<string, unknown>; 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<string, string> = {
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<string, string[]> = {
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<any> { 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 (
<Link href={`/bookmarks?open=${bookmark.id}`} className="flex min-w-0 items-center gap-2 hover:underline">
{bookmark.favicon
// eslint-disable-next-line @next/next/no-img-element -- remote favicons stay no-referrer (web/AGENTS.md)
? <img src={bookmark.favicon} alt="" referrerPolicy="no-referrer" className="h-4 w-4 shrink-0 rounded-sm" />
: <Layers className="h-4 w-4 shrink-0 text-muted-foreground" />}
<span className="min-w-0">
<span className="block max-w-[16rem] truncate text-sm">{bookmark.title || bookmark.url}</span>
<span className="block max-w-[16rem] truncate text-xs text-muted-foreground">{host}</span>
</span>
</Link>
)
}
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<void> }) {
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 <Checkbox aria-label={prop.name} checked={value === true} onCheckedChange={(c) => { void onSave(c === true) }} />
case 'SELECT': {
const options = prop.config.options ?? []
return (
<Select
value={typeof value === 'string' && value ? value : NONE}
onValueChange={(id) => { void onSave(id === NONE ? null : id) }}
>
<SelectTrigger className="h-8 w-[9.5rem]" aria-label={prop.name}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>—</SelectItem>
{options.map((o) => (
<SelectItem key={o.id} value={o.id}>
<span className="flex items-center gap-1.5">
{o.color && <span className="h-2 w-2 rounded-full" style={{ backgroundColor: o.color }} />}
{o.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)
}
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button type="button" aria-label={`Edit ${prop.name}`} className="flex min-h-8 w-[9.5rem] flex-wrap items-center gap-1 rounded-md border border-input px-2 py-1 text-left hover:bg-muted">
{selected.size === 0 && <span className="text-xs text-muted-foreground">—</span>}
{options.filter((o) => selected.has(o.id)).map((o) => (
<Badge key={o.id} variant="secondary" className="gap-1 text-xs" style={o.color ? { borderColor: o.color } : undefined}>{o.label}</Badge>
))}
</button>
</PopoverTrigger>
<PopoverContent className="w-52 space-y-2" align="start">
{options.map((o) => (
<label key={o.id} className="flex items-center gap-2 text-sm">
<Checkbox checked={selected.has(o.id)} onCheckedChange={() => { void toggle(o.id) }} />
{o.color && <span className="h-2 w-2 rounded-full" style={{ backgroundColor: o.color }} />}
{o.label}
</label>
))}
{options.length === 0 && <p className="text-xs text-muted-foreground">No options yet</p>}
</PopoverContent>
</Popover>
)
}
case 'DATE':
return prop.config.mode === 'datetime'
? <Input type="datetime-local" aria-label={prop.name} className="h-8 w-[13rem]" value={toLocalInput(value)} onChange={(e) => { const v = e.target.value; void onSave(v ? new Date(v).toISOString() : null) }} />
: <Input type="date" aria-label={prop.name} className="h-8 w-[9.5rem]" value={typeof value === 'string' ? value : ''} onChange={(e) => { void onSave(e.target.value || null) }} />
case 'NUMBER':
case 'TEXT':
case 'URL':
default:
return (
<Input
aria-label={prop.name}
className="h-8 w-[9.5rem]"
type={prop.type === 'NUMBER' ? 'number' : 'text'}
value={text}
onChange={(e) => 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<CollectionSummary[]>([])
const [activeId, setActiveId] = useState<string | null>(null)
const [detail, setDetail] = useState<CollectionDetail | null>(null)
const [items, setItems] = useState<CollectionItem[]>([])
const [cursor, setCursor] = useState<string | null>(null)
const [draggedItemId, setDraggedItemId] = useState<string | null>(null)
const [boardDropKey, setBoardDropKey] = useState<string | null>(null)
const [viewId, setViewId] = useState<string | null>(null)
const [newName, setNewName] = useState('')
const [nameDraft, setNameDraft] = useState('')
const [addOpen, setAddOpen] = useState(false)
const [addQuery, setAddQuery] = useState('')
const [addResults, setAddResults] = useState<Bookmark[]>([])
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<boolean> => {
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<string, unknown>) =>
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<string, unknown> = {}
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<string, unknown>) => {
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 = () => (
<div className="overflow-x-auto rounded-lg border border-border/60">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[16rem]">Bookmark</TableHead>
{(detail?.properties ?? []).map((p) => (
<TableHead key={p.id} className="min-w-[11rem]">
<span className="flex items-center gap-1.5">
{p.name}
<button type="button" aria-label={`Delete property ${p.name}`} className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100" onClick={() => deleteProperty(p)}>
<Trash2 className="h-3 w-3" />
</button>
</span>
</TableHead>
))}
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id} className="group">
<TableCell><BookmarkCell bookmark={item.bookmark} /></TableCell>
{(detail?.properties ?? []).map((p) => (
<TableCell key={p.id}><ValueEditor prop={p} value={item.propertyValues?.[p.id]} onSave={(v) => saveValue(item, p.id, v)} /></TableCell>
))}
<TableCell>
<button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
<Trash2 className="h-4 w-4" />
</button>
</TableCell>
</TableRow>
))}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={(detail?.properties.length ?? 0) + 2} className="py-10 text-center text-sm text-muted-foreground">
No items{filterClauses.length ? " match this view's filters" : ''}. Add existing bookmarks — nothing is duplicated.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)
const renderBoard = () => {
if (!groupProp || groupProp.type !== 'SELECT') {
return (
<div className="rounded-lg border border-dashed border-border p-8 text-center">
<Kanban className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Board views group by a select property.</p>
<div className="mx-auto mt-3 w-56">
<Select
value={NONE}
onValueChange={(id) => { if (id !== NONE) patchView({ groupByPropertyId: id }) }}
>
<SelectTrigger aria-label="Group by property"><SelectValue placeholder="Choose a select property…" /></SelectTrigger>
<SelectContent>
{(detail?.properties ?? []).filter((p) => p.type === 'SELECT').map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}
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 (
<div className="flex gap-3 overflow-x-auto pb-2">
{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 (
<div
key={col.key}
data-board-group={col.key}
onDragOver={(e) => {
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',
)}
>
<div className="mb-2 flex items-center gap-1.5 px-1 text-sm font-medium">
{col.color && <span className="h-2 w-2 rounded-full" style={{ backgroundColor: col.color }} />}
{col.label}
<span className="ml-auto text-xs text-muted-foreground">{colItems.length}</span>
</div>
<div className="space-y-2">
{colItems.map((item) => (
<div
key={item.id}
data-collection-item-id={item.id}
className={cn(
'rounded-md border border-border bg-card p-2 shadow-sm transition-[transform,box-shadow,opacity] duration-150 group',
draggedItemId === item.id && 'opacity-40',
)}
>
<div className="flex items-start gap-1">
<span
data-drag-handle
draggable
onDragStart={(e) => {
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'}`}
>
<GripVertical className="h-3.5 w-3.5" />
</span>
<div className="min-w-0 flex-1">
<BookmarkCell bookmark={item.bookmark} />
</div>
</div>
<div className="mt-1.5 flex items-center justify-between gap-1">
<Select
value={typeof item.propertyValues?.[groupProp.id] === 'string' ? (item.propertyValues[groupProp.id] as string) : NONE}
onValueChange={(id) => { void moveItemToGroup(item, id === NONE ? null : id) }}
>
<SelectTrigger className="h-7 w-28 text-xs" aria-label={`Move ${item.bookmark.title ?? 'card'}`}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>—</SelectItem>
{options.map((o) => <SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
{colItems.length === 0 && (
<p className={cn('px-1 py-3 text-center text-xs text-muted-foreground', isDropTarget && 'text-primary')}>
{isDropTarget ? 'Drop here' : 'Empty'}
</p>
)}
</div>
</div>
)
})}
</div>
)
}
return (
<div className="flex h-full flex-col">
<div className="flex flex-1 overflow-hidden">
{/* Collection list */}
<div className="flex w-60 shrink-0 flex-col border-r border-border/60 p-3">
<h1 className="mb-3 flex items-center gap-2 font-display text-lg font-semibold tracking-tight">
<Layers className="h-4 w-4" /> Collections
</h1>
<div className="mb-3 flex gap-1.5">
<Input value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') createCollection() }} placeholder="New collection" className="h-8 text-sm" aria-label="New collection name" />
<Button size="sm" variant="outline" className="h-8 shrink-0 px-2" onClick={createCollection} aria-label="Create collection"><Plus className="h-4 w-4" /></Button>
</div>
<div className="flex-1 space-y-1 overflow-y-auto">
{collections.map((c) => (
<button
key={c.id}
onClick={() => { setActiveId(c.id); setViewId(null) }}
className={cn('flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors', c.id === activeId ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground')}
>
<Layers className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{c.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">{c._count.items}</span>
</button>
))}
{collections.length === 0 && (
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
Collections hold workflow state per project. The same bookmark can live in many — nothing is duplicated.
</p>
)}
</div>
</div>
{/* Detail */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{!detail ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<Layers className="h-10 w-10 text-muted-foreground" />
<h2 className="font-display text-xl font-semibold">{collections.length ? 'Select a collection' : 'No collections yet'}</h2>
<p className="max-w-sm text-sm text-muted-foreground">
A collection is a saved workflow over existing bookmarks: per-collection status, priority, dates — independent everywhere the link appears.
</p>
</div>
) : (
<>
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input value={nameDraft} onChange={(e) => 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" />
<span className="text-xs text-muted-foreground">{detail._count?.items ?? 0} item{(detail._count?.items ?? 0) === 1 ? '' : 's'}</span>
<div className="ml-auto flex items-center gap-1.5">
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}><Plus className="mr-1 h-4 w-4" />Add bookmarks</Button>
<Button size="sm" variant="outline" onClick={() => setPropOpen(true)}><Plus className="mr-1 h-4 w-4" />Property</Button>
<Button size="sm" variant="ghost" onClick={deleteCollection} aria-label="Delete collection" title="Delete collection"><Trash2 className="h-4 w-4 text-destructive" /></Button>
</div>
</div>
{/* View tabs */}
<div className="mb-3 flex flex-wrap items-center gap-1.5">
{detail.views.map((v) => {
const Icon = VIEW_ICONS[v.type] ?? Table2
return (
<button
key={v.id}
onClick={() => setViewId(v.id)}
className={cn('flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm transition-colors', v.id === viewId ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground')}
>
<Icon className="h-3.5 w-3.5" /> {v.name}
</button>
)
})}
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => setViewOpen(true)} aria-label="Add view"><Plus className="h-4 w-4" /></Button>
{activeView && (
<div className="ml-auto flex items-center gap-1.5">
<Popover open={filterOpen} onOpenChange={(o) => { setFilterOpen(o); if (o) { setFProp(detail.properties[0]?.id ?? ''); setFOp(FILTER_OPS[detail.properties[0]?.type ?? 'TEXT']?.[0] ?? 'eq') } }}>
<PopoverTrigger asChild>
<Button size="sm" variant="ghost" className="h-7 gap-1 px-2 text-muted-foreground"><Filter className="h-3.5 w-3.5" />Filter{filterClauses.length > 0 && ` (${filterClauses.length})`}</Button>
</PopoverTrigger>
<PopoverContent className="w-80 space-y-2" align="end">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Add a filter clause</Label>
<Select value={fProp || undefined} onValueChange={(id) => { setFProp(id); const p = detail.properties.find((x) => x.id === id); setFOp(FILTER_OPS[p?.type ?? 'TEXT']?.[0] ?? 'eq'); setFValue('') }}>
<SelectTrigger aria-label="Filter property"><SelectValue placeholder="Property" /></SelectTrigger>
<SelectContent>
{detail.properties.filter((p) => FILTER_OPS[p.type]).map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
<Select value={fOp} onValueChange={setFOp}>
<SelectTrigger aria-label="Filter operator"><SelectValue /></SelectTrigger>
<SelectContent>
{(FILTER_OPS[detail.properties.find((p) => p.id === fProp)?.type ?? 'TEXT'] ?? []).map((op) => <SelectItem key={op} value={op}>{OP_LABELS[op] ?? op}</SelectItem>)}
</SelectContent>
</Select>
{!['empty', 'notEmpty'].includes(fOp) && (() => {
const p = detail.properties.find((x) => x.id === fProp)
if (p?.type === 'SELECT') return (
<Select value={fValue || undefined} onValueChange={setFValue}>
<SelectTrigger aria-label="Filter value"><SelectValue placeholder="Value" /></SelectTrigger>
<SelectContent>{(p.config.options ?? []).map((o) => <SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>)}</SelectContent>
</Select>
)
if (p?.type === 'CHECKBOX') return (
<Select value={fValue || undefined} onValueChange={setFValue}>
<SelectTrigger aria-label="Filter value"><SelectValue placeholder="Value" /></SelectTrigger>
<SelectContent><SelectItem value="true">checked</SelectItem><SelectItem value="false">unchecked</SelectItem></SelectContent>
</Select>
)
return <Input value={fValue} onChange={(e) => setFValue(e.target.value)} placeholder="Value" aria-label="Filter value" className="h-8 text-sm" />
})()}
<Button size="sm" className="w-full" onClick={addClause}>Apply filter</Button>
</PopoverContent>
</Popover>
<Button size="sm" variant="ghost" className="h-7 px-2 text-muted-foreground" onClick={deleteView} aria-label="Delete view" title="Delete view"><Trash2 className="h-3.5 w-3.5" /></Button>
</div>
)}
</div>
{/* Active filter chips */}
{filterClauses.length > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-1.5">
<span className="text-xs text-muted-foreground">{activeView?.filters?.operator ?? 'AND'}:</span>
{filterClauses.map((c, i) => (
<Badge key={i} variant="secondary" className="gap-1">
{clauseLabel(c)}
<button type="button" aria-label="Remove filter" onClick={() => setClause(filterClauses.filter((_, j) => j !== i))}><X className="h-3 w-3" /></button>
</Badge>
))}
</div>
)}
{activeView?.type === 'BOARD' ? renderBoard() : renderTable()}
{activeView && (activeView.type === 'CALENDAR' || activeView.type === 'LIST') && (
<p className="mt-2 text-xs text-muted-foreground">Calendar and list layouts render as a table in this phase.</p>
)}
{cursor && (
<Button size="sm" variant="outline" className="mt-3" onClick={() => loadItems(detail.id, viewId, cursor)}>Load more</Button>
)}
</>
)}
</div>
</div>
{/* Add bookmarks dialog */}
<Dialog open={addOpen} onOpenChange={(o) => { setAddOpen(o); if (!o) setAddQuery('') }}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>Add existing bookmarks</DialogTitle></DialogHeader>
<p className="text-xs text-muted-foreground">Membership is contextual — adding here never duplicates or re-owns the bookmark.</p>
<Input autoFocus value={addQuery} onChange={(e) => setAddQuery(e.target.value)} placeholder="Search bookmarks…" aria-label="Search bookmarks" />
<div className="max-h-72 space-y-1 overflow-y-auto">
{addResults.map((b) => {
const member = items.some((i) => i.bookmarkId === b.id)
return (
<div key={b.id} className="flex items-center gap-2 rounded-md border border-border/60 px-2 py-1.5">
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{b.title || b.url}</p>
<p className="truncate text-xs text-muted-foreground">{b.url}</p>
</div>
<Button size="sm" variant={member ? 'ghost' : 'outline'} disabled={member} onClick={() => addItem(b.id)}>
{member ? 'Added' : 'Add'}
</Button>
</div>
)
})}
{addResults.length === 0 && <p className="py-6 text-center text-sm text-muted-foreground">No bookmarks found</p>}
</div>
</DialogContent>
</Dialog>
{/* New property dialog */}
<Dialog open={propOpen} onOpenChange={setPropOpen}>
<DialogContent className="max-w-md">
<DialogHeader><DialogTitle>New property</DialogTitle></DialogHeader>
<div className="space-y-3">
<Input value={propName} onChange={(e) => setPropName(e.target.value)} placeholder="Name (e.g. Status)" aria-label="Property name" />
<Select value={propType} onValueChange={(t) => setPropType(t as (typeof PROPERTY_TYPES)[number])}>
<SelectTrigger aria-label="Property type"><SelectValue /></SelectTrigger>
<SelectContent>
{PROPERTY_TYPES.map((t) => <SelectItem key={t} value={t}>{t.replace('_', ' ').toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase())}</SelectItem>)}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Type is permanent — conversions that could destroy values are refused by design.</p>
{(propType === 'SELECT' || propType === 'MULTI_SELECT') && (
<div className="space-y-2">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Options</Label>
{propOptions.map((o, i) => (
<div key={i} className="flex items-center gap-2">
<Input type="color" aria-label="Option color" value={o.color || '#6366f1'} onChange={(e) => setPropOptions(propOptions.map((x, j) => (j === i ? { ...x, color: e.target.value } : x)))} className="h-8 w-10 shrink-0 p-1" />
<Input value={o.label} onChange={(e) => 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" />
<Button size="sm" variant="ghost" className="h-8 px-2" aria-label="Remove option" onClick={() => setPropOptions(propOptions.filter((_, j) => j !== i))}><X className="h-4 w-4" /></Button>
</div>
))}
<Button size="sm" variant="outline" onClick={() => setPropOptions([...propOptions, { label: '', color: '#6366f1' }])}><Plus className="mr-1 h-4 w-4" />Option</Button>
</div>
)}
{propType === 'DATE' && (
<Select value={propMode} onValueChange={(m) => setPropMode(m as 'date' | 'datetime')}>
<SelectTrigger aria-label="Date mode"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="date">Date only (never shifts across timezones)</SelectItem>
<SelectItem value="datetime">Date &amp; time (stored UTC)</SelectItem>
</SelectContent>
</Select>
)}
<Button className="w-full" onClick={createProperty}>Create property</Button>
</div>
</DialogContent>
</Dialog>
{/* New view dialog */}
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
<DialogContent className="max-w-sm">
<DialogHeader><DialogTitle>New view</DialogTitle></DialogHeader>
<div className="space-y-3">
<Input autoFocus value={viewName} onChange={(e) => setViewName(e.target.value)} placeholder="View name" aria-label="View name" />
<Select value={viewType} onValueChange={(t) => setViewType(t as typeof viewType)}>
<SelectTrigger aria-label="View type"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="TABLE">Table</SelectItem>
<SelectItem value="BOARD">Board</SelectItem>
<SelectItem value="CALENDAR">Calendar (renders as table for now)</SelectItem>
<SelectItem value="LIST">List (renders as table for now)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">A view is a saved query — filters and grouping never copy records.</p>
<Button className="w-full" onClick={createView}>Create view</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { CollectionsClient } from './client'
export default function CollectionsPage() {
return <CollectionsClient />
}

Some files were not shown because too many files have changed in this diff Show More