This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
@@ -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.
|
||||
Executable
+87
@@ -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)"
|
||||
Executable
+68
@@ -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
@@ -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"
|
||||
@@ -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;
|
||||
@@ -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 $$;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Compatibility alias: there is now only one complete stack definition.
|
||||
include:
|
||||
- docker-compose.yml
|
||||
@@ -0,0 +1,139 @@
|
||||
name: computer
|
||||
services:
|
||||
db:
|
||||
image: postgres:16.15-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: computer
|
||||
POSTGRES_USER: computer
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
|
||||
healthcheck:
|
||||
test: [CMD-SHELL, pg_isready -h 127.0.0.1 -U computer -d computer]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
networks: [backend]
|
||||
|
||||
migrate:
|
||||
image: postgres:16.15-alpine
|
||||
restart: "no"
|
||||
depends_on:
|
||||
db: {condition: service_healthy}
|
||||
environment:
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||
DB_HOST: db
|
||||
DB_USER: computer
|
||||
DB_NAME: computer
|
||||
volumes:
|
||||
- ./db:/db:ro
|
||||
command: [sh, /db/migrate.sh]
|
||||
networks: [backend]
|
||||
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
migrate: {condition: service_completed_successfully}
|
||||
environment:
|
||||
DATABASE_URL: postgres://computer:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@db:5432/computer?sslmode=disable
|
||||
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
|
||||
NEXTAUTH_SECRET: ${AUTH_SECRET}
|
||||
# Optional dedicated key for stored integration credentials (review
|
||||
# OPS-01): rotating AUTH_SECRET no longer invalidates IMAP passwords
|
||||
# when ENCRYPTION_KEY is set. Back it up with the database.
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-}
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||
ports: ["127.0.0.1:${APP_HOST_PORT:-8080}:8080"]
|
||||
healthcheck:
|
||||
test: [CMD, /computer, --healthcheck]
|
||||
interval: 3s
|
||||
timeout: 4s
|
||||
retries: 20
|
||||
read_only: true
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
networks: [backend]
|
||||
|
||||
web:
|
||||
build: ./web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
app: {condition: service_healthy}
|
||||
environment:
|
||||
API_BASE_URL: http://app:8080
|
||||
AUTH_SECRET: ${AUTH_SECRET}
|
||||
NEXTAUTH_SECRET: ${AUTH_SECRET}
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
||||
AUTH_TRUST_HOST: "true"
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||
ports: ["127.0.0.1:${WEB_HOST_PORT:-3000}:3000"]
|
||||
read_only: true
|
||||
tmpfs: [/tmp]
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
healthcheck:
|
||||
# busybox wget from the alpine base; /login renders without a session.
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/login"]
|
||||
interval: 10s
|
||||
timeout: 4s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks: [backend]
|
||||
|
||||
tests:
|
||||
profiles: [test]
|
||||
# Debian-based (not alpine): -race requires cgo, which needs gcc.
|
||||
# Keep the Go version in lockstep with the app Dockerfile.
|
||||
image: golang:1.26.1
|
||||
depends_on:
|
||||
db: {condition: service_healthy}
|
||||
working_dir: /src
|
||||
# Mount only what `go test ./internal/...` needs — notably NOT the repo
|
||||
# root, so the real .env (AUTH_SECRET, POSTGRES_PASSWORD) never reaches
|
||||
# the test process; tests set their own secrets via t.Setenv.
|
||||
volumes:
|
||||
- ./go.mod:/src/go.mod:ro
|
||||
- ./go.sum:/src/go.sum:ro
|
||||
- ./internal:/src/internal:ro
|
||||
- ./db:/src/db:ro
|
||||
tmpfs: ["/tmp:exec"]
|
||||
environment:
|
||||
TEST_DATABASE_URL: postgres://computer:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@db:5432/computer?sslmode=disable
|
||||
GOCACHE: /tmp/go-cache
|
||||
GOMODCACHE: /tmp/go-mod
|
||||
command: [go, test, -race, -count=1, -v, ./internal/...]
|
||||
networks: [backend]
|
||||
|
||||
# Fails when db/schema.sql drifts from the migration chain (review MIG-03).
|
||||
converge:
|
||||
profiles: [test]
|
||||
image: postgres:16.15-alpine
|
||||
restart: "no"
|
||||
depends_on:
|
||||
db: {condition: service_healthy}
|
||||
environment:
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||
DB_HOST: db
|
||||
DB_USER: computer
|
||||
volumes:
|
||||
- ./db:/db:ro
|
||||
command: [sh, /db/check-convergence.sh]
|
||||
networks: [backend]
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
name: ${POSTGRES_VOLUME:-computer_pgdata}
|
||||
external: ${POSTGRES_VOLUME_EXTERNAL:-false}
|
||||
networks:
|
||||
backend:
|
||||
driver: bridge
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))`
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 & 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Executable
+107
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+59
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "test": "node e2e.mjs", "test:regressions": "node regressions.mjs" },
|
||||
"devDependencies": { "playwright": "1.58.2" }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Executable
+354
@@ -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"
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
.env*
|
||||
.git
|
||||
out
|
||||
*.log
|
||||
@@ -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=
|
||||
@@ -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
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CalendarClient } from './client'
|
||||
|
||||
export default function CalendarPage() {
|
||||
return <CalendarClient />
|
||||
}
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user