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.
327 lines
13 KiB
Go
327 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"github.com/andrew/go-computer/internal/db"
|
|
"github.com/andrew/go-computer/internal/markdown"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// pageView is the canonical page projection over any queryer. HandlePagesUpdate
|
|
// reads it INSIDE the write transaction (review CR-18): a post-commit query
|
|
// could observe another writer's committed revision, and the acknowledgement
|
|
// would then describe state this write did not produce — a client adopting it
|
|
// could overwrite the other writer on its next save.
|
|
func pageView(ctx context.Context, q db.Queryer, id, uid string) (object, error) {
|
|
// Linked bookmarks use the viewer projection (review PRIV-02): a page can
|
|
// link bookmarks shared with its owner, and those arrive narrowed.
|
|
//
|
|
// Connected pages (phase 3): backlinks and embeds are owner-scoped in the
|
|
// query itself, not trusted from the caller. `links` is the resolved
|
|
// outgoing edge set (useful for navigation); the Markdown remains the
|
|
// source of truth and is rebuilt by syncPageGraph on every content save.
|
|
return db.One(ctx, q, `SELECT to_jsonb(p)||jsonb_build_object(
|
|
'bookmarks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('bookmark',`+db.BookmarkViewerView+`) ORDER BY pb.position) FROM "PageBookmark" pb JOIN "Bookmark" b ON b.id=pb."bookmarkId" WHERE pb."pageId"=p.id AND `+db.BookmarkVisible+`),'[]'::jsonb),
|
|
'backlinks',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',s.id,'title',s.title,'updatedAt',s."updatedAt") ORDER BY s."updatedAt" DESC,s.id) FROM "PageLink" pl JOIN "Page" s ON s.id=pl."sourcePageId" WHERE pl."targetPageId"=p.id AND s."ownerId"=p."ownerId"),'[]'::jsonb),
|
|
'links',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',t.id,'title',t.title) ORDER BY t.title,t.id) FROM "PageLink" pl JOIN "Page" t ON t.id=pl."targetPageId" WHERE pl."sourcePageId"=p.id AND t."ownerId"=p."ownerId"),'[]'::jsonb),
|
|
'embeds',COALESCE((SELECT jsonb_agg(jsonb_build_object(
|
|
'collectionId',e."collectionId",'viewId',e."viewId",'position',e.position,
|
|
'collection',to_jsonb(c)||jsonb_build_object(
|
|
'properties',COALESCE((SELECT jsonb_agg(to_jsonb(pr) ORDER BY pr.position,pr.id) FROM "CollectionProperty" pr WHERE pr."collectionId"=c.id),'[]'::jsonb),
|
|
'views',COALESCE((SELECT jsonb_agg(to_jsonb(v) ORDER BY v.position,v.id) FROM "CollectionView" v WHERE v."collectionId"=c.id),'[]'::jsonb)),
|
|
'view',(SELECT to_jsonb(v) FROM "CollectionView" v WHERE v.id=e."viewId"))
|
|
ORDER BY e.position,e."collectionId") FROM "PageEmbed" e JOIN "Collection" c ON c.id=e."collectionId" WHERE e."pageId"=p.id AND c."ownerId"=p."ownerId"),'[]'::jsonb)
|
|
) FROM "Page" p WHERE p.id=$1 AND p."ownerId"=$2`, id, uid)
|
|
}
|
|
func page(r *http.Request, id string) (object, error) {
|
|
return pageView(r.Context(), database(r), id, user(r))
|
|
}
|
|
func HandlePagesList(w http.ResponseWriter, r *http.Request) {
|
|
// Review PERF-01: the list is a metadata projection — content bodies stay
|
|
// out of it (the sidebar/tab strip only render titles) and are fetched on
|
|
// demand via GET /api/pages/{id}. Search still matches content server-side.
|
|
// Escape LIKE wildcards (review PERF-2): "%" and "_" in a search are
|
|
// literal characters, not patterns. The pg_trgm indexes on title/content
|
|
// let Postgres use the same ILIKE predicate without scanning every page.
|
|
search := "%" + likeEscaper.Replace(r.URL.Query().Get("search")) + "%"
|
|
v, e := db.Many(r.Context(), database(r), `SELECT (to_jsonb(p)-'content')||jsonb_build_object('_count',jsonb_build_object(
|
|
'bookmarks',(SELECT count(*) FROM "PageBookmark" pb WHERE pb."pageId"=p.id),
|
|
'children',(SELECT count(*) FROM "Page" ch WHERE ch."parentId"=p.id AND ch."ownerId"=p."ownerId"))) FROM "Page" p WHERE "ownerId"=$1 AND (title ILIKE $2 ESCAPE '\' OR content ILIKE $2 ESCAPE '\') ORDER BY pinned DESC,position,"createdAt"`, user(r), search)
|
|
respond(w, 200, v, e)
|
|
}
|
|
func pageData(b object) (object, error) {
|
|
d := fields(b, "title content icon pinned parentId")
|
|
// parentId is nullable: an explicit null promotes the page to the root.
|
|
if e := stringFields(d, "icon parentId", "title content"); e != nil {
|
|
return nil, e
|
|
}
|
|
if e := boolFields(d, "pinned"); e != nil {
|
|
return nil, e
|
|
}
|
|
if e := limitText(d, "content", maxPageContentLen); e != nil {
|
|
return nil, e
|
|
}
|
|
if e := limitText(d, "icon", maxPageIconLen); e != nil {
|
|
return nil, e
|
|
}
|
|
if _, ok := d["title"]; ok {
|
|
s := strings.TrimSpace(str(d, "title"))
|
|
if s == "" {
|
|
s = "Untitled page"
|
|
}
|
|
rs := []rune(s)
|
|
if len(rs) > 120 {
|
|
rs = rs[:120]
|
|
}
|
|
d["title"] = string(rs)
|
|
}
|
|
return d, nil
|
|
}
|
|
func HandlePagesCreate(w http.ResponseWriter, r *http.Request) {
|
|
b, ok := read(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
d, e := pageData(b)
|
|
if e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
if _, ok := d["title"]; !ok {
|
|
d["title"] = "Untitled page"
|
|
}
|
|
d["id"], d["ownerId"] = db.NewID(), user(r)
|
|
var v object
|
|
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
|
|
if e := lockUser(r, tx); e != nil {
|
|
return e
|
|
}
|
|
if raw, ok := d["parentId"]; ok {
|
|
parent, e := pageParent(r.Context(), tx, d["id"].(string), user(r), raw)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
d["parentId"] = parent
|
|
}
|
|
var n int
|
|
if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "Page" WHERE "ownerId"=$1`, user(r)).Scan(&n); e != nil {
|
|
return e
|
|
}
|
|
d["position"] = n
|
|
var e error
|
|
v, e = db.Insert(r.Context(), tx, "Page", d)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if content, ok := d["content"].(string); ok {
|
|
if e := syncPageGraph(r.Context(), tx, d["id"].(string), user(r), content); e != nil {
|
|
return e
|
|
}
|
|
}
|
|
if e := capturePageRevision(r, tx, d["id"].(string)); e != nil {
|
|
return e
|
|
}
|
|
// Acknowledge the same snapshot the write produced (the update path's
|
|
// CR-18 discipline), so the 201 already carries links/embeds.
|
|
v, e = pageView(r.Context(), tx, d["id"].(string), user(r))
|
|
return e
|
|
})
|
|
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 {
|
|
return
|
|
}
|
|
list, e := ids(b, "order")
|
|
if e == nil {
|
|
e = reorderPages(r, list)
|
|
}
|
|
respond(w, 200, OK{true}, e)
|
|
}
|
|
func HandlePagesGet(w http.ResponseWriter, r *http.Request) {
|
|
v, e := page(r, r.PathValue("id"))
|
|
respond(w, 200, v, e)
|
|
}
|
|
func HandlePagesUpdate(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
b, ok := read(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
d, e := pageData(b)
|
|
if e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
// Optimistic concurrency (review DATA-01): a client that sends the
|
|
// version it edited from gets a 409 instead of silently overwriting a
|
|
// newer save from another tab or device. The revision counter tracks
|
|
// content edits — metadata patches (title/pin/links) neither require nor
|
|
// bump it, so they cannot invalidate an editor's in-flight draft.
|
|
baseVersion, hasVersion := 0, false
|
|
if v, ok := b["version"]; ok {
|
|
f, isNum := v.(float64)
|
|
if !isNum || f < 0 || f != float64(int64(f)) {
|
|
respond(w, 0, nil, bad("version must be a non-negative integer"))
|
|
return
|
|
}
|
|
baseVersion, hasVersion = int(f), true
|
|
}
|
|
// Review DATA-05: concurrency must not be optional at the API boundary.
|
|
// A content write without a base version is rejected outright — otherwise
|
|
// any script or stale client could silently clobber newer content by
|
|
// omitting the field. Explicit overwrites re-fetch the latest version and
|
|
// send it (web `resolveOverwrite`); they never bypass this check.
|
|
if _, hasContent := d["content"]; hasContent && !hasVersion {
|
|
respond(w, 0, nil, bad("version is required when saving content; refetch the page and retry"))
|
|
return
|
|
}
|
|
d["updatedAt"] = time.Now().UTC()
|
|
// Review CR-18: the response snapshot is captured inside the same
|
|
// transaction as the write, so the acknowledged revision always
|
|
// describes exactly the state this request produced — never a later
|
|
// commit by a competing writer.
|
|
var v object
|
|
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
|
|
// Same locking discipline as page creation/reorder: serializes
|
|
// max(position)+1 so concurrent link adds cannot duplicate positions.
|
|
if e := lockUser(r, tx); e != nil {
|
|
return e
|
|
}
|
|
current, e := owned(r, tx, "Page", id, "ownerId")
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if hasVersion {
|
|
stored, _ := current["version"].(float64)
|
|
if int(stored) != baseVersion {
|
|
return conflict("Page was modified elsewhere; reload to see the latest version")
|
|
}
|
|
}
|
|
if raw, ok := d["parentId"]; ok {
|
|
parent, e := pageParent(r.Context(), tx, id, user(r), raw)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
d["parentId"] = parent
|
|
}
|
|
if _, e := db.Update(r.Context(), tx, "Page", id, "ownerId", user(r), d); e != nil {
|
|
return e
|
|
}
|
|
if content, ok := d["content"].(string); ok {
|
|
if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET version=version+1 WHERE id=$1`, id); e != nil {
|
|
return e
|
|
}
|
|
// Rebuild resolved links/embeds in the same transaction as the
|
|
// Markdown they were derived from.
|
|
if e := syncPageGraph(r.Context(), tx, id, user(r), content); e != nil {
|
|
return e
|
|
}
|
|
if e := capturePageRevision(r, tx, id); e != nil {
|
|
return e
|
|
}
|
|
}
|
|
if bid := str(b, "addBookmarkId"); bid != "" {
|
|
if _, e := db.One(r.Context(), tx, `SELECT to_jsonb(b) FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible, bid, user(r)); e != nil {
|
|
return bad("Bookmark is not accessible")
|
|
}
|
|
_, e := tx.ExecContext(r.Context(), `INSERT INTO "PageBookmark"("pageId","bookmarkId",position) SELECT $1,$2,COALESCE(max(position),-1)+1 FROM "PageBookmark" WHERE "pageId"=$1 ON CONFLICT DO NOTHING`, id, bid)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
}
|
|
if bid := str(b, "removeBookmarkId"); bid != "" {
|
|
_, e := tx.ExecContext(r.Context(), `DELETE FROM "PageBookmark" WHERE "pageId"=$1 AND "bookmarkId"=$2`, id, bid)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
}
|
|
v, e = pageView(r.Context(), tx, id, user(r))
|
|
return e
|
|
})
|
|
respond(w, 200, v, e)
|
|
}
|
|
|
|
// HandlePagesBacklinks returns the pages that link to this one. It is a
|
|
// separate read surface for navigation; the same owner-scoped list is
|
|
// embedded in pageView for the editor.
|
|
func HandlePagesBacklinks(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if _, e := owned(r, database(r), "Page", id, "ownerId"); e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object('id',s.id,'title',s.title,'updatedAt',s."updatedAt") FROM "PageLink" pl JOIN "Page" s ON s.id=pl."sourcePageId" WHERE pl."targetPageId"=$1 AND s."ownerId"=$2 ORDER BY s."updatedAt" DESC,s.id`, id, user(r))
|
|
respond(w, 200, v, e)
|
|
}
|
|
func HandlePagesDelete(w http.ResponseWriter, r *http.Request) {
|
|
// Children are promoted to the top level by the self-FK's SET NULL; a
|
|
// delete never silently takes nested pages with it. The page, its history
|
|
// and its bookmark links are snapshotted for restore.
|
|
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
|
|
if e := lockUser(r, tx); e != nil {
|
|
return e
|
|
}
|
|
return trashPage(r, tx, r.PathValue("id"))
|
|
})
|
|
respond(w, 200, OK{true}, e)
|
|
}
|
|
func HandlePagesMarkdown(w http.ResponseWriter, r *http.Request) {
|
|
v, e := page(r, r.PathValue("id"))
|
|
if e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
markdown.Send(w, markdown.PageDocument(v), str(v, "title"))
|
|
}
|