Address code review findings across API, web and deployment
release gate / check (push) Failing after 6m43s

High severity:
- wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup
- bucket public rate limits on effective client IP, add per-bookmark budget,
  and forward the signed browser identity from the share page
- disable first-run bootstrap in server deployments and document seeding
- serialize last-admin demotion in one advisory-locked transaction

Medium/low:
- cap and batch share recipient inserts
- add a shared outbound budget for preview/CardDAV/IMAP/signal work
- pin CardDAV dials to 443 and bound IMAP logout
- fix rightward kanban column drags and normalize cards on column delete
- classify backend outages in auth guards, add request timeouts and an
  error boundary, remove the dead login route, surface throttling
- enforce pinned/unpinned page reorder grouping server-side
- avoid preview work for stale bookmark versions; drop duplicate dialog fetch
- sequence-guard collection/tag/calendar/quick-find loads
- validate property config and fix MULTI_SELECT/DATE filter inputs
- document index maintenance; apply same-origin check to sign-out
- trim signup names, unique shared dates and kanban keys (migration 010),
  reset reference cache on identity changes, hide OIDC issuer, return 413
  for oversized direct bodies, escape Markdown link destinations
- remove dead template files and duplicate test helper

Verified with go build/vet, full PostgreSQL race suite, db convergence,
tsc, both ESLint configs and a Next production build.
This commit is contained in:
Andrew S Erwin
2026-10-20 00:13:30 -05:00
parent 5eab58f99c
commit 4e421f6ea2
58 changed files with 1419 additions and 333 deletions
+16
View File
@@ -9,6 +9,22 @@ NEXTAUTH_URL=http://localhost:3000
# COOKIE_SECURE=1
# Reserved administrator emails; provision them using the operator seed in README.md.
ADMIN_EMAILS=
# First-run administrator claim. The default (unset) lets the first signup
# become administrator, which is convenient locally and DANGEROUS on a public
# server: whoever registers first after DNS is live owns the instance.
# On an exposed deployment set ALLOW_BOOTSTRAP_SIGNUP=0, list the operator in
# ADMIN_EMAILS, and provision that account with `make seed` (scripts/
# server-deploy.sh does this automatically for new installs).
#ALLOW_BOOTSTRAP_SIGNUP=0
# Master switch for self-service registration (default enabled). Set to 0 on
# private instances where accounts are provisioned only by an operator.
#SIGNUP_ENABLED=0
# Trust the X-Real-IP header for rate-limit identity. Set to 1 ONLY when a
# proxy in front of the web container overwrites X-Real-IP with the real peer
# address (the bundled nginx template does; scripts/server-deploy.sh writes
# this for server deployments). Never set it when port 3000 is reachable
# directly — clients could then choose their own rate-limit bucket.
#TRUSTED_PROXY=1
# Keep these defaults on a new install; change only to reuse an existing volume.
POSTGRES_VOLUME=computer_pgdata
POSTGRES_VOLUME_EXTERNAL=false
+18 -12
View File
@@ -7,6 +7,10 @@ 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.
Request bodies over 1 MiB answer **413**. Rate-limited endpoints answer **429**
with `Retry-After`; public bookmark lookups are limited per effective client
and per bookmark id, and previews per client. Share recipient lists are
capped (100 per bookmark/tag/collection share, 200 per shared date).
## Authentication
@@ -17,19 +21,19 @@ non-admin administration, **400** for invalid input, **409** for duplicates.
| 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 |
| POST | `/api/auth/signout` | `{ok:true}` and expired cookie; same-origin `Origin` required when present |
| 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/status` | `{enabled,issuerSet,clientIdSet,clientSecretSet}`; never includes secrets or the issuer URL |
| 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
Go login accepts JSON and rejects foreign browser origins. Login and signup
are throttled per client IP (429 with `Retry-After`), plus a
consecutive-failure lockout per IP+email on login. Login applies to every
peer, loopback included; signup exempts only a direct loopback peer. 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
@@ -50,10 +54,12 @@ 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.
`isPublic`, timestamps, `tags` and `owner:{id,name}` — never the owner's
contact record, the recipient list, alerts, mailbox watches or board
placement (`kanbanColumnId`/`kanbanPosition`). The legacy `kanbanStatus`
badge IS included and is status, not placement; it is documented here so the
projection and UI agree. Detail responses also include
`pageLinks:[{pageId,page}]`, limited to the effective user's pages.
| Method | Path | Returns / behavior |
|---|---|---|
@@ -231,7 +237,7 @@ already see.
| 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:[...]}` |
| POST | `/api/shared-dates` | 201 array of created shares for `{date,note?,recipientIds:[...]}`. Sharing a date also grants read-only access to the creator's bookmarks due that day (including notes); the UI discloses this. Re-sharing the same date updates its note. |
| DELETE | `/api/shared-dates/{id}` | `{ok:true}`, creator only |
| GET | `/api/users/search?q=...` | Safe user projections; minimum two characters, maximum ten results |
+46
View File
@@ -308,6 +308,52 @@ 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.
### Rate-limit identity behind a proxy
Set `TRUSTED_PROXY=1` in `.env` when (and only when) the proxy in front of the
web container **overwrites** `X-Real-IP` with the browser's address — the
bundled nginx template does, and `scripts/server-deploy.sh` writes the flag
for server deployments. The Next server then signs that address and forwards
it to Go, so each browser gets its own login/signup/public-endpoint bucket.
Without it every client shares the web container's bucket (20 logins/minute
for the whole instance).
Do **not** set `TRUSTED_PROXY=1` while port 3000 is reachable directly:
clients could then choose their own rate-limit identity. The web process logs
a startup warning when `NEXTAUTH_URL` is HTTPS but the flag is missing.
### First-run administrator on an exposed server
The default empty `ADMIN_EMAILS` plus bootstrap lets the first signup become
administrator. That is fine locally, but on a public host it is a race with
whoever registers first after DNS goes live. For exposed deployments:
1. Put `ALLOW_BOOTSTRAP_SIGNUP=0` in `.env` (server-deploy.sh writes it).
2. List the operator in `ADMIN_EMAILS=you@example.com` and restart the app.
3. Provision the account with `SEED_EMAIL=you@example.com SEED_PASSWORD='...' make seed`.
`SIGNUP_ENABLED=0` additionally closes self-service registration. Public
signup can never grant `ADMIN` after bootstrap is disabled; recovery is
operator-driven.
### Large-install index maintenance
Migration `007` builds GIN trigram indexes on `Page(title)` and
`Page(content)`. The migration runner wraps each file in one transaction, so
`CREATE INDEX CONCURRENTLY` is unavailable and the build holds locks that
block writes for its duration. Fresh and small installations are fine; on a
large existing `Page` table, schedule a maintenance window, or pre-build the
indexes yourself before deploying (the migration uses `IF NOT EXISTS` and
becomes a no-op):
```sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX CONCURRENTLY IF NOT EXISTS "Page_title_trgm_idx" ON "Page" USING gin (title gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS "Page_content_trgm_idx" ON "Page" USING gin (content gin_trgm_ops);
```
Run those through `make db-shell` (they cannot run inside a transaction).
### Bundled deployment script
The repository ships a target-agnostic deployment for exactly this setup —
+32
View File
@@ -0,0 +1,32 @@
-- Review L-3/L-12: enforce the uniqueness constraints the handlers rely on.
-- Both dedupe existing rows first so the migration succeeds on an installed
-- database that already contains duplicates.
-- One calendar share per (creator, recipient, date). Keep the newest row and
-- collapse the rest; re-sharing now updates the surviving row's note.
DELETE FROM "SharedDate" older
USING "SharedDate" newer
WHERE older."creatorId" = newer."creatorId"
AND older."recipientId" = newer."recipientId"
AND older.date = newer.date
AND (older."createdAt", older.id) < (newer."createdAt", newer.id);
DROP INDEX IF EXISTS "SharedDate_creatorId_recipientId_date_idx";
CREATE UNIQUE INDEX "SharedDate_creatorId_recipientId_date_idx"
ON "SharedDate"("creatorId","recipientId",date);
-- Legacy kanban status keys are unique per user. Keep the key on the
-- earliest column and clear it from duplicates; those columns remain normal
-- (explicitly placed) columns, only the ambiguous legacy mapping goes away.
UPDATE "KanbanColumn" dup
SET "key" = NULL
WHERE dup."key" IS NOT NULL
AND EXISTS (
SELECT 1 FROM "KanbanColumn" keep
WHERE keep."userId" = dup."userId"
AND keep."key" = dup."key"
AND (keep.position, keep.id) < (dup.position, dup.id)
);
CREATE UNIQUE INDEX IF NOT EXISTS "KanbanColumn_userId_key_idx"
ON "KanbanColumn"("userId","key") WHERE "key" IS NOT NULL;
+6 -1
View File
@@ -183,6 +183,9 @@ CREATE TABLE "KanbanColumn" (
"createdAt" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX "KanbanColumn_userId_position_idx" ON "KanbanColumn"("userId", position);
-- Legacy status keys are unique per user (review L-12): ResolveColumnID picks
-- the first match, so duplicates would make the legacy fallback ambiguous.
CREATE UNIQUE INDEX "KanbanColumn_userId_key_idx" ON "KanbanColumn"("userId", "key") WHERE "key" IS NOT NULL;
-- Bookmark.kanbanColumnId -> KanbanColumn (SetNull)
ALTER TABLE "Bookmark" ADD CONSTRAINT "Bookmark_kanbanColumnId_fkey"
@@ -338,7 +341,9 @@ CREATE TABLE "SharedDate" (
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);
-- One row per creator/recipient/date (review L-3): re-sharing the same date
-- updates the note instead of duplicating calendar rows and notifications.
CREATE UNIQUE 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
+9
View File
@@ -47,6 +47,10 @@ services:
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
# Review H-3: these must reach the Go container, not just .env. An empty
# value keeps local defaults (bootstrap enabled, signup open).
ALLOW_BOOTSTRAP_SIGNUP: ${ALLOW_BOOTSTRAP_SIGNUP:-}
SIGNUP_ENABLED: ${SIGNUP_ENABLED:-}
COOKIE_SECURE: ${COOKIE_SECURE:-}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
@@ -73,6 +77,11 @@ services:
NEXTAUTH_SECRET: ${AUTH_SECRET}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
AUTH_TRUST_HOST: "true"
# Review H-1: opt in only when a proxy in front of web pins X-Real-IP
# (the shipped nginx template does; scripts/server-deploy.sh sets it).
# Leave unset when port 3000 is browser-reachable directly, otherwise
# clients can spoof their rate-limit identity.
TRUSTED_PROXY: ${TRUSTED_PROXY:-}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
+23 -1
View File
@@ -40,7 +40,13 @@ func failure(w http.ResponseWriter, status int, s string) {
}
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 {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
// Review L-8: oversized is 413, not malformed JSON.
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
failure(w, 413, "Request body too large (limit 1 MiB)")
return false
}
failure(w, 400, "Invalid JSON")
return false
}
@@ -69,6 +75,9 @@ func HandleSignup(w http.ResponseWriter, r *http.Request) {
failure(w, 400, "Invalid email")
return
}
// Review L-2: a display name of only whitespace is not a name. Trim, then
// fall back to the email local-part when nothing remains.
req.Name = strings.TrimSpace(req.Name)
if len([]rune(req.Name)) > maxSignupNameLen {
failure(w, 400, "Name must be at most 120 characters")
return
@@ -144,6 +153,13 @@ func checkAuthRequest(w http.ResponseWriter, r *http.Request) bool {
failure(w, 415, "Content-Type must be application/json")
return false
}
return checkOrigin(w, r)
}
// checkOrigin rejects a cross-origin Origin when one is present. Extracted
// from checkAuthRequest so the sign-out endpoint can share the same guard
// without forcing a JSON content type on legacy cookie clients (review M-16).
func checkOrigin(w http.ResponseWriter, r *http.Request) bool {
if origin := r.Header.Get("Origin"); origin != "" {
allowed, err := url.Parse(config.Load().NextAuthURL)
scheme := "http"
@@ -236,6 +252,12 @@ func HandleAuth(w http.ResponseWriter, r *http.Request) {
failure(w, 405, "Use POST to sign out")
return
}
// Review M-16: keep sign-out consistent with the rest of the auth
// surface. A browser cross-site POST is rejected; idempotent calls
// for an already-invalid session still succeed below.
if !checkOrigin(w, r) {
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 {
+21 -15
View File
@@ -35,13 +35,13 @@ import (
)
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
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
@@ -169,9 +169,6 @@ func ResetRateLimits() {
}
}
// 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 {
@@ -285,14 +282,23 @@ func RateLimitSignup(next http.HandlerFunc) http.HandlerFunc {
// 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.
// Limiter wraps a handler with a simple per-client fixed-window rate limit.
// Review H-2: this buckets on EffectiveClientIP (the signed forwarded browser
// identity when the trusted Next server attested one), not the raw direct
// peer. Behind the Next proxy every public request otherwise shared one
// instance-wide bucket, so a single caller could deny the endpoint to all
// users. Loopback is not exempt — public endpoints apply to every peer.
func Limiter(s *limiterStore, limit int, window time.Duration) func(http.HandlerFunc) http.HandlerFunc {
return LimiterKeyed(s, limit, window, EffectiveClientIP)
}
// LimiterKeyed is Limiter with a caller-supplied bucket key. It backs the
// second public-share layer (review H-2): one hot bookmark id gets its own
// generous budget in addition to the per-client one.
func LimiterKeyed(s *limiterStore, limit int, window time.Duration, key func(*http.Request) string) 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 {
if ok, retry := s.allow(key(r), limit, window, time.Now()); !ok {
tooManyRequests(w, retry)
return
}
+45 -7
View File
@@ -93,8 +93,8 @@ func TestFailureTrackerEviction(t *testing.T) {
func dummyHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }
func TestRateLimitMiddleware(t *testing.T) {
resetRateLimits()
t.Cleanup(resetRateLimits)
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.
@@ -109,7 +109,7 @@ func TestRateLimitMiddleware(t *testing.T) {
t.Fatalf("loopback login exempt: got %d", loopLast.Code)
}
// Signup keeps the loopback exemption for host-local consumers.
resetRateLimits()
ResetRateLimits()
var loopSignup *httptest.ResponseRecorder
for i := 0; i < signupIPLimit*2; i++ {
loopSignup = httptest.NewRecorder()
@@ -118,7 +118,7 @@ func TestRateLimitMiddleware(t *testing.T) {
if loopSignup.Code != 204 {
t.Fatalf("loopback signup limited with %d", loopSignup.Code)
}
resetRateLimits()
ResetRateLimits()
// Non-loopback peers get 429 after the window limit, with Retry-After.
remote := httptest.NewRequest("POST", "/api/auth/login", nil)
@@ -136,7 +136,7 @@ func TestRateLimitMiddleware(t *testing.T) {
}
// Signup limiter counts independently.
resetRateLimits()
ResetRateLimits()
var signupLast *httptest.ResponseRecorder
for i := 0; i < signupIPLimit+1; i++ {
signupLast = httptest.NewRecorder()
@@ -207,10 +207,48 @@ func TestSignedForwardedClientIP(t *testing.T) {
}
}
// Review H-2: public endpoints must bucket on the signed forwarded browser
// identity too, not the shared Next container peer.
func TestPublicLimiterUsesForwardedIP(t *testing.T) {
t.Setenv("NEXTAUTH_SECRET", "")
t.Setenv("AUTH_SECRET", "public-limiter-forward-secret")
ts := time.Now().Unix()
sign := func(ip string) (string, string) {
mac := hmac.New(sha256.New, []byte("public-limiter-forward-secret"))
mac.Write([]byte(ip + "|" + strconv.FormatInt(ts, 10)))
return strconv.FormatInt(ts, 10), hex.EncodeToString(mac.Sum(nil))
}
handler := Limiter(NewLimiterStore(), 2, time.Minute)(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
do := func(ip string) int {
r := httptest.NewRequest("GET", "/api/public/bookmarks/x", nil)
r.RemoteAddr = "10.0.0.5:1234"
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 < 2; i++ {
if code := do("198.51.100.1"); code != http.StatusOK {
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)
}
if code := do("198.51.100.2"); code != http.StatusOK {
t.Fatalf("browser 2 blocked by browser 1: %d", code)
}
}
func TestForwardedIPsGetSeparateBuckets(t *testing.T) {
t.Setenv("NEXTAUTH_SECRET", "")
t.Setenv("AUTH_SECRET", "ratelimit-forward-secret")
resetRateLimits()
ResetRateLimits()
ts := time.Now().Unix()
sign := func(ip string) (string, string) {
mac := hmac.New(sha256.New, []byte("ratelimit-forward-secret"))
@@ -241,5 +279,5 @@ func TestForwardedIPsGetSeparateBuckets(t *testing.T) {
if code := do("198.51.100.2"); code != 200 {
t.Fatalf("browser 2 blocked by browser 1: %d", code)
}
resetRateLimits()
ResetRateLimits()
}
+44 -12
View File
@@ -1,7 +1,9 @@
package authguard
import (
"database/sql"
"encoding/json"
"errors"
"github.com/andrew/go-computer/internal/auth"
"github.com/andrew/go-computer/internal/config"
"github.com/andrew/go-computer/internal/db"
@@ -11,6 +13,11 @@ import (
"time"
)
// errLastAdmin is the transactional refusal to remove the final
// administrator (review H-4). It is raised inside the locked transaction so
// the count and the update cannot be interleaved by a concurrent demotion.
var errLastAdmin = errors.New("at least one administrator must remain")
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
@@ -43,21 +50,46 @@ func HandleAdminPatchUser(w http.ResponseWriter, r *http.Request) {
handlers.WriteJSON(w, 404, handlers.Error{Error: "Not found"})
return
}
// Review SEC-11: an installation must keep at least one administrator.
// Review SEC-11/H-4: an installation must keep at least one administrator.
// Self-demotion is already refused; this closes the "demote the last
// other admin" path.
// other admin" path. The role re-read, count and update all run in ONE
// transaction serialized by an advisory lock, so two admins demoting
// each other concurrently can no longer both observe admins == 2 and
// leave zero. The pre-transaction role read above only picks the audit
// message; the decision uses the locked value.
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
}
e = db.Transaction(r.Context(), d, func(tx *sql.Tx) error {
if _, e := tx.ExecContext(r.Context(), `SELECT pg_advisory_xact_lock(927439)`); e != nil {
return e
}
var current string
if e := tx.QueryRowContext(r.Context(), `SELECT role FROM "User" WHERE id=$1`, b.UserID).Scan(&current); e != nil {
return e
}
if current == "ADMIN" {
var admins int
if e := tx.QueryRowContext(r.Context(), `SELECT count(*) FROM "User" WHERE role='ADMIN' AND COALESCE(email,'') NOT LIKE '%@example.com'`).Scan(&admins); e != nil {
return e
}
if admins <= 1 {
return errLastAdmin
}
}
_, e := tx.ExecContext(r.Context(), `UPDATE "User" SET role=$1,"updatedAt"=now() WHERE id=$2`, b.Role, b.UserID)
return e
})
} else {
e = repo.SetRole(r.Context(), b.UserID, b.Role)
}
if e := repo.SetRole(r.Context(), b.UserID, b.Role); e != nil {
if errors.Is(e, sql.ErrNoRows) {
handlers.WriteJSON(w, 404, handlers.Error{Error: "Not found"})
return
}
if errors.Is(e, errLastAdmin) {
handlers.WriteJSON(w, 400, handlers.Error{Error: "At least one administrator must remain"})
return
}
if e != nil {
handlers.WriteJSON(w, 500, handlers.Error{Error: "Unable to change role"})
return
}
+32 -2
View File
@@ -1,9 +1,11 @@
package config
import (
"fmt"
"net/url"
"os"
"strings"
"sync"
)
// DBURL returns the database connection string, preferring DATABASE_URL and
@@ -76,11 +78,23 @@ func (c *Config) CookieSecure() bool {
return err == nil && u.Scheme == "https"
}
// adminEmailsCache memoizes the parsed ADMIN_EMAILS value by its raw env
// string (review M-12: IsAdminEmail runs on the auth hot path and re-split
// and re-lowercased the list on every call). Keying on the raw value keeps
// t.Setenv-based tests correct without a process-global startup snapshot.
var adminEmailsCache struct {
mu sync.Mutex
raw string
out []string
}
// AdminEmails splits env ADMIN_EMAILS on commas, trims, lowercases.
func AdminEmails() []string {
s := os.Getenv("ADMIN_EMAILS")
if s == "" {
return nil
adminEmailsCache.mu.Lock()
defer adminEmailsCache.mu.Unlock()
if adminEmailsCache.raw == s {
return adminEmailsCache.out
}
var out []string
for _, email := range strings.Split(s, ",") {
@@ -88,6 +102,7 @@ func AdminEmails() []string {
out = append(out, email)
}
}
adminEmailsCache.raw, adminEmailsCache.out = s, out
return out
}
@@ -111,3 +126,18 @@ func IsAdminEmail(email string) bool {
func AllowBootstrapSignup() bool {
return os.Getenv("ALLOW_BOOTSTRAP_SIGNUP") != "0"
}
// Validate fails fast on configuration the app cannot use, instead of
// discovering it on the first request (review M-12).
func (c *Config) Validate() error {
if strings.TrimSpace(c.Secret()) == "" {
return fmt.Errorf("AUTH_SECRET (or NEXTAUTH_SECRET) must be set")
}
if c.NextAuthURL != "" {
u, err := url.Parse(c.NextAuthURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("NEXTAUTH_URL must be an absolute http(s) URL, got %q", c.NextAuthURL)
}
}
return nil
}
+31
View File
@@ -35,6 +35,37 @@ func TestSecretPrecedence(t *testing.T) {
}
}
// Review M-12: fail fast on unusable configuration at startup.
func TestValidate(t *testing.T) {
if err := (&Config{}).Validate(); err == nil {
t.Fatal("missing secret passed validation")
}
if err := (&Config{AuthSecret: "secret", NextAuthURL: "not a url"}).Validate(); err == nil {
t.Fatal("relative NEXTAUTH_URL passed validation")
}
for _, u := range []string{"", "http://localhost:3000", "https://app.example.com"} {
if err := (&Config{AuthSecret: "secret", NextAuthURL: u}).Validate(); err != nil {
t.Fatalf("valid configuration %q rejected: %v", u, err)
}
}
}
// Review M-12: the parsed ADMIN_EMAILS list is memoized but still follows
// env changes (tests and operator restarts rely on that).
func TestAdminEmailsTracksEnv(t *testing.T) {
t.Setenv("ADMIN_EMAILS", " A@Example.com ,, b@example.com ")
if got := AdminEmails(); len(got) != 2 || got[0] != "a@example.com" || got[1] != "b@example.com" {
t.Fatalf("unexpected parse: %v", got)
}
t.Setenv("ADMIN_EMAILS", "")
if got := AdminEmails(); len(got) != 0 {
t.Fatalf("cache did not follow the env change: %v", got)
}
if IsAdminEmail("a@example.com") {
t.Fatal("stale admin email after env change")
}
}
// Review OPS-01: a dedicated ENCRYPTION_KEY decouples integration-credential
// encryption from the session signing secret.
func TestEncryptionSecretPrecedence(t *testing.T) {
+4 -1
View File
@@ -23,7 +23,10 @@ const BookmarkOwnerView = `to_jsonb(b) || jsonb_build_object(
// 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.
// recipient list, no alerts, no mailbox-watch settings, no board placement
// (kanbanColumnId/kanbanPosition). The legacy kanbanStatus badge IS included
// deliberately: it is status, not placement, and API.md/web/AGENTS.md say so
// (review L-10).
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",
+9 -1
View File
@@ -318,6 +318,14 @@ func HandleBookmarksUpdate(w http.ResponseWriter, r *http.Request) {
respond(w, 0, nil, e)
return
}
// Review M-10: reject a stale base version BEFORE doing outbound preview
// work. The transaction below still re-checks under the user lock; this
// early check only avoids spending up to 8 seconds and the shared preview
// budget on a request that can only end in 409.
if stored, _ := existing["version"].(float64); int(stored) != baseVersion {
respond(w, 0, nil, conflict("Bookmark was modified elsewhere; reload to see the latest version"))
return
}
if s := str(d, "url"); s != "" && s != str(existing, "url") {
addPreview(r.Context(), previewKey(r), d, false)
}
@@ -400,7 +408,7 @@ func HandleBookmarksShare(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
list, e := ids(b, "userIds")
list, e := cappedIDs(b, "userIds", maxShareRecipients)
if e != nil {
respond(w, 0, nil, e)
return
+13 -2
View File
@@ -306,7 +306,7 @@ func HandleCollectionShare(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
list, e := ids(b, "userIds")
list, e := cappedIDs(b, "userIds", maxShareRecipients)
if e != nil {
respond(w, 0, nil, e)
return
@@ -431,7 +431,18 @@ func validatePropertyConfig(typ string, raw any, existing []object) (object, []s
}
return object{"mode": mode}, nil, nil
default:
if m, ok := raw.(object); ok && raw != nil && len(m) > 0 {
// Review M-13: these types take no configuration. Accept null or an
// empty object and reject every other value — silently replacing a
// malformed config with {} told the client it had configured
// something it had not.
if raw == nil {
return object{}, nil, nil
}
m, ok := raw.(object)
if !ok {
return nil, nil, bad("config must be an object")
}
if len(m) > 0 {
return nil, nil, bad(typ + " properties take no config")
}
return object{}, nil, nil
+53 -7
View File
@@ -13,6 +13,7 @@ import (
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
@@ -36,7 +37,19 @@ 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 {
if err := dec.Decode(&b); err != nil {
// Review L-8: an oversized body is 413, not malformed JSON. The Next
// proxy already answers 413; direct API callers deserve the same
// class of error.
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeJSON(w, 413, Error{"Request body too large (limit 1 MiB)"})
return nil, false
}
writeJSON(w, 400, Error{"Invalid JSON object"})
return nil, false
}
if b == nil {
writeJSON(w, 400, Error{"Invalid JSON object"})
return nil, false
}
@@ -98,6 +111,27 @@ func ids(b object, k string) ([]string, error) {
}
return out, nil
}
// Share-list caps (review M-1). A 1 MiB JSON body can carry tens of
// thousands of ids; each one used to become an INSERT in a user-locked
// transaction. These are product limits, not storage limits.
const (
maxShareRecipients = 100
maxSharedDateRecipients = 200
)
// cappedIDs is ids plus a length cap (review M-1), returning a clear 400.
func cappedIDs(b object, key string, max int) ([]string, error) {
list, e := ids(b, key)
if e != nil {
return nil, e
}
if len(list) > max {
return nil, bad(fmt.Sprintf("%s may contain at most %d entries", key, max))
}
return list, nil
}
func fields(b object, allowed string) object {
out := object{}
for _, k := range strings.Fields(allowed) {
@@ -254,11 +288,23 @@ func reorder(r *http.Request, table, col string, order []string) error {
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
}
// outboundBusy answers 429 with Retry-After when the shared outbound budget
// is exhausted (review M-2).
func outboundBusy(w http.ResponseWriter, retry time.Duration) {
if retry < time.Second {
retry = time.Second
}
return nil
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
writeJSON(w, 429, Error{"Too much outbound work right now; try again shortly"})
}
// shareRows inserts every recipient in ONE statement (review M-1). table and
// key come only from the server-side call sites, never from request input.
func shareRows(r *http.Request, tx *sql.Tx, table, key, id string, list []string) error {
if len(list) == 0 {
return nil
}
_, e := tx.ExecContext(r.Context(), `INSERT INTO `+db.Quote(table)+` (`+db.Quote(key)+`,"userId") SELECT $1,unnest($2::text[]) ON CONFLICT DO NOTHING`, id, list)
return e
}
+31 -2
View File
@@ -230,16 +230,32 @@ func HandleKanbanColumnsDelete(w http.ResponseWriter, r *http.Request) {
return bad("A board needs at least one column")
}
dest = str(remaining[0], "id")
// The destination column's legacy key, when it has one, becomes the
// moved cards' kanbanStatus so the two representations do not point
// at the deleted column (review M-5/L-12). Custom columns have no
// key; their cards keep the legacy status as a documented fallback.
destKey := ""
for _, c := range remaining {
if str(c, "id") == dest {
if k := str(c, "key"); validStatus(k) {
destKey = k
}
}
}
// 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))
// Positions are normalized below too (review M-5), so reload order is
// deterministic instead of interleaving with the destination's cards.
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 ORDER BY b."kanbanPosition",b."updatedAt" DESC,b.id DESC`, user(r))
if e != nil {
return e
}
moveIDs := make([]string, 0, len(bms))
moving := map[string]bool{}
for _, b := range bms {
if kanban.ResolveColumnID(b, cols) == id {
moveIDs = append(moveIDs, str(b, "id"))
moving[str(b, "id")] = true
}
}
if len(moveIDs) > 0 {
@@ -248,7 +264,20 @@ func HandleKanbanColumnsDelete(w http.ResponseWriter, r *http.Request) {
// 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 {
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" SET "kanbanColumnId"=$1,"kanbanStatus"=CASE WHEN $4='' THEN "kanbanStatus" ELSE $4::"KanbanStatus" END,version=version+1,"updatedAt"=now() WHERE id=ANY($2::text[]) AND "ownerId"=$3`, dest, moveIDs, user(r), destKey); e != nil {
return e
}
// Append the moved cards after the destination's existing order
// and renumber contiguously, exactly like a normal card move.
order := make([]string, 0, len(bms))
for _, b := range bms {
bid := str(b, "id")
if !moving[bid] && kanban.ResolveColumnID(b, remaining) == dest {
order = append(order, bid)
}
}
order = append(order, moveIDs...)
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" AS b SET "kanbanPosition"=t.ord-1 FROM unnest($1::text[]) WITH ORDINALITY AS t(bid,ord) WHERE b.id=t.bid AND b."ownerId"=$2`, order, user(r)); e != nil {
return e
}
moved = len(moveIDs)
+55 -4
View File
@@ -9,8 +9,10 @@ import (
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/outbound"
"github.com/andrew/go-computer/internal/signals"
"io"
"net"
"net/http"
"net/url"
"strconv"
@@ -18,12 +20,24 @@ import (
"time"
)
// carddavDial pins production CardDAV traffic to 443 (review M-3), matching
// previews (80/443) and IMAP (143/993). Without it a user could make the
// server TLS-handshake and send Basic-auth credentials to any public port.
// The fixture/LAN escape hatch keeps loopback fakes and deliberate private
// deployments working.
func carddavDial(ctx context.Context, network, address string) (net.Conn, error) {
if netguard.AllowPrivate() {
return netguard.DialContext(ctx, network, address)
}
return netguard.DialContextPorts(ctx, network, address, 443)
}
// 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},
Transport: &http.Transport{DialContext: carddavDial},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
@@ -65,7 +79,7 @@ func HandleSharedDatesCreate(w http.ResponseWriter, r *http.Request) {
respond(w, 0, nil, bad("Date required"))
return
}
list, e := ids(b, "recipientIds")
list, e := cappedIDs(b, "recipientIds", maxSharedDateRecipients)
if e != nil || len(list) == 0 {
respond(w, 0, nil, bad("Recipients required"))
return
@@ -81,7 +95,11 @@ func HandleSharedDatesCreate(w http.ResponseWriter, r *http.Request) {
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"]})
// Review L-3: re-sharing the same creator/recipient/date updates
// the note instead of inserting a duplicate calendar row.
v, e := db.One(r.Context(), tx, `INSERT INTO "SharedDate"(id,date,note,"creatorId","recipientId") VALUES($1,$2,$3,$4,$5)
ON CONFLICT ("creatorId","recipientId",date) DO UPDATE SET note=EXCLUDED.note
RETURNING to_jsonb("SharedDate")`, db.NewID(), dt, b["note"], user(r), id)
if e != nil {
return e
}
@@ -149,6 +167,13 @@ func HandleContactsSync(w http.ResponseWriter, r *http.Request) {
respond(w, 0, nil, bad("Nextcloud URL must use https"))
return
}
// Review M-3: the transport pins production dials to 443; reject an
// explicit non-standard port up front for a clear error instead of a
// generic connection failure.
if !netguard.AllowPrivate() && base.Port() != "" && base.Port() != "443" {
respond(w, 0, nil, bad("Nextcloud URL must use port 443"))
return
}
path := str(b, "cardDavPath")
if path == "" {
path = "/remote.php/dav/addressbooks/users/" + url.PathEscape(str(b, "username")) + "/contacts/"
@@ -158,6 +183,15 @@ func HandleContactsSync(w http.ResponseWriter, r *http.Request) {
respond(w, 0, nil, e)
return
}
// Review M-2: CardDAV sync draws from the shared outbound budget with
// IMAP/preview/signal checks, so one user cannot open unbounded
// long-lived connections or starve egress.
release, retry, ok := outbound.Reserve("carddav:"+user(r), 10, time.Minute)
if !ok {
outboundBusy(w, retry)
return
}
defer release()
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 {
@@ -493,11 +527,20 @@ func HandleImapTest(w http.ResponseWriter, r *http.Request) {
}
m.Password = saved.Password
}
release, retry, ok := outbound.Reserve("imap:"+user(r), 10, time.Minute)
if !ok {
outboundBusy(w, retry)
return
}
defer release()
c, e := signals.Connect(r.Context(), m)
if e != nil {
respond(w, 0, nil, bad("Mailbox connection or login failed"))
return
}
// Review M-2: a hostile server must not hold the handler open during the
// goodbye exchange after the test has already succeeded.
c.Timeout = 5 * time.Second
c.Logout()
respond(w, 200, OK{true}, nil)
}
@@ -523,6 +566,12 @@ func HandleImapCheck(w http.ResponseWriter, r *http.Request) {
respond(w, 0, nil, bad("Configure IMAP first"))
return
}
release, retry, ok := outbound.Reserve("imap:"+user(r), 10, time.Minute)
if !ok {
outboundBusy(w, retry)
return
}
defer release()
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel()
c, e := signals.Connect(ctx, m)
@@ -532,7 +581,9 @@ func HandleImapCheck(w http.ResponseWriter, r *http.Request) {
}
stop := context.AfterFunc(ctx, func() { c.Terminate() })
defer stop()
defer c.Logout()
// Keep the goodbye exchange bounded too (review M-2); searches above
// stay under the caller's 45-second context.
defer func() { c.Timeout = 5 * time.Second; c.Logout() }()
searchFailed := false
for _, b := range watched {
events, e := signals.SearchConn(c, m, "", str(b, "imapQuery"))
+47 -1
View File
@@ -131,6 +131,52 @@ func HandlePagesCreate(w http.ResponseWriter, r *http.Request) {
})
respond(w, 201, v, e)
}
// reorderPages enforces the pinned/unpinned grouping invariant server-side
// (review M-9). The list orders by pinned DESC, position, so a permutation
// that interleaves the groups could never be displayed as requested. Rather
// than mutating meaningless positions, positions are normalized INSIDE each
// group: the submitted order determines the order of pinned pages among
// pinned pages and unpinned among unpinned, regardless of interleaving.
func reorderPages(r *http.Request, order []string) error {
return db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
rows, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',id,'pinned',pinned) FROM "Page" WHERE "ownerId"=$1`, user(r))
if e != nil {
return e
}
if len(rows) != len(order) {
return bad("Order must contain every record exactly once")
}
pinnedByID := make(map[string]bool, len(rows))
for _, row := range rows {
isPinned, _ := row["pinned"].(bool)
pinnedByID[str(row, "id")] = isPinned
}
groups := map[bool][]string{}
seen := make(map[string]bool, len(order))
for _, id := range order {
isPinned, ok := pinnedByID[id]
if !ok || seen[id] {
return bad("Order must contain every record exactly once")
}
seen[id] = true
groups[isPinned] = append(groups[isPinned], id)
}
for _, ids := range [][]string{groups[true], groups[false]} {
if len(ids) == 0 {
continue
}
if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" AS p SET position=t.ord-1 FROM unnest($1::text[]) WITH ORDINALITY AS t(pid,ord) WHERE p.id=t.pid AND p."ownerId"=$2`, ids, user(r)); e != nil {
return e
}
}
return nil
})
}
func HandlePagesReorder(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
@@ -138,7 +184,7 @@ func HandlePagesReorder(w http.ResponseWriter, r *http.Request) {
}
list, e := ids(b, "order")
if e == nil {
e = reorder(r, "Page", "ownerId", list)
e = reorderPages(r, list)
}
respond(w, 200, OK{true}, e)
}
+10
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/outbound"
"github.com/andrew/go-computer/internal/signals"
"net/http"
"strings"
@@ -42,6 +43,15 @@ func HandleSignalsCreate(w http.ResponseWriter, r *http.Request) {
// 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.
// Review M-2: a manual check is a 45-second outbound pass; it draws
// from the same budget as previews/IMAP/CardDAV so repeated clicks
// cannot multiply concurrent mailbox connections.
release, retry, ok := outbound.Reserve("signals:"+user(r), 10, time.Minute)
if !ok {
outboundBusy(w, retry)
return
}
defer release()
ctx := r.Context()
leaseKey := "signal-poll:" + user(r)
conn, e := database(r).Conn(ctx)
+1 -1
View File
@@ -74,7 +74,7 @@ func HandleTagsUpdate(w http.ResponseWriter, r *http.Request) {
respond(w, 0, nil, e)
return
}
list, e := ids(b, "shareUserIds")
list, e := cappedIDs(b, "shareUserIds", maxShareRecipients)
if e != nil {
respond(w, 0, nil, e)
return
+332
View File
@@ -0,0 +1,332 @@
package http
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"strings"
"sync"
"testing"
)
// rawCall issues a request with the client's cookies and explicit headers,
// without the apiClient helper's automatic status assertion.
func rawCall(t *testing.T, c apiClient, method, path string, body []byte, headers map[string]string) (int, []byte) {
t.Helper()
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequest(method, c.base+path, r)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
res, err := c.c.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
return res.StatusCode, out
}
// Review H-4: two admins demoting each other must not both pass the
// "at least one administrator" check. The advisory-locked transaction makes
// one of them win and the other observe the committed count.
func TestConcurrentAdminDemotionsKeepOneAdmin(t *testing.T) {
srv, d := testServerDB(t)
a, _ := accountClient(t, srv.URL, "admin-a@computer.test")
b, bID := accountClient(t, srv.URL, "admin-b@computer.test")
a.request("PATCH", "/api/admin/users", map[string]any{"userId": bID, "role": "ADMIN"}, 200)
aID := meID(a)
demote := func(c apiClient, target string) int {
body, _ := json.Marshal(map[string]any{"userId": target, "role": "USER"})
status, _ := rawCall(t, c, "PATCH", "/api/admin/users", body, nil)
return status
}
start := make(chan struct{})
var wg sync.WaitGroup
statuses := make([]int, 2)
wg.Add(2)
go func() { defer wg.Done(); <-start; statuses[0] = demote(a, bID) }()
go func() { defer wg.Done(); <-start; statuses[1] = demote(b, aID) }()
close(start)
wg.Wait()
ok, refused := 0, 0
for _, s := range statuses {
switch s {
case 200:
ok++
case 400, 403:
// 400: still an admin, but the locked count now shows one.
// 403: the other demotion committed first and this session is no
// longer an admin by the time its request is authorized.
refused++
default:
t.Fatalf("unexpected demotion status: %v", statuses)
}
}
if ok != 1 || refused != 1 {
t.Fatalf("wanted one successful and one refused demotion, got %v", statuses)
}
var admins int
if err := d.QueryRow(`SELECT count(*) FROM "User" WHERE role='ADMIN'`).Scan(&admins); err != nil {
t.Fatal(err)
}
if admins != 1 {
t.Fatalf("concurrent demotions left %d administrators", admins)
}
}
// Review L-2: a whitespace-only display name is trimmed and falls back to the
// email local-part instead of being stored verbatim.
func TestSignupTrimsWhitespaceName(t *testing.T) {
srv, _ := testServerDB(t)
jar, _ := cookiejar.New(nil)
c := apiClient{t, &http.Client{Jar: jar}, srv.URL}
body := map[string]any{"email": "trimmed@computer.test", "password": "test-long-password", "name": " "}
c.request("POST", "/api/signup", body, 201)
c.request("POST", "/api/auth/login", body, 200)
me := c.request("GET", "/api/me", nil, 200)
user := me["user"].(map[string]any)
if user["name"] != "trimmed" {
t.Fatalf("whitespace name was not normalized: %v", user["name"])
}
}
// Review M-1: share recipient arrays are capped before they become one
// INSERT per id inside a user-locked transaction.
func TestShareRecipientListsAreCapped(t *testing.T) {
srv, _ := testServerDB(t)
owner, _ := accountClient(t, srv.URL, "cap-owner@computer.test")
id := owner.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.com/cap"}, 201)["id"].(string)
tooMany := make([]string, maxTestShareRecipients+1)
for i := range tooMany {
tooMany[i] = fmt.Sprintf("user-%d", i)
}
owner.request("POST", "/api/bookmarks/"+id+"/share", map[string]any{"userIds": tooMany}, 400)
dates := make([]string, maxTestSharedDateRecipients+1)
for i := range dates {
dates[i] = fmt.Sprintf("user-%d", i)
}
owner.request("POST", "/api/shared-dates", map[string]any{"date": "2026-12-01", "recipientIds": dates}, 400)
}
// Mirrors handlers.maxShareRecipients / maxSharedDateRecipients (unexported).
const (
maxTestShareRecipients = 100
maxTestSharedDateRecipients = 200
)
// Review L-8: an oversized direct API body is 413, not a misleading 400.
func TestOversizedDirectBodyReturns413(t *testing.T) {
srv, _ := testServerDB(t)
owner, _ := accountClient(t, srv.URL, "oversized@computer.test")
body := []byte(`{"url":"https://example.com","notes":"` + strings.Repeat("a", 1<<20) + `"}`)
status, _ := rawCall(t, owner, "POST", "/api/bookmarks", body, nil)
if status != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized body: wanted 413, got %d", status)
}
}
// Review M-16: sign-out rejects a cross-site Origin like the other auth
// endpoints, while an idempotent same-origin call still succeeds.
func TestSignoutRejectsCrossOrigin(t *testing.T) {
srv, _ := testServerDB(t)
owner, _ := accountClient(t, srv.URL, "signout@computer.test")
body := []byte(`{}`)
status, _ := rawCall(t, owner, "POST", "/api/auth/signout", body, map[string]string{"Origin": "https://attacker.invalid"})
if status != http.StatusForbidden {
t.Fatalf("cross-origin signout: wanted 403, got %d", status)
}
status, _ = rawCall(t, owner, "POST", "/api/auth/signout", body, map[string]string{"Origin": srv.URL})
if status != http.StatusOK {
t.Fatalf("same-origin signout: wanted 200, got %d", status)
}
}
// Review M-9: the server normalizes positions inside the pinned/unpinned
// groups instead of trusting a client to keep them apart.
func TestPageReorderKeepsPinnedGrouping(t *testing.T) {
srv, _ := testServerDB(t)
owner, _ := accountClient(t, srv.URL, "reorder@computer.test")
pinned := owner.request("POST", "/api/pages", map[string]any{"title": "Pinned", "pinned": true}, 201)["id"].(string)
second := owner.request("POST", "/api/pages", map[string]any{"title": "Second"}, 201)["id"].(string)
third := owner.request("POST", "/api/pages", map[string]any{"title": "Third"}, 201)["id"].(string)
// Interleaved request: unpinned pages first, the pinned page last.
owner.request("PUT", "/api/pages", map[string]any{"order": []string{third, second, pinned}}, 200)
res, err := owner.c.Get(srv.URL + "/api/pages")
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)
}
got := make([]string, 0, len(list))
for _, p := range list {
got = append(got, p["id"].(string))
}
want := []string{pinned, third, second}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("page order after reorder = %v, want %v", got, want)
}
owner.request("PUT", "/api/pages", map[string]any{"order": []string{pinned, second}}, 400)
}
// Review M-13: non-object / non-empty config on types that take no config is
// rejected instead of silently discarded.
func TestPropertyConfigRejectsMalformedValues(t *testing.T) {
srv, _ := testServerDB(t)
owner, _ := accountClient(t, srv.URL, "props@computer.test")
cid := owner.request("POST", "/api/collections", map[string]any{"name": "Config"}, 201)["id"].(string)
for _, cfg := range []any{"x", []any{}, 5, true} {
owner.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Bad", "type": "TEXT", "config": cfg}, 400)
}
owner.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Null", "type": "TEXT", "config": nil}, 201)
owner.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Empty", "type": "TEXT", "config": map[string]any{}}, 201)
}
// Review L-3: re-sharing the same date updates the surviving row instead of
// creating duplicate calendar entries.
func TestSharedDateReshareIsIdempotent(t *testing.T) {
srv, _ := testServerDB(t)
owner, _ := accountClient(t, srv.URL, "dates-owner@computer.test")
_, recipientID := accountClient(t, srv.URL, "dates-recipient@computer.test")
post := func(note string) []map[string]any {
body, _ := json.Marshal(map[string]any{"date": "2026-11-05", "note": note, "recipientIds": []string{recipientID}})
status, raw := rawCall(t, owner, "POST", "/api/shared-dates", body, nil)
if status != 201 {
t.Fatalf("shared-date create: %d %s", status, raw)
}
var rows []map[string]any
if err := json.Unmarshal(raw, &rows); err != nil {
t.Fatal(err)
}
return rows
}
first := post("first")
second := post("second")
if len(first) != 1 || len(second) != 1 {
t.Fatalf("want one returned share, got %d and %d", len(first), len(second))
}
if second[0]["id"] != first[0]["id"] {
t.Fatalf("re-share created a new row: %v vs %v", second[0]["id"], first[0]["id"])
}
if second[0]["note"] != "second" {
t.Fatalf("re-share did not update the note: %v", second[0]["note"])
}
res, err := owner.c.Get(srv.URL + "/api/shared-dates")
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)
}
count := 0
for _, row := range list {
date, _ := row["date"].(string)
if row["recipientId"] == recipientID && strings.HasPrefix(date, "2026-11-05") {
count++
}
}
if count != 1 {
t.Fatalf("want one shared-date row, got %d", count)
}
}
// Review M-3: production CardDAV dials are pinned to 443.
func TestCardDAVRejectsNonStandardPort(t *testing.T) {
srv, _ := testServerDB(t)
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "0")
owner, _ := accountClient(t, srv.URL, "carddav@computer.test")
v := owner.request("POST", "/api/contacts/sync", map[string]any{
"nextcloudUrl": "https://198.51.100.10:8443",
"username": "user",
"password": "secret",
}, 400)
msg, _ := v["error"].(string)
if !strings.Contains(msg, "443") {
t.Fatalf("expected a port-443 error, got %q", msg)
}
}
// Review M-5: deleting a column appends its cards to the destination and
// renumbers positions contiguously, so reload order is deterministic.
func TestColumnDeleteNormalizesMovedCardPositions(t *testing.T) {
srv, _ := testServerDB(t)
owner, _ := accountClient(t, srv.URL, "columns@computer.test")
var columns []map[string]any
res, err := owner.c.Get(srv.URL + "/api/kanban/columns")
if err != nil {
t.Fatal(err)
}
if err := json.NewDecoder(res.Body).Decode(&columns); err != nil {
t.Fatal(err)
}
res.Body.Close()
if len(columns) < 2 {
t.Fatalf("expected the seeded board, got %d columns", len(columns))
}
first, second := columns[0]["id"].(string), columns[1]["id"].(string)
mk := func(column, status, title string) string {
return owner.request("POST", "/api/bookmarks", map[string]any{
"url": "https://example.com/" + title, "title": title,
"kanbanColumnId": column, "kanbanStatus": status,
}, 201)["id"].(string)
}
mk(second, "TODO", "dest-1")
mk(second, "TODO", "dest-2")
mk(first, "INBOX", "moved-1")
mk(first, "INBOX", "moved-2")
owner.request("DELETE", "/api/kanban/columns/"+first, nil, 200)
res, err = owner.c.Get(srv.URL + "/api/bookmarks?limit=200")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var bookmarks []map[string]any
if err := json.NewDecoder(res.Body).Decode(&bookmarks); err != nil {
t.Fatal(err)
}
positions := map[float64]bool{}
for _, b := range bookmarks {
if b["kanbanColumnId"] != second {
t.Fatalf("bookmark %v was not moved to the destination", b["title"])
}
if b["kanbanStatus"] != "TODO" {
t.Fatalf("legacy status for %v still points at the deleted column: %v", b["title"], b["kanbanStatus"])
}
positions[b["kanbanPosition"].(float64)] = true
}
if len(bookmarks) != 4 || len(positions) != 4 {
t.Fatalf("want four contiguously positioned cards, got %d cards at %d positions", len(bookmarks), len(positions))
}
for i := 0; i < 4; i++ {
if !positions[float64(i)] {
t.Fatalf("positions are not contiguous 0..3: %v", positions)
}
}
}
+15 -6
View File
@@ -142,16 +142,25 @@ func NewServer(cfg *config.Config, database *db.DB) *http.Server {
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).
// Review SEC-10/H-2: public endpoints get two rate-limit layers keyed on
// the EFFECTIVE client identity (signed forwarded browser IP, else the
// direct peer), never the shared Next container address:
// - per client: 30 public lookups/min, 20 previews/min;
// - per public bookmark id: 120/min, so one hot shared link cannot
// exhaust the instance-wide budget.
// Preview is rate limited AFTER auth: unauthenticated requests must not
// consume the bucket and deny previews to signed-in users.
publicBookmarkLimiter := auth.NewLimiterStore()
publicBookmarkIDLimiter := auth.NewLimiterStore()
previewLimiter := auth.NewLimiterStore()
publicLimit := auth.Limiter(publicBookmarkLimiter, 30, time.Minute)
publicIDLimit := auth.LimiterKeyed(publicBookmarkIDLimiter, 120, time.Minute, func(r *http.Request) string {
return "bookmark:" + r.PathValue("id")
})
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)))
mux.HandleFunc("GET /share/bookmark/{id}", publicLimit(publicIDLimit(handlers.HandlePublicBookmark)))
mux.HandleFunc("GET /api/public/bookmarks/{id}", publicLimit(publicIDLimit(handlers.HandlePublicBookmark)))
mux.HandleFunc("GET /api/preview", authed(previewLimit(handlers.HandlePreview)))
return &http.Server{
Handler: withDB(database, mux),
+16 -1
View File
@@ -43,6 +43,21 @@ func BookmarkDocument(b db.Object) string {
}
return out + "\n" + strings.TrimSpace(text(b, "notes")) + "\n"
}
// linkDestination wraps a URL in a CommonMark angle-bracket destination
// (review L-11). Parentheses (and the whitespace validURL accepts) no longer
// terminate the link, and the two characters that cannot appear unescaped
// inside <...> are percent-encoded.
func linkDestination(raw string) string {
escaped := strings.NewReplacer(
"<", "%3C",
">", "%3E",
"\r", "%0D",
"\n", "%0A",
).Replace(raw)
return "<" + escaped + ">"
}
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 {
@@ -60,7 +75,7 @@ func PageDocument(p db.Object) string {
if len(rs) > 160 {
desc = string(rs[:160])
}
out += "- [" + title + "](" + text(b, "url") + ")"
out += "- [" + title + "](" + linkDestination(text(b, "url")) + ")"
if desc != "" {
out += " — " + desc
}
+27
View File
@@ -0,0 +1,27 @@
package markdown
import (
"strings"
"testing"
"github.com/andrew/go-computer/internal/db"
)
// Review L-11: link destinations are wrapped and escaped so parentheses or
// angle brackets in a bookmark URL cannot terminate or extend the link.
func TestPageDocumentEscapesLinkDestinations(t *testing.T) {
out := PageDocument(db.Object{
"title": "Links",
"content": "",
"bookmarks": []any{
map[string]any{"bookmark": map[string]any{
"title": "Odd",
"url": "https://example.com/a(b)?x=<y>",
}},
},
})
want := "[Odd](<https://example.com/a(b)?x=%3Cy%3E>)"
if !strings.Contains(out, want) {
t.Fatalf("link destination not escaped:\n%s", out)
}
}
+5 -2
View File
@@ -22,7 +22,11 @@ import (
// 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}
// GET /api/auth/oidc/status -> {enabled, issuerSet, clientIdSet, clientSecretSet}
//
// Review L-7: the issuer URL is deliberately NOT public. It is usually a
// public hostname, but an internal one would become an information leak; the
// authenticated settings surface can expose it when one exists.
func HandleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodGet {
@@ -38,7 +42,6 @@ func HandleStatus(w http.ResponseWriter, r *http.Request) {
"issuerSet": s.IssuerSet,
"clientIdSet": s.ClientIDSet,
"clientSecretSet": s.ClientSecretSet,
"issuer": s.Issuer,
})
}
+75
View File
@@ -0,0 +1,75 @@
// Package outbound bounds server-initiated network work — URL previews,
// CardDAV syncs, IMAP test/check passes and manual signal checks — so an
// authenticated user cannot open unbounded outbound connections, exhaust
// file descriptors or saturate egress (review M-2).
//
// Two layers are shared by every caller:
// - a global in-flight cap (defaultConcurrent), and
// - a per-identity fixed-window rate (the caller supplies the limit/window,
// since a preview is cheaper than an IMAP search).
//
// Exhaustion is immediate: previews degrade to no metadata and interactive
// endpoints answer 429 with Retry-After. Nothing queues behind the budget.
package outbound
import (
"sync"
"time"
)
const defaultConcurrent = 8
type window struct {
count int
reset time.Time
}
type budget struct {
mu sync.Mutex
inFlight int
max int
keys map[string]*window
}
var shared = &budget{max: defaultConcurrent, keys: map[string]*window{}}
// Reserve claims one outbound slot for key. The returned release MUST be
// called when the work finishes. When ok is false, retry is a conservative
// Retry-After duration.
func Reserve(key string, limit int, windowDur time.Duration) (release func(), retry time.Duration, ok bool) {
if key == "" {
key = "anonymous"
}
shared.mu.Lock()
defer shared.mu.Unlock()
if shared.inFlight >= shared.max {
return nil, time.Second, false
}
now := time.Now()
if len(shared.keys) > 4096 {
for k, w := range shared.keys {
if now.After(w.reset) {
delete(shared.keys, k)
}
}
}
w := shared.keys[key]
if w == nil || now.After(w.reset) {
w = &window{reset: now.Add(windowDur)}
shared.keys[key] = w
}
if limit > 0 && w.count >= limit {
left := w.reset.Sub(now)
if left < 0 {
left = 0
}
return nil, left.Truncate(time.Second) + time.Second, false
}
w.count++
shared.inFlight++
return func() {
shared.mu.Lock()
shared.inFlight--
shared.mu.Unlock()
}, 0, true
}
+42
View File
@@ -0,0 +1,42 @@
package outbound
import (
"testing"
"time"
)
// Review M-2: outbound work is bounded per identity and globally, and a
// released slot is reusable.
func TestReserveBoundsPerKeyAndGlobally(t *testing.T) {
releaseA, retry, ok := Reserve("test-a", 1, time.Minute)
if !ok || releaseA == nil {
t.Fatalf("first reservation rejected (retry %v)", retry)
}
if _, _, ok := Reserve("test-a", 1, time.Minute); ok {
t.Fatal("per-key limit did not reject the second reservation")
}
// Fill the remaining global slots on distinct keys.
releases := []func(){releaseA}
for i := 0; i < defaultConcurrent-1; i++ {
release, _, ok := Reserve("test-global-"+string(rune('a'+i)), 10, time.Minute)
if !ok {
t.Fatalf("global slot %d rejected below the cap", i)
}
releases = append(releases, release)
}
if _, _, ok := Reserve("test-overflow", 10, time.Minute); ok {
t.Fatal("global concurrency cap did not reject an extra reservation")
}
// Release one slot and verify the global capacity is usable again.
releases[0]()
release, _, ok := Reserve("test-after-release", 1, time.Minute)
if !ok {
t.Fatal("released slot was not reusable")
}
release()
for _, r := range releases[1:] {
r()
}
}
+10 -49
View File
@@ -4,13 +4,13 @@ import (
"context"
"fmt"
"github.com/andrew/go-computer/internal/netguard"
"github.com/andrew/go-computer/internal/outbound"
"golang.org/x/net/html"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
@@ -72,60 +72,21 @@ var previewClient = &http.Client{
// 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
keyLimit = 20
keyWindow = time.Minute
// maxConcurrent mirrors outbound's global cap; kept for the budget tests
// and documentation.
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.
// ok is false when either budget layer is exhausted. Preview shares the
// process-wide outbound budget with CardDAV/IMAP/signal checks (review M-2),
// while keeping its own more generous per-identity rate.
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
release, _, ok = outbound.Reserve(key, keyLimit, keyWindow)
return release, ok
}
// FetchPreview resolves metadata for raw on behalf of key (the requesting
+2 -2
View File
@@ -34,8 +34,8 @@ func main() {
return
}
cfg := config.Load()
if cfg.Secret() == "" {
log.Fatal("config: AUTH_SECRET (or NEXTAUTH_SECRET) must be set")
if err := cfg.Validate(); err != nil {
log.Fatalf("config: %v", err)
}
d, err := db.New(cfg)
if err != nil {
+24
View File
@@ -166,6 +166,12 @@ AUTH_SECRET=$(openssl rand -hex 32)
NEXTAUTH_URL=https://$DOMAIN
COOKIE_SECURE=1
ADMIN_EMAILS=
# Review H-1: nginx overwrites X-Real-IP, so the trusted Next server may sign
# and forward the browser identity for per-client rate limiting.
TRUSTED_PROXY=1
# Review H-3: do not let the first visitor after DNS goes live claim the
# instance. Provision the operator with ADMIN_EMAILS + `make seed` (below).
ALLOW_BOOTSTRAP_SIGNUP=0
POSTGRES_VOLUME=computer_pgdata
POSTGRES_VOLUME_EXTERNAL=false
APP_HOST_PORT=$APP_HOST_PORT
@@ -181,6 +187,14 @@ else
# 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
# Review H-1: every server deployment terminates TLS at the bundled nginx,
# which pins X-Real-IP. Upgrade older .env files that predate the switch so
# proxied users stop sharing one rate-limit bucket.
grep -q '^TRUSTED_PROXY=' .env || echo "TRUSTED_PROXY=1" >> .env
# Review H-3: also stop later deploys from re-opening public bootstrap on a
# .env that predates the flag. Operators who deliberately want it can set
# ALLOW_BOOTSTRAP_SIGNUP=1 explicitly.
grep -q '^ALLOW_BOOTSTRAP_SIGNUP=' .env || echo "ALLOW_BOOTSTRAP_SIGNUP=0" >> .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'"
@@ -351,4 +365,14 @@ if [ -n "$REVISION" ]; then
printf '%s\n' "$REVISION" > "$REMOTE_DIR/.deployed-revision"
log "Recorded deployed revision $REVISION"
fi
# Review H-3: with bootstrap disabled, nobody is administrator until the
# operator provisions one. Say so loudly instead of leaving a fresh instance
# without an owner.
if [ "$(sed -n 's/^ALLOW_BOOTSTRAP_SIGNUP=//p' .env | tail -n1)" = "0" ]; then
log "Administrator provisioning is operator-driven (ALLOW_BOOTSTRAP_SIGNUP=0)."
log " 1. Add ADMIN_EMAILS=you@example.com to $REMOTE_DIR/.env"
log " 2. docker compose up -d app # reloads ADMIN_EMAILS"
log " 3. SEED_EMAIL=you@example.com SEED_PASSWORD='...' make seed"
log "Public signup cannot become administrator until an operator exists."
fi
log "Server-side deploy complete"
+3
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Eye, Shield } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { resetReferenceCache } from '@/lib/reference-data'
type U = { id: string; name: string | null; email: string; role: string; _count?: { bookmarks: number; pages: number; tags: number } }
@@ -21,6 +22,8 @@ export function AdminClient({ currentUserId }: { currentUserId: string }) {
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')
// Review L-6: entering view-as changes the effective identity.
resetReferenceCache()
window.location.href = '/pages'
}
+4 -5
View File
@@ -18,17 +18,16 @@ export function BookmarksClient({ initialSearch = null, initialOpen = null }: {
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
// the fresh version, or the next save would send a stale base. Depend on
// `selected` instead of writing a ref during render.
const refreshSelected = useCallback(async () => {
const id = selectedRef.current?.id
const id = selected?.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 */ }
}, [])
}, [selected])
const [tab, setTab] = useState<'mine' | 'shared'>('mine')
const [sortField, setSortField] = useState<'title' | 'updatedAt' | 'kanbanStatus'>('updatedAt')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
+32 -6
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { BookmarkCard } from '@/components/bookmark-card'
import { BookmarkDetailSheet } from '@/components/bookmark-detail-sheet'
@@ -11,11 +11,16 @@ import { Input } from '@/components/ui/input'
import { toast } from 'sonner'
import { fetchBookmarks } from '@/lib/bookmarks'
const INITIAL_DATE = new Date(2026, 9, 1)
// Review L-4: a fixed, obviously-not-current placeholder. The real month and
// today are set after mount (to avoid an SSR/client hydration mismatch), and
// the calendar renders a skeleton until then instead of briefly presenting
// this epoch as the current month.
const INITIAL_DATE = new Date(2000, 0, 1)
export function CalendarClient() {
const [currentMonth, setCurrentMonth] = useState<Date>(INITIAL_DATE)
const [today, setToday] = useState<Date>(INITIAL_DATE)
const [ready, setReady] = useState(false)
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [selectedDay, setSelectedDay] = useState<Date | null>(null)
const [selectedBookmark, setSelectedBookmark] = useState<Bookmark | null>(null)
@@ -38,18 +43,25 @@ export function CalendarClient() {
}, [shareQuery])
useEffect(() => {
const timer = setTimeout(() => { const now = new Date(); setCurrentMonth(now); setToday(now) }, 0)
const timer = setTimeout(() => { const now = new Date(); setCurrentMonth(now); setToday(now); setReady(true) }, 0)
return () => clearTimeout(timer)
}, [])
// Review M-11: bookmark-change events and slow initial loads can overlap;
// only the newest pass may publish its data.
const loadSeq = useRef(0)
const load = useCallback(async () => {
const seq = ++loadSeq.current
setLoading(true)
try {
const [mine, shared, dates] = await Promise.all([fetchBookmarks(), fetchBookmarks(new URLSearchParams({ shared: 'true' })), fetch('/api/shared-dates')])
if (seq !== loadSeq.current) return
const sharedDatesJson = dates.ok ? await dates.json() : null
if (seq !== loadSeq.current) return
setBookmarks([...mine, ...shared])
if (dates.ok) setSharedDates(await dates.json())
} catch { toast.error('Could not load calendar') }
finally { setLoading(false) }
if (sharedDatesJson) setSharedDates(sharedDatesJson)
} catch { if (seq === loadSeq.current) toast.error('Could not load calendar') }
finally { if (seq === loadSeq.current) setLoading(false) }
}, [])
useEffect(() => { const timer = setTimeout(load, 0); window.addEventListener('computer:bookmarks-changed', load); return () => { clearTimeout(timer); window.removeEventListener('computer:bookmarks-changed', load) } }, [load])
@@ -80,6 +92,18 @@ export function CalendarClient() {
return bookmarksByDay[key] ?? []
}, [selectedDay, bookmarksByDay])
if (!ready) {
return (
<div className="space-y-6">
<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="h-[60vh] animate-pulse rounded-lg border border-border/60 bg-muted/30" aria-label="Loading calendar" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -151,6 +175,8 @@ export function CalendarClient() {
<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>
{/* Review L-9: sharing a date is broader than the old label said. */}
<p className="text-xs text-muted-foreground">Recipients will see your bookmarks due that day, including their notes.</p>
{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 ? (
+37 -9
View File
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -109,7 +109,7 @@ function plainValue(prop: CollectionProperty, value: unknown): string {
if (prop.type === 'DATE' && typeof value === 'string' && value) {
if (prop.config.mode === 'date') return value.slice(0, 10)
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('en-US', { timeZone: 'UTC' })
}
if (value === null || value === undefined || value === '') return '—'
return String(value)
@@ -246,6 +246,12 @@ export function CollectionsClient() {
const [shareQuery, setShareQuery] = useState('')
const [shareResults, setShareResults] = useState<{ id: string; name: string | null; email: string }[]>([])
// Review M-11: switching collections/views quickly used to let an older
// response overwrite the newer selection. Each loader has a request
// sequence and only the latest may apply its result.
const detailSeq = useRef(0)
const itemsSeq = useRef(0)
const loadCollections = useCallback(async () => {
try {
const res = await fetch('/api/collections')
@@ -254,27 +260,33 @@ export function CollectionsClient() {
}, [])
const loadDetail = useCallback(async (id: string) => {
const seq = ++detailSeq.current
try {
const res = await fetch(`/api/collections/${id}`)
if (seq !== detailSeq.current) return
if (!res.ok) { setDetail(null); return }
const data: CollectionDetail = await res.json()
if (seq !== detailSeq.current) return
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') }
} catch { if (seq === detailSeq.current) toast.error('Could not load collection') }
}, [])
const loadItems = useCallback(async (id: string, view: string | null, append?: string | null) => {
const seq = ++itemsSeq.current
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 (seq !== itemsSeq.current) return
if (!res.ok) return
const rows: CollectionItem[] = await res.json()
if (seq !== itemsSeq.current) return
setItems((current) => (append ? [...current, ...rows] : rows))
setCursor(res.headers.get('X-Next-Cursor'))
} catch { toast.error('Could not load items') }
} catch { if (seq === itemsSeq.current) toast.error('Could not load items') }
}, [])
useEffect(() => { const t = setTimeout(loadCollections, 0); return () => clearTimeout(t) }, [loadCollections])
@@ -286,14 +298,14 @@ export function CollectionsClient() {
}, [collections, activeId])
useEffect(() => {
const t = setTimeout(() => {
if (!activeId) { setDetail(null); return }
if (!activeId) { detailSeq.current++; setDetail(null); return }
loadDetail(activeId)
}, 0)
return () => clearTimeout(t)
}, [activeId, loadDetail])
useEffect(() => {
const t = setTimeout(() => {
if (!activeId) { setItems([]); return }
if (!activeId) { itemsSeq.current++; setItems([]); return }
loadItems(activeId, viewId)
}, 0)
return () => clearTimeout(t)
@@ -529,7 +541,12 @@ export function CollectionsClient() {
if (!['empty', 'notEmpty'].includes(fOp)) {
if (prop.type === 'NUMBER') value = Number(fValue)
else if (prop.type === 'CHECKBOX') value = fValue === 'true'
else value = fValue
else if (prop.type === 'DATE' && prop.config?.mode === 'datetime') {
// Review M-14: datetime-local emits a local string; the server stores
// fixed-width UTC.
const parsed = new Date(fValue)
value = Number.isNaN(parsed.getTime()) ? '' : parsed.toISOString()
} 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 }])
@@ -539,7 +556,9 @@ export function CollectionsClient() {
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
// Review M-14: MULTI_SELECT filters also carry an option id; show its
// label like SELECT instead of the raw id.
if ((prop.type === 'SELECT' || prop.type === 'MULTI_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}` : ''}`
}
@@ -637,6 +656,9 @@ export function CollectionsClient() {
onDrop={(e) => {
e.preventDefault()
const item = items.find((i) => i.id === draggedItemId)
// This is a drop handler, not render; the compiler rule cannot
// see through the async helper's captured state.
// eslint-disable-next-line react-hooks/refs
if (item) void moveItemToGroup(item, col.key === NONE ? null : col.key)
setDraggedItemId(null)
setBoardDropKey(null)
@@ -824,12 +846,18 @@ export function CollectionsClient() {
</Select>
{!['empty', 'notEmpty'].includes(fOp) && (() => {
const p = detail.properties.find((x) => x.id === fProp)
if (p?.type === 'SELECT') return (
if (p?.type === 'SELECT' || p?.type === 'MULTI_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>
)
// Review M-14: DATE filters used to render a plain
// text box, so a normal date always failed server
// validation. Match the property editor's inputs.
if (p?.type === 'DATE') return p.config?.mode === 'datetime'
? <Input type="datetime-local" value={fValue} onChange={(e) => setFValue(e.target.value)} aria-label="Filter value" className="h-8 text-sm" />
: <Input type="date" value={fValue} onChange={(e) => setFValue(e.target.value)} aria-label="Filter value" className="h-8 text-sm" />
if (p?.type === 'CHECKBOX') return (
<Select value={fValue || undefined} onValueChange={setFValue}>
<SelectTrigger aria-label="Filter value"><SelectValue placeholder="Value" /></SelectTrigger>
+28
View File
@@ -0,0 +1,28 @@
'use client'
// Review M-6: a backend outage is not a logout. Server components throw
// BackendUnavailableError (auth guards) or fail their data fetch; show a
// retry surface instead of redirecting to /login or rendering empty data.
import { useEffect } from 'react'
export default function AppError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
console.error('App route failed', error)
}, [error])
return (
<main className="mx-auto flex min-h-[60vh] max-w-md flex-col items-center justify-center gap-4 p-6 text-center">
<h1 className="text-xl font-semibold">Service temporarily unavailable</h1>
<p className="text-sm text-muted-foreground">
The application backend could not be reached. You are still signed in; try again in a moment.
</p>
<button
type="button"
onClick={() => reset()}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
>
Try again
</button>
</main>
)
}
+16 -7
View File
@@ -49,6 +49,10 @@ export function KanbanClient() {
const [draggedColumnId, setDraggedColumnId] = useState<string | null>(null)
const [cardDrop, setCardDrop] = useState<CardDrop | null>(null)
const [columnDropId, setColumnDropId] = useState<string | null>(null)
// Review M-4: a column drop needs a direction. "Before the target" made a
// rightward drag onto the adjacent column a no-op (the source was already
// before it), so the pointer's half of the target decides before/after.
const [columnDropAfter, setColumnDropAfter] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [editLabel, setEditLabel] = useState('')
const [adding, setAdding] = useState(false)
@@ -85,6 +89,7 @@ export function KanbanClient() {
setDraggedColumnId(null)
setCardDrop(null)
setColumnDropId(null)
setColumnDropAfter(false)
}, [])
const finishDrag = useCallback(() => {
@@ -101,16 +106,18 @@ export function KanbanClient() {
else { toast.error('Could not reorder columns'); load() }
}, [load])
const reorderColumn = useCallback((sourceId: string, targetId: string) => {
const reorderColumn = useCallback((sourceId: string, targetId: string, afterTarget: boolean) => {
if (sourceId === targetId) return
const from = columns.findIndex(c => c.id === sourceId)
const to = columns.findIndex(c => c.id === targetId)
if (from < 0 || to < 0) return
if (from < 0 || !columns.some(c => c.id === targetId)) return
const next = [...columns]
const [moved] = next.splice(from, 1)
// Drop before the target column; this is the least surprising direction
// for both leftward and rightward moves.
next.splice(from < to ? to - 1 : to, 0, moved)
// The target's index after removal, then before or after it according to
// the pointer half the drop reported.
let insertAt = next.findIndex(c => c.id === targetId)
if (insertAt < 0) return
if (afterTarget) insertAt += 1
next.splice(insertAt, 0, moved)
saveColumnOrder(next)
}, [columns, saveColumnOrder])
@@ -223,6 +230,8 @@ export function KanbanClient() {
if (draggedColumnId === col.id) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const rect = e.currentTarget.getBoundingClientRect()
setColumnDropAfter(e.clientX > rect.left + rect.width / 2)
setColumnDropId(col.id)
setCardDrop(null)
return
@@ -237,7 +246,7 @@ export function KanbanClient() {
const handleColumnDrop = (e: React.DragEvent<HTMLElement>, col: Column) => {
e.preventDefault()
if (draggedColumnId) {
reorderColumn(draggedColumnId, col.id)
reorderColumn(draggedColumnId, col.id, columnDropAfter)
finishDrag()
return
}
+2 -2
View File
@@ -110,7 +110,7 @@ function PageEmbedCard({ embed }: { embed: PageEmbed }) {
if (prop.type === 'DATE' && typeof value === 'string' && value) {
if (prop.config.mode === 'date') return value.slice(0, 10)
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString()
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString('en-US', { timeZone: 'UTC' })
}
if (value === null || value === undefined || value === '') return '—'
return String(value)
@@ -1010,7 +1010,7 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
<button type="button" className="flex w-full items-center justify-between gap-2 text-left" onClick={() => void viewRevision(rev.id)}>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">Version {rev.version}</span>
<span className="block text-xs text-muted-foreground">{new Date(rev.createdAt).toLocaleString()}</span>
<span className="block text-xs text-muted-foreground">{new Date(rev.createdAt).toLocaleString('en-US', { timeZone: 'UTC' })}</span>
</span>
{revisions[0]?.id === rev.id && <span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">Current</span>}
</button>
+17 -5
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
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'
@@ -51,11 +51,17 @@ export function TagsClient({ initialTag = null }: { initialTag?: string | null }
} catch { toast.error('Could not load tags') }
}, [])
// Review M-11: a slower response for the previously selected tag must not
// replace the list after the user has moved on.
const bookmarksSeq = useRef(0)
const loadBookmarks = useCallback(async (tagId: string) => {
const seq = ++bookmarksSeq.current
setLoading(true)
try { setBookmarks(await fetchBookmarks(new URLSearchParams({ tagId }))) }
catch { toast.error('Could not load bookmarks') }
finally { setLoading(false) }
try {
const rows = await fetchBookmarks(new URLSearchParams({ tagId }))
if (seq === bookmarksSeq.current) setBookmarks(rows)
} catch { if (seq === bookmarksSeq.current) toast.error('Could not load bookmarks') }
finally { if (seq === bookmarksSeq.current) setLoading(false) }
}, [])
useEffect(() => { const timer = setTimeout(loadTags, 0); return () => clearTimeout(timer) }, [loadTags])
@@ -65,7 +71,13 @@ export function TagsClient({ initialTag = null }: { initialTag?: string | null }
const timer = setTimeout(() => setSelectedTag(initialTag), 0)
return () => clearTimeout(timer)
}, [initialTag])
useEffect(() => { const timer = setTimeout(() => { if (selectedTag) loadBookmarks(selectedTag) }, 0); return () => clearTimeout(timer) }, [selectedTag, loadBookmarks])
useEffect(() => {
const timer = setTimeout(() => {
if (!selectedTag) { bookmarksSeq.current++; setBookmarks([]); return }
loadBookmarks(selectedTag)
}, 0)
return () => clearTimeout(timer)
}, [selectedTag, loadBookmarks])
useEffect(() => { const reload = () => { loadTags(); if (selectedTag) loadBookmarks(selectedTag) }; window.addEventListener('computer:bookmarks-changed', reload); return () => window.removeEventListener('computer:bookmarks-changed', reload) }, [loadTags, loadBookmarks, selectedTag])
const createTag = async () => {
+1 -1
View File
@@ -100,7 +100,7 @@ export function TrashClient() {
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{entry.title}</p>
<p className="text-xs text-muted-foreground">
{new Date(entry.deletedAt).toLocaleString()}
{new Date(entry.deletedAt).toLocaleString('en-US', { timeZone: 'UTC' })}
</p>
</div>
<Badge variant="secondary" className="capitalize">{entry.type}</Badge>
+12 -1
View File
@@ -96,6 +96,11 @@ async function proxy(request: NextRequest) {
}
const response = await backendFetch(request.nextUrl.pathname + request.nextUrl.search, {
method: request.method, headers, body,
// Go's slowest handlers (IMAP/signal checks) legitimately run ~45s;
// keep the proxy budget inside Go's 60s WriteTimeout while still
// bounding a blackholed connection. fast server-component calls keep
// backendFetch's 10s default.
signal: AbortSignal.timeout(55_000),
})
const outgoing = new Headers()
for (const name of ['content-type', 'content-disposition', 'set-cookie', 'x-next-cursor']) {
@@ -105,8 +110,14 @@ async function proxy(request: NextRequest) {
outgoing.set('Cache-Control', 'no-store')
return new Response(response.body, { status: response.status, headers: outgoing })
} catch (error) {
// Review M-7: a timed-out backend is a 504 so callers can distinguish it
// from an unreachable one (502).
const timedOut = (error as { status?: unknown })?.status === 504
console.error('Go API unavailable', error)
return Response.json({ error: 'API unavailable' }, { status: 502 })
return Response.json(
{ error: timedOut ? 'API timed out' : 'API unavailable' },
{ status: timedOut ? 504 : 502 },
)
}
}
export { proxy as GET, proxy as HEAD, proxy as POST, proxy as PUT, proxy as PATCH, proxy as DELETE }
-24
View File
@@ -1,24 +0,0 @@
export const dynamic = 'force-dynamic'
import { NextResponse } from 'next/server'
import { signIn } from '@/auth'
export async function POST(req: Request) {
const expectedOrigin = new URL(process.env.NEXTAUTH_URL || req.url).origin
if (req.headers.get('origin') !== expectedOrigin) {
return NextResponse.json({ error: 'Invalid request origin' }, { status: 403 })
}
if (req.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') {
return NextResponse.json({ error: 'Content-Type must be application/json' }, { status: 415 })
}
try {
const body = await req.json()
const result = await signIn('credentials', {
email: body.email,
password: body.password,
redirect: false,
})
return NextResponse.json({ ok: true, url: result })
} catch {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}
}
+4
View File
@@ -26,6 +26,10 @@ export function LoginClient({ oidcEnabled }: { oidcEnabled: boolean }) {
const result = await signIn('credentials', { email, password, redirect: false })
if (result?.ok) {
router.push('/pages')
} else if (result?.code === 'throttled') {
// Review M-8: Go's 429 is propagated as a CredentialsSignin code;
// do not present throttling as a wrong password.
toast.error('Too many attempts. Wait a few minutes and try again.')
} else {
toast.error('Invalid credentials')
}
+10 -2
View File
@@ -1,11 +1,19 @@
import { apiBase } from '@/lib/api'
import { apiBase, forwardedClientHeaders } from '@/lib/api'
import { headers } from 'next/headers'
import { notFound } from 'next/navigation'
import { PublicBookmarkView } from './client'
export default async function PublicSharePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const response = await fetch(`${apiBase()}/api/public/bookmarks/${encodeURIComponent(id)}`, { cache: 'no-store' })
// Review H-2: pass the attested browser identity through to Go. Without it
// every server-rendered share view shares the web container's public
// rate-limit bucket, so one hot link (or one client) could exhaust it.
const response = await fetch(`${apiBase()}/api/public/bookmarks/${encodeURIComponent(id)}`, {
cache: 'no-store',
headers: forwardedClientHeaders(await headers()),
signal: AbortSignal.timeout(10_000),
})
if (!response.ok) notFound()
const bookmark = await response.json()
+3 -1
View File
@@ -18,7 +18,9 @@ export function SignupClient() {
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault()
if (password.length < 6) { toast.error('Password must be at least 6 characters'); return }
// Review M-8: match the server's 8-character minimum (and the input's
// minLength). The old 6-character check let a doomed request through.
if (password.length < 8) { toast.error('Password must be at least 8 characters'); return }
setLoading(true)
try {
const res = await fetch('/api/signup', {
+9 -5
View File
@@ -1,4 +1,4 @@
import NextAuth from 'next-auth'
import NextAuth, { CredentialsSignin } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import jwt from 'jsonwebtoken'
import { createHmac } from 'node:crypto'
@@ -63,12 +63,16 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
body: JSON.stringify({ email: credentials.email, password: credentials.password }),
})
if (!response.ok) {
// Surface Go's 429 so callers can distinguish throttling from bad
// credentials (the custom login route maps everything else to 401).
// Review M-8: surface Go's 429 as a distinct CredentialsSignin code
// so the login UI can say "too many attempts" instead of "invalid
// credentials" or "check your connection". Auth.js carries `code`
// through to the client's signIn() result. The status field remains
// for server-side callers/tests.
if (response.status === 429) {
const retry = response.headers.get('retry-after')
const err = new Error(response.status === 429 ? 'Throttled' : 'Unauthorized') as Error & { status?: number; retryAfter?: string }
const err = new CredentialsSignin() as CredentialsSignin & { status?: number; retryAfter?: string }
err.code = 'throttled'
err.status = 429
const retry = response.headers.get('retry-after')
if (retry) err.retryAfter = retry
throw err
}
+4 -18
View File
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Link2, Loader2, Plus, X } from 'lucide-react'
import { Link2, Plus, X } from 'lucide-react'
import { toast } from 'sonner'
import { invalidateReference } from '@/lib/reference-data'
@@ -24,7 +24,6 @@ export function AddBookmarkDialog({ open, onClose, onCreated }: Props) {
const [dueDate, setDueDate] = useState('')
const [kanban, setKanban] = useState('INBOX')
const [loading, setLoading] = useState(false)
const [fetching, setFetching] = useState(false)
const [tags, setTags] = useState<{ id: string; name: string; color: string }[]>([])
const [allTags, setAllTags] = useState<any[]>([])
const [newTagName, setNewTagName] = useState('')
@@ -37,20 +36,9 @@ export function AddBookmarkDialog({ open, onClose, onCreated }: Props) {
}
}, [open])
const fetchPreview = useCallback(async () => {
if (!url) return
setFetching(true)
try {
const res = await fetch(`/api/preview?url=${encodeURIComponent(url)}`)
if (res.ok) {
const data = await res.json()
if (data?.ogTitle && !title) setTitle(data.ogTitle)
if (data?.ogDescription && !description) setDescription(data.ogDescription)
}
} catch { /* ignore */ }
setFetching(false)
}, [url, title, description])
// Review M-10: no client-side preview fetch. The server fetches preview
// metadata once during create (and fills title/description defaults), so a
// blur followed by Save no longer spends the preview budget twice.
const handleSubmit = useCallback(async () => {
if (!url) { toast.error('URL is required'); return }
setLoading(true)
@@ -111,9 +99,7 @@ export function AddBookmarkDialog({ open, onClose, onCreated }: Props) {
placeholder="https://..."
value={url}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUrl(e.target.value)}
onBlur={fetchPreview}
/>
{fetching && <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />}
</div>
</div>
<div>
+5 -6
View File
@@ -7,6 +7,7 @@ import { Sidebar } from '@/components/sidebar'
import { TopBar } from '@/components/top-bar'
import { AddBookmarkDialog } from '@/components/add-bookmark-dialog'
import { QuickFind } from '@/components/quick-find'
import { resetReferenceCache } from '@/lib/reference-data'
export type Bookmark = {
id: string
@@ -74,6 +75,8 @@ export function AppShell({ children }: { children: React.ReactNode }) {
try {
await fetch('/api/admin/view-as', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: null }) })
} catch { /* navigating to /admin surfaces any persistent failure */ }
// Review L-6: leaving view-as restores the admin identity.
resetReferenceCache()
window.location.href = '/admin'
}, [])
@@ -104,12 +107,8 @@ export function AppShell({ children }: { children: React.ReactNode }) {
return () => clearInterval(interval)
}, [session?.user])
// Request notification permission
useEffect(() => {
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
Notification.requestPermission()
}
}, [])
// Review L-5: notification permission is requested from the alert bell on
// an explicit user action (top-bar.tsx), not on every app mount.
const refreshPage = useCallback(() => {
window.dispatchEvent(new Event('computer:bookmarks-changed'))
+11 -4
View File
@@ -5,7 +5,7 @@
// (new page, add bookmark, toggle theme). Search is read-only; mutations
// stay on their owning screens.
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTheme } from 'next-themes'
import { toast } from 'sonner'
@@ -86,16 +86,23 @@ export function QuickFind({ open, onOpenChange, isAdmin = false, onAddBookmark }
}, [open])
// Bookmark search hits the API (debounced) because the server matches
// notes and URLs too, not just titles.
// notes and URLs too, not just titles. Review M-11: requests can still
// resolve out of order after the debounce fires, so only the newest
// sequence may publish results.
const searchSeq = useRef(0)
useEffect(() => {
if (!open) return
const q = query.trim()
const t = setTimeout(async () => {
if (q.length < 2) { setBookmarks([]); return }
if (q.length < 2) { searchSeq.current++; setBookmarks([]); return }
const seq = ++searchSeq.current
try {
const params = new URLSearchParams({ search: q, limit: '6' })
const res = await fetch(`/api/bookmarks?${params}`)
if (res.ok) setBookmarks(((await res.json()) as BookmarkHit[]).slice(0, 6))
if (!res.ok || seq !== searchSeq.current) return
const rows = (await res.json()) as BookmarkHit[]
if (seq !== searchSeq.current) return
setBookmarks(rows.slice(0, 6))
} catch { /* transient; retried on next keystroke */ }
}, q.length < 2 ? 0 : 200)
return () => clearTimeout(t)
+9 -3
View File
@@ -230,8 +230,10 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna
}
}
const dropPage = (target: PageSummary, after: boolean) => {
const source = draggedPageRef.current
// The source id is passed in from the drop handler (reading the ref inside
// an event handler) so the compiler does not treat this helper as reading a
// ref during render.
const dropPage = (source: string | null, target: PageSummary, after: boolean) => {
draggedPageRef.current = null
setPageDrop(null)
if (!source) return
@@ -310,6 +312,8 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna
key={p.id}
data-page-id={p.id}
onDragOver={(e) => {
// Event handler, not render: reading the drag source is safe.
// eslint-disable-next-line react-hooks/refs
if (!draggedPageRef.current || draggedPageRef.current === p.id) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
@@ -322,7 +326,9 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna
onDrop={(e) => {
e.preventDefault()
const rect = e.currentTarget.getBoundingClientRect()
dropPage(p, e.clientY > rect.top + rect.height / 2)
// Event handler, not render: reading the drag source is safe.
// eslint-disable-next-line react-hooks/refs
dropPage(draggedPageRef.current, p, e.clientY > rect.top + rect.height / 2)
}}
style={{ paddingLeft: depth * 12 }}
className={cn(
+27 -1
View File
@@ -18,6 +18,7 @@ import {
import { useTheme } from 'next-themes'
import { useMounted } from '@/components/client-only'
import { ACTIVE_PAGE_CHANGED } from '@/lib/pages'
import { resetReferenceCache } from '@/lib/reference-data'
// Notion keeps the top bar minimal: a breadcrumb path on the left, a subtle
// Quick Find pill, and ghost icon buttons on the right.
@@ -47,6 +48,25 @@ export function TopBar({ user, alerts, onDismissAlert, onAddBookmark, onToggleSi
const mounted = useMounted()
const { theme, setTheme } = useTheme()
const [activePageTitle, setActivePageTitle] = useState<string | null>(null)
// Review L-5: never prompt for notification permission on mount. Browsers
// increasingly ignore prompts without a user gesture, and the prompt was
// intrusive on every first load. The alert bell exposes an explicit action;
// permission is read when that panel opens (null until then, so the action
// never renders on the server or before the user interacts).
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission | 'unsupported' | null>(null)
const toggleAlerts = () => {
setShowAlerts(!showAlerts)
setNotificationPermission(typeof Notification === 'undefined' ? 'unsupported' : Notification.permission)
}
const enableNotifications = async () => {
if (typeof Notification === 'undefined') return
const permission = await Notification.requestPermission()
setNotificationPermission(permission)
if (permission === 'granted') toast.success('Desktop alerts enabled')
else if (permission === 'denied') toast.error('Desktop alerts are blocked in your browser settings')
}
// The pages workspace announces the open page so breadcrumbs can show it.
// The title only renders on /pages, so route changes need no reset effect.
@@ -88,6 +108,7 @@ export function TopBar({ user, alerts, onDismissAlert, onAddBookmark, onToggleSi
if (!revoked) {
toast.error('Signed out on this device, but global revocation failed — other sessions may remain signed in until the backend recovers.')
}
resetReferenceCache()
void signOut({ redirectTo: '/login' })
}
@@ -142,7 +163,7 @@ export function TopBar({ user, alerts, onDismissAlert, onAddBookmark, onToggleSi
</Button>
<div className="relative shrink-0" id="topbar-alerts">
<Button variant="ghost" size="icon-sm" onClick={() => setShowAlerts(!showAlerts)}>
<Button variant="ghost" size="icon-sm" onClick={toggleAlerts}>
<Bell className="h-4 w-4" />
{(alerts?.length ?? 0) > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[10px] text-destructive-foreground">
@@ -153,6 +174,11 @@ export function TopBar({ user, alerts, onDismissAlert, onAddBookmark, onToggleSi
{showAlerts && (
<div className="absolute right-0 top-full mt-2 w-80 rounded-lg border bg-card p-2 shadow-lg z-50">
<p className="px-2 py-1 text-xs font-medium text-muted-foreground">Alerts</p>
{notificationPermission === 'default' && (
<Button variant="outline" size="sm" className="mx-2 mb-1 w-[calc(100%-1rem)]" onClick={enableNotifications}>
Enable desktop alerts
</Button>
)}
{(alerts?.length ?? 0) === 0 && (
<p className="px-2 py-4 text-sm text-center text-muted-foreground">No pending alerts</p>
)}
-66
View File
@@ -1,66 +0,0 @@
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from './card'
import { Badge } from './badge'
import { Checkbox } from './checkbox'
import { Button } from './button'
import { Pencil, Trash2 } from 'lucide-react'
import { motion } from 'framer-motion'
interface TaskCardProps {
id: string
title: string
description?: string
category: string
completed: boolean
onComplete: (id: string, completed: boolean) => void
onDelete: (id: string) => void
onEdit: (id: string) => void
}
export function TaskCard({
id,
title,
description,
category,
completed,
onComplete,
onDelete,
onEdit,
}: TaskCardProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
<Card className={`w-full ${completed ? 'opacity-60' : ''}`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center space-x-2">
<Checkbox
checked={completed}
onCheckedChange={(checked) => onComplete(id, checked as boolean)}
/>
<CardTitle className={`text-lg ${completed ? 'line-through' : ''}`}>
{title}
</CardTitle>
</div>
<div className="flex space-x-2">
<Button variant="ghost" size="icon" onClick={() => onEdit(id)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => onDelete(id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
<Badge variant="secondary" className="mt-2">
{category}
</Badge>
</CardContent>
</Card>
</motion.div>
)
}
+27
View File
@@ -0,0 +1,27 @@
// Review H-1: the proxy-attested client IP is what gives every browser its
// own login/signup/public-endpoint rate-limit bucket. It only works when the
// Next server trusts the proxy that pins X-Real-IP. A TLS deployment without
// that switch silently puts every client in the web container's bucket, so
// warn loudly at startup instead of waiting for an outage caused by one
// caller exhausting the instance limit.
export async function register() {
// Runs for both the Node and Edge runtimes; env diagnostics only apply to
// the server process.
if (process.env.NEXT_RUNTIME !== 'nodejs') return
const url = process.env.NEXTAUTH_URL || ''
const trusted = process.env.TRUSTED_PROXY === '1'
if (url.startsWith('https://') && !trusted) {
console.warn(
'[config] NEXTAUTH_URL is HTTPS but TRUSTED_PROXY is not 1. All clients ' +
'share the web container rate-limit bucket; set TRUSTED_PROXY=1 when a ' +
'proxy in front of web overwrites X-Real-IP (see RUNNING.md).',
)
}
if (trusted && !url.startsWith('https://')) {
console.warn(
'[config] TRUSTED_PROXY=1 is set for a non-HTTPS NEXTAUTH_URL. Only do ' +
'this when the proxy is the sole route to this server; otherwise ' +
'clients can spoof X-Real-IP and choose their rate-limit identity.',
)
}
}
+29 -1
View File
@@ -24,6 +24,21 @@ export const signClientIP = (ip: string): { ts: string; sig: string } => {
return { ts, sig: createHmac('sha256', secret).update(`${ip}|${ts}`).digest('hex') }
}
// Review H-2: server-rendered pages that call Go directly (the public share
// page) must forward the same attested identity as the data proxy, otherwise
// every rendered share view lands in the web container's bucket. Returns an
// empty object when there is no trusted proxy or no usable header.
export const forwardedClientHeaders = (source: Headers): Record<string, string> => {
const ip = observedClientIP(source)
if (!ip) return {}
const { ts, sig } = signClientIP(ip)
return {
'x-computer-client-ip': ip,
'x-computer-client-ts': ts,
'x-computer-client-sig': sig,
}
}
// Only this trusted Next server signs assertions. The browser never chooses a
// user ID, and Go still re-reads roles/ownership on every request. The typ
// claim binds the token to the Bearer channel; Go rejects it as a cookie and
@@ -55,5 +70,18 @@ export async function backendFetch(path: string, init: RequestInit = {}) {
}
const viewAs = (await cookies()).get('computer_view_as')?.value
if (viewAs) headers.set('Cookie', `computer_view_as=${encodeURIComponent(viewAs)}`)
return fetch(`${apiBase()}${path}`, { ...init, headers, cache: 'no-store' })
// Review M-7: bound server-side calls so a hung or blackholed backend
// cannot stall a route/server component until the platform timeout.
// Callers may provide their own signal to override this default.
const signal = init.signal ?? AbortSignal.timeout(10_000)
try {
return await fetch(`${apiBase()}${path}`, { ...init, headers, cache: 'no-store', signal })
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
const timeout = new Error('Backend request timed out') as Error & { status?: number }
timeout.status = 504
throw timeout
}
throw error
}
}
+28 -1
View File
@@ -1,6 +1,33 @@
import { backendFetch } from '@/lib/api'
export const VIEW_AS_COOKIE = 'computer_view_as'
export type AuthUser = { id: string; name?: string | null; email?: string | null; image?: string | null; role: 'USER' | 'ADMIN'; actingAdminId?: string }
async function account() { const res = await backendFetch('/api/me'); return res.ok ? res.json() : null }
// Review M-6: Go distinguishes "no session" (401) from backend degradation
// (503). Collapsing both to null sent an authenticated admin to /login during
// a database outage, as if their credentials had failed. Only 401 means
// signed out; anything else is an outage the UI must show as such.
export class BackendUnavailableError extends Error {
status: number
constructor(status: number) {
super('Backend unavailable')
this.name = 'BackendUnavailableError'
this.status = status
}
}
async function account() {
let res: Response
try {
res = await backendFetch('/api/me')
} catch (error) {
const status = (error as { status?: unknown })?.status
throw new BackendUnavailableError(typeof status === 'number' ? status : 502)
}
if (res.status === 401) return null
if (!res.ok) throw new BackendUnavailableError(res.status)
return res.json()
}
export async function getRealUser(): Promise<AuthUser | null> { return (await account())?.user ?? null }
export async function getAuthUser(): Promise<AuthUser | null> { const me = await account(); return me ? { ...me.effectiveUser, ...(me.viewingAs ? { actingAdminId: me.user.id } : {}) } : null }
+7
View File
@@ -19,6 +19,13 @@ export function invalidateReference(kind: ReferenceKind) {
cache.delete(kind)
}
// Review L-6: the cache is module-global, not scoped to the authenticated
// identity. An in-tab identity change (sign-out, view-as enter/exit) must not
// serve the previous user's me/tags/contacts for up to 30 seconds.
export function resetReferenceCache() {
cache.clear()
}
// Resolves to null on any failure; failures are not kept cached.
export function fetchReference<T = unknown>(kind: ReferenceKind): Promise<T | null> {
const hit = cache.get(kind)
-28
View File
@@ -1,28 +0,0 @@
export type Expense = {
id: string
amount: number
category: string
description: string
date: Date
}
export type ExpenseFormData = Omit<Expense, 'id' | 'date'> & {
date: string
}
export const EXPENSE_CATEGORIES = [
'Food',
'Transportation',
'Housing',
'Utilities',
'Entertainment',
'Healthcare',
'Shopping',
'Education',
'Other'
] as const
export type DateRange = {
from: Date | undefined
to: Date | undefined
}
-8
View File
@@ -4,11 +4,3 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const remainingSeconds = seconds % 60
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
}