Files
brain/internal/handlers/bookmarks.go
T
Andrew S Erwin 4e421f6ea2
release gate / check (push) Failing after 6m43s
Address code review findings across API, web and deployment
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.
2026-10-20 00:13:30 -05:00

455 lines
16 KiB
Go

package handlers
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/markdown"
"github.com/andrew/go-computer/internal/preview"
"net/http"
"strconv"
"strings"
"time"
)
func bookmark(r *http.Request, id string, public bool) (object, error) {
if public {
// Review PRIV-04: the public projection is an explicit allowlist and
// carries NO owner attribution — the README promises owner identity
// stays private, and API, share page, and docs must agree. Notes and
// dueDate ARE published by product decision; the share panel states
// exactly what a public link exposes before the owner toggles it.
return db.One(r.Context(), database(r), `SELECT jsonb_build_object(
'id',b.id,'url',b.url,'title',b.title,'description',b.description,'notes',b.notes,
'favicon',b.favicon,'ogImage',b."ogImage",'ogTitle',b."ogTitle",'ogDescription',b."ogDescription",
'dueDate',b."dueDate",'isPublic',b."isPublic",
'tags',COALESCE((SELECT jsonb_agg(jsonb_build_object('tag',jsonb_build_object('id',t.id,'name',t.name,'color',t.color))) FROM "BookmarkTag" bt JOIN "Tag" t ON t.id=bt."tagId" WHERE bt."bookmarkId"=b.id),'[]'::jsonb))
FROM "Bookmark" b WHERE b.id=$1 AND b."isPublic"`, id)
}
// Review PRIV-02: shared readers get the narrowed allowlist projection;
// only the owner receives the full row with contact and recipient list.
where := `b.id=$1 AND ` + db.BookmarkVisible
uid := user(r)
return db.One(r.Context(), database(r), `SELECT `+db.BookmarkViewerView+` || jsonb_build_object('pageLinks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('page',jsonb_build_object('id',p.id,'title',p.title))) FROM "PageBookmark" pb JOIN "Page" p ON p.id=pb."pageId" WHERE pb."bookmarkId"=b.id AND p."ownerId"=$2),'[]'::jsonb)) FROM "Bookmark" b WHERE `+where, id, uid)
}
func HandleBookmarksList(w http.ResponseWriter, r *http.Request) {
args := []any{nil, user(r)}
sp := r.URL.Query()
limit := 200
if raw := sp.Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > 200 {
respond(w, 0, nil, bad("Limit must be between 1 and 200"))
return
}
limit = n
}
where := `b."ownerId"=$2`
if sp.Get("shared") == "true" {
where = db.BookmarkVisible + ` AND b."ownerId"<>$2`
}
add := func(s string, v any) { args = append(args, v); where += " AND " + fmt.Sprintf(s, len(args)) }
if s := sp.Get("search"); s != "" {
// Escape LIKE wildcards (review PERF-2): an unescaped query pattern
// matched everything and defeated the search's selectivity.
args = append(args, "%"+likeEscaper.Replace(s)+"%")
n := len(args)
where += fmt.Sprintf(` AND (b.title ILIKE $%d ESCAPE '\' OR b.url ILIKE $%d ESCAPE '\' OR b.notes ILIKE $%d ESCAPE '\' OR EXISTS(SELECT 1 FROM "BookmarkTag" bt JOIN "Tag" t ON t.id=bt."tagId" WHERE bt."bookmarkId"=b.id AND t.name ILIKE $%d ESCAPE '\'))`, n, n, n, n)
}
if s := sp.Get("tagId"); s != "" {
add(`EXISTS(SELECT 1 FROM "BookmarkTag" bt WHERE bt."bookmarkId"=b.id AND bt."tagId"=$%d)`, s)
if sp.Get("shared") != "true" {
where = strings.Replace(where, `b."ownerId"=$2`, db.BookmarkVisible, 1)
}
}
if s := sp.Get("contactId"); s != "" {
add(`b."contactId"=$%d`, s)
}
if s := sp.Get("kanban"); s != "" {
if !validStatus(s) {
respond(w, 0, nil, bad("Invalid status"))
return
}
add(`b."kanbanStatus"=$%d`, s)
}
if s := sp.Get("date"); s != "" {
v, e := dateValue(s)
if e != nil {
respond(w, 0, nil, e)
return
}
// Pin both sides to UTC: bare ::date casts use the Postgres session
// timezone, which is not pinned and would shift "due on date X".
add(`(b."dueDate" AT TIME ZONE 'UTC')::date=($%d::timestamptz AT TIME ZONE 'UTC')::date`, v)
// Guarded exactly like tagId (review COR-7): re-injecting the
// visibility predicate into a list already widened by it would nest
// BookmarkVisible inside itself.
if sp.Get("shared") != "true" && !strings.Contains(where, db.BookmarkVisible) {
where = strings.Replace(where, `b."ownerId"=$2`, db.BookmarkVisible, 1)
}
}
// Port artifact: db.BookmarkVisible binds the user as $2, so $1 stays a
// permanently-nil placeholder and this predicate is always true. Removing
// it would require renumbering BookmarkVisible's parameters everywhere.
where += ` AND $1::text IS NULL`
if raw := sp.Get("cursor"); raw != "" {
var cursor struct {
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
}
data, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil || json.Unmarshal(data, &cursor) != nil || cursor.UpdatedAt.IsZero() || cursor.ID == "" {
respond(w, 0, nil, bad("Invalid cursor"))
return
}
args = append(args, cursor.UpdatedAt, cursor.ID)
where += fmt.Sprintf(` AND (b."updatedAt",b.id)<($%d,$%d)`, len(args)-1, len(args))
}
args = append(args, limit+1)
v, e := db.Many(r.Context(), database(r), `SELECT `+db.BookmarkViewerView+` FROM "Bookmark" b WHERE `+where+fmt.Sprintf(` ORDER BY b."updatedAt" DESC,b.id DESC LIMIT $%d`, len(args)), args...)
if e == nil && len(v) > limit {
v = v[:limit]
last := v[len(v)-1]
cursor, _ := json.Marshal(object{"updatedAt": last["updatedAt"], "id": last["id"]})
w.Header().Set("X-Next-Cursor", base64.RawURLEncoding.EncodeToString(cursor))
}
respond(w, 200, v, e)
}
func validStatus(s string) bool {
return s == "INBOX" || s == "TODO" || s == "IN_PROGRESS" || s == "DONE" || s == "ARCHIVED"
}
func bookmarkFields(b object) (object, error) {
d := fields(b, "url title description notes dueDate alertAt kanbanStatus kanbanColumnId contactId isPublic imapWatchEnabled imapQuery")
if e := stringFields(d, "title description notes kanbanColumnId contactId imapQuery", "url kanbanStatus"); e != nil {
return nil, e
}
for _, l := range []struct {
key string
max int
}{
{"url", maxBookmarkURLLen},
{"title", maxBookmarkTitleLen},
{"description", maxBookmarkDescriptionLen},
{"notes", maxBookmarkNotesLen},
{"imapQuery", maxBookmarkQueryLen},
} {
if e := limitText(d, l.key, l.max); e != nil {
return nil, e
}
}
if e := boolFields(d, "isPublic imapWatchEnabled"); e != nil {
return nil, e
}
if v, ok := d["url"]; ok && !validURL(v.(string)) {
return nil, bad("A valid HTTP(S) URL is required")
}
if s, ok := d["kanbanStatus"]; ok && !validStatus(s.(string)) {
return nil, bad("Invalid status")
}
for _, k := range []string{"dueDate", "alertAt"} {
if v, ok := d[k]; ok {
v, e := dateValue(v)
if e != nil {
return nil, e
}
d[k] = v
if k == "alertAt" {
d["alertSent"] = false
}
}
}
for _, k := range []string{"contactId", "kanbanColumnId"} {
if d[k] == "" {
d[k] = nil
}
}
return d, nil
}
func checkRelations(r *http.Request, tx *sql.Tx, d object, tags []string) error {
for k, table := range map[string]string{"contactId": "Contact", "kanbanColumnId": "KanbanColumn"} {
if v, ok := d[k]; ok && v != nil {
if _, e := owned(r, tx, table, v.(string), "userId"); e != nil {
return bad("Invalid " + k)
}
}
}
// A tag shared WITH you may be attached to your own bookmarks. Note the
// group semantics this implies (by design, see db.BookmarkVisible): the
// bookmark then becomes visible to every recipient of that tag.
for _, tag := range tags {
if _, e := db.One(r.Context(), tx, `SELECT to_jsonb(t) FROM "Tag" t WHERE id=$1 AND ("ownerId"=$2 OR EXISTS(SELECT 1 FROM "TagShare" s WHERE s."tagId"=t.id AND s."userId"=$2))`, tag, user(r)); e != nil {
return bad("Invalid tag")
}
}
return nil
}
func setTags(r *http.Request, tx *sql.Tx, id string, tags []string) error {
if _, e := tx.ExecContext(r.Context(), `DELETE FROM "BookmarkTag" WHERE "bookmarkId"=$1`, id); e != nil {
return e
}
for _, t := range tags {
if _, e := tx.ExecContext(r.Context(), `INSERT INTO "BookmarkTag"("bookmarkId","tagId") VALUES($1,$2)`, id, t); e != nil {
return e
}
}
return nil
}
// addPreview fetches og/favicon metadata for the bookmark's URL through the
// shared preview budget (review CR-06): bookmark mutations and the dedicated
// preview route draw from the SAME per-identity rate and global concurrency
// limits, so saves cannot bypass the route's throttle. Preview work is
// decorative — when the budget is exhausted the bookmark still persists,
// just without fresh og metadata.
func addPreview(ctx context.Context, key string, d object, create bool) {
p := preview.FetchPreview(ctx, key, str(d, "url"))
d["favicon"], d["ogImage"], d["ogTitle"], d["ogDescription"] = p.Favicon, p.OgImage, p.OgTitle, p.OgDescription
if create {
if d["title"] == nil {
if p.OgTitle != nil {
d["title"] = *p.OgTitle
} else {
d["title"] = d["url"]
}
}
if d["description"] == nil {
d["description"] = p.OgDescription
}
}
}
func HandleBookmarksCreate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
d, e := bookmarkFields(b)
if e != nil {
respond(w, 0, nil, e)
return
}
if str(d, "url") == "" {
respond(w, 0, nil, bad("URL required"))
return
}
tags, e := ids(b, "tagIds")
if e != nil {
respond(w, 0, nil, e)
return
}
id := db.NewID()
d["id"], d["ownerId"] = id, user(r)
addPreview(r.Context(), previewKey(r), d, true)
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
// One locking protocol for board mutations (review CR-05): column
// deletion holds lockUser while it snapshots the affected bookmarks,
// moves them and drops the column. Bookmark writes that validate and
// assign kanbanColumnId take the SAME lock first, so a concurrent
// create can no longer land on a column the deletion already counted
// as moved (or escape its move set). Lock order is always user row →
// bookmarks/columns, matching every other lockUser transaction.
if e := lockUser(r, tx); e != nil {
return e
}
if e := checkRelations(r, tx, d, tags); e != nil {
return e
}
row, e := db.Insert(r.Context(), tx, "Bookmark", d)
if e != nil {
return e
}
if e = syncLegacyWatch(r, tx, row); e != nil {
return e
}
return setTags(r, tx, id, tags)
})
if e != nil {
respond(w, 0, nil, e)
return
}
v, e := bookmark(r, id, false)
respond(w, 201, v, e)
}
func HandleBookmarksGet(w http.ResponseWriter, r *http.Request) {
v, e := bookmark(r, r.PathValue("id"), false)
respond(w, 200, v, e)
}
func HandleBookmarksUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
existing, e := owned(r, database(r), "Bookmark", id, "ownerId")
if e != nil {
respond(w, 0, nil, e)
return
}
b, ok := read(w, r)
if !ok {
return
}
d, e := bookmarkFields(b)
if e != nil {
respond(w, 0, nil, e)
return
}
// Optimistic concurrency (review CR-04): the PUT is the owner-driven
// mutation surface (fields AND tag replacement), and concurrency is not
// optional on it — the same DATA-05 rule pages enforce. The client sends
// the version it edited from; a stale base is a 409 and the row is left
// untouched, so overlapping edits can no longer silently overwrite each
// other. Sharing (POST /share) and server-driven flags (alertSent) are
// deliberately outside this contract; the kanban column-deletion move
// bumps the version itself.
baseVersion, hasVersion := 0, false
if v, present := b["version"]; present {
f, isNum := v.(float64)
if !isNum || f < 0 || f != float64(int64(f)) {
respond(w, 0, nil, bad("version must be a non-negative integer"))
return
}
baseVersion, hasVersion = int(f), true
}
if !hasVersion {
respond(w, 0, nil, bad("version is required when updating a bookmark; refetch it and retry"))
return
}
tags, e := ids(b, "tagIds")
if e != nil {
respond(w, 0, nil, e)
return
}
// 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)
}
d["updatedAt"] = time.Now().UTC()
d["version"] = baseVersion + 1
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
// Review CR-05: same board-mutation locking protocol as create and
// column deletion — placement is validated (checkRelations) and
// written under the user lock, so a column deletion can neither race
// this assignment nor move a bookmark this write just placed.
if e := lockUser(r, tx); e != nil {
return e
}
// Re-read under the lock: the version check must see the latest
// committed state, not the pre-transaction snapshot.
cur, e := owned(r, tx, "Bookmark", id, "ownerId")
if e != nil {
return e
}
if stored, _ := cur["version"].(float64); int(stored) != baseVersion {
return conflict("Bookmark was modified elsewhere; reload to see the latest version")
}
if e := checkRelations(r, tx, d, tags); e != nil {
return e
}
row, e := db.Update(r.Context(), tx, "Bookmark", id, "ownerId", user(r), d)
if e != nil {
return e
}
if e = syncLegacyWatch(r, tx, row); e != nil {
return e
}
if _, ok := b["tagIds"]; ok {
return setTags(r, tx, id, tags)
}
return nil
})
if e != nil {
respond(w, 0, nil, e)
return
}
v, e := bookmark(r, id, false)
respond(w, 200, v, e)
}
func HandleBookmarksDelete(w http.ResponseWriter, r *http.Request) {
// Recoverable delete (trust phase): snapshot intrinsic fields and
// owner-scoped links in the same transaction as the hard delete. Shares
// and the public flag are deliberately not restored.
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
return trashBookmark(r, tx, r.PathValue("id"))
})
respond(w, 200, OK{true}, e)
}
// The original mailbox-watch switch now drives an actual email signal source.
// Modern watchers added in the Signals panel are managed independently.
func syncLegacyWatch(r *http.Request, tx *sql.Tx, b object) error {
enabled, _ := b["imapWatchEnabled"].(bool)
raw, _ := json.Marshal(object{"subject": str(b, "imapQuery"), "legacy": true})
res, e := tx.ExecContext(r.Context(), `UPDATE "SignalSource" SET config=$1::json,enabled=$2,"updatedAt"=now() WHERE "bookmarkId"=$3 AND "ownerId"=$4 AND config->>'legacy'='true'`, string(raw), enabled, b["id"], user(r))
if e != nil {
return e
}
n, _ := res.RowsAffected()
if n == 0 && enabled {
_, e = db.Insert(r.Context(), tx, "SignalSource", object{"id": db.NewID(), "type": "email", "config": string(raw), "enabled": true, "bookmarkId": b["id"], "ownerId": user(r)})
}
return e
}
func HandleBookmarksShare(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, e := owned(r, database(r), "Bookmark", id, "ownerId"); e != nil {
respond(w, 0, nil, e)
return
}
b, ok := read(w, r)
if !ok {
return
}
list, e := cappedIDs(b, "userIds", maxShareRecipients)
if e != nil {
respond(w, 0, nil, e)
return
}
if e = boolFields(b, "isPublic"); e != nil {
respond(w, 0, nil, e)
return
}
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := shareRows(r, tx, "BookmarkShare", "bookmarkId", id, list); e != nil {
return e
}
if _, ok := b["isPublic"]; ok {
_, e := db.Update(r.Context(), tx, "Bookmark", id, "ownerId", user(r), object{"isPublic": b["isPublic"], "updatedAt": time.Now().UTC()})
return e
}
return nil
})
respond(w, 200, OK{true}, e)
}
func HandleBookmarksMarkdown(w http.ResponseWriter, r *http.Request) {
// bookmark() already applies the permission-specific projection (review
// PRIV-02), so shared readers export only fields they may read.
v, e := bookmark(r, r.PathValue("id"), false)
if e != nil {
respond(w, 0, nil, e)
return
}
markdown.Send(w, markdown.BookmarkDocument(v), str(v, "title"))
}
// previewKey is the shared-budget identity for preview work (review CR-06):
// per user, across every entry point (route + mutations).
func previewKey(r *http.Request) string { return "user:" + user(r) }
func HandlePreview(w http.ResponseWriter, r *http.Request) {
u := r.URL.Query().Get("url")
if !validURL(u) {
respond(w, 0, nil, bad("A valid HTTP(S) URL is required"))
return
}
respond(w, 200, preview.FetchPreview(r.Context(), previewKey(r), u), nil)
}