release gate / check (push) Failing after 6m36s
Implement Notion-like upgrade phase 4: append-only page revisions with restore-as-a-new-version; recoverable bookmark/page/collection deletions whose restores never resurrect public flags or shares; owner-scoped JSON export; and explicit, revocable read-only collection sharing with owner-only mutations and no transitive third-party bookmark leaks. Add migration 009, API/docs, integration coverage, and page/collections/trash UI.
447 lines
16 KiB
Go
447 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
|
|
}
|
|
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 := ids(b, "userIds")
|
|
if e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
if e = boolFields(b, "isPublic"); e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
|
|
if e := shareRows(r, tx, "BookmarkShare", "bookmarkId", id, list); e != nil {
|
|
return e
|
|
}
|
|
if _, ok := b["isPublic"]; ok {
|
|
_, e := db.Update(r.Context(), tx, "Bookmark", id, "ownerId", user(r), object{"isPublic": b["isPublic"], "updatedAt": time.Now().UTC()})
|
|
return e
|
|
}
|
|
return nil
|
|
})
|
|
respond(w, 200, OK{true}, e)
|
|
}
|
|
func HandleBookmarksMarkdown(w http.ResponseWriter, r *http.Request) {
|
|
// bookmark() already applies the permission-specific projection (review
|
|
// PRIV-02), so shared readers export only fields they may read.
|
|
v, e := bookmark(r, r.PathValue("id"), false)
|
|
if e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
markdown.Send(w, markdown.BookmarkDocument(v), str(v, "title"))
|
|
}
|
|
|
|
// previewKey is the shared-budget identity for preview work (review CR-06):
|
|
// per user, across every entry point (route + mutations).
|
|
func previewKey(r *http.Request) string { return "user:" + user(r) }
|
|
|
|
func HandlePreview(w http.ResponseWriter, r *http.Request) {
|
|
u := r.URL.Query().Get("url")
|
|
if !validURL(u) {
|
|
respond(w, 0, nil, bad("A valid HTTP(S) URL is required"))
|
|
return
|
|
}
|
|
respond(w, 200, preview.FetchPreview(r.Context(), previewKey(r), u), nil)
|
|
}
|