initial commit on Federted servers
release gate / check (push) Failing after 8m35s

This commit is contained in:
Andrew S Erwin
2026-10-19 16:23:55 -05:00
commit b9721ff13f
217 changed files with 31534 additions and 0 deletions
+438
View File
@@ -0,0 +1,438 @@
package handlers
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/markdown"
"github.com/andrew/go-computer/internal/preview"
"net/http"
"strconv"
"strings"
"time"
)
func bookmark(r *http.Request, id string, public bool) (object, error) {
if public {
// Review PRIV-04: the public projection is an explicit allowlist and
// carries NO owner attribution — the README promises owner identity
// stays private, and API, share page, and docs must agree. Notes and
// dueDate ARE published by product decision; the share panel states
// exactly what a public link exposes before the owner toggles it.
return db.One(r.Context(), database(r), `SELECT jsonb_build_object(
'id',b.id,'url',b.url,'title',b.title,'description',b.description,'notes',b.notes,
'favicon',b.favicon,'ogImage',b."ogImage",'ogTitle',b."ogTitle",'ogDescription',b."ogDescription",
'dueDate',b."dueDate",'isPublic',b."isPublic",
'tags',COALESCE((SELECT jsonb_agg(jsonb_build_object('tag',jsonb_build_object('id',t.id,'name',t.name,'color',t.color))) FROM "BookmarkTag" bt JOIN "Tag" t ON t.id=bt."tagId" WHERE bt."bookmarkId"=b.id),'[]'::jsonb))
FROM "Bookmark" b WHERE b.id=$1 AND b."isPublic"`, id)
}
// Review PRIV-02: shared readers get the narrowed allowlist projection;
// only the owner receives the full row with contact and recipient list.
where := `b.id=$1 AND ` + db.BookmarkVisible
uid := user(r)
return db.One(r.Context(), database(r), `SELECT `+db.BookmarkViewerView+` || jsonb_build_object('pageLinks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('page',jsonb_build_object('id',p.id,'title',p.title))) FROM "PageBookmark" pb JOIN "Page" p ON p.id=pb."pageId" WHERE pb."bookmarkId"=b.id AND p."ownerId"=$2),'[]'::jsonb)) FROM "Bookmark" b WHERE `+where, id, uid)
}
func HandleBookmarksList(w http.ResponseWriter, r *http.Request) {
args := []any{nil, user(r)}
sp := r.URL.Query()
limit := 200
if raw := sp.Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > 200 {
respond(w, 0, nil, bad("Limit must be between 1 and 200"))
return
}
limit = n
}
where := `b."ownerId"=$2`
if sp.Get("shared") == "true" {
where = db.BookmarkVisible + ` AND b."ownerId"<>$2`
}
add := func(s string, v any) { args = append(args, v); where += " AND " + fmt.Sprintf(s, len(args)) }
if s := sp.Get("search"); s != "" {
// Escape LIKE wildcards (review PERF-2): an unescaped query pattern
// matched everything and defeated the search's selectivity.
args = append(args, "%"+likeEscaper.Replace(s)+"%")
n := len(args)
where += fmt.Sprintf(` AND (b.title ILIKE $%d ESCAPE '\' OR b.url ILIKE $%d ESCAPE '\' OR b.notes ILIKE $%d ESCAPE '\' OR EXISTS(SELECT 1 FROM "BookmarkTag" bt JOIN "Tag" t ON t.id=bt."tagId" WHERE bt."bookmarkId"=b.id AND t.name ILIKE $%d ESCAPE '\'))`, n, n, n, n)
}
if s := sp.Get("tagId"); s != "" {
add(`EXISTS(SELECT 1 FROM "BookmarkTag" bt WHERE bt."bookmarkId"=b.id AND bt."tagId"=$%d)`, s)
if sp.Get("shared") != "true" {
where = strings.Replace(where, `b."ownerId"=$2`, db.BookmarkVisible, 1)
}
}
if s := sp.Get("contactId"); s != "" {
add(`b."contactId"=$%d`, s)
}
if s := sp.Get("kanban"); s != "" {
if !validStatus(s) {
respond(w, 0, nil, bad("Invalid status"))
return
}
add(`b."kanbanStatus"=$%d`, s)
}
if s := sp.Get("date"); s != "" {
v, e := dateValue(s)
if e != nil {
respond(w, 0, nil, e)
return
}
// Pin both sides to UTC: bare ::date casts use the Postgres session
// timezone, which is not pinned and would shift "due on date X".
add(`(b."dueDate" AT TIME ZONE 'UTC')::date=($%d::timestamptz AT TIME ZONE 'UTC')::date`, v)
// Guarded exactly like tagId (review COR-7): re-injecting the
// visibility predicate into a list already widened by it would nest
// BookmarkVisible inside itself.
if sp.Get("shared") != "true" && !strings.Contains(where, db.BookmarkVisible) {
where = strings.Replace(where, `b."ownerId"=$2`, db.BookmarkVisible, 1)
}
}
// Port artifact: db.BookmarkVisible binds the user as $2, so $1 stays a
// permanently-nil placeholder and this predicate is always true. Removing
// it would require renumbering BookmarkVisible's parameters everywhere.
where += ` AND $1::text IS NULL`
if raw := sp.Get("cursor"); raw != "" {
var cursor struct {
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
}
data, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil || json.Unmarshal(data, &cursor) != nil || cursor.UpdatedAt.IsZero() || cursor.ID == "" {
respond(w, 0, nil, bad("Invalid cursor"))
return
}
args = append(args, cursor.UpdatedAt, cursor.ID)
where += fmt.Sprintf(` AND (b."updatedAt",b.id)<($%d,$%d)`, len(args)-1, len(args))
}
args = append(args, limit+1)
v, e := db.Many(r.Context(), database(r), `SELECT `+db.BookmarkViewerView+` FROM "Bookmark" b WHERE `+where+fmt.Sprintf(` ORDER BY b."updatedAt" DESC,b.id DESC LIMIT $%d`, len(args)), args...)
if e == nil && len(v) > limit {
v = v[:limit]
last := v[len(v)-1]
cursor, _ := json.Marshal(object{"updatedAt": last["updatedAt"], "id": last["id"]})
w.Header().Set("X-Next-Cursor", base64.RawURLEncoding.EncodeToString(cursor))
}
respond(w, 200, v, e)
}
func validStatus(s string) bool {
return s == "INBOX" || s == "TODO" || s == "IN_PROGRESS" || s == "DONE" || s == "ARCHIVED"
}
func bookmarkFields(b object) (object, error) {
d := fields(b, "url title description notes dueDate alertAt kanbanStatus kanbanColumnId contactId isPublic imapWatchEnabled imapQuery")
if e := stringFields(d, "title description notes kanbanColumnId contactId imapQuery", "url kanbanStatus"); e != nil {
return nil, e
}
for _, l := range []struct {
key string
max int
}{
{"url", maxBookmarkURLLen},
{"title", maxBookmarkTitleLen},
{"description", maxBookmarkDescriptionLen},
{"notes", maxBookmarkNotesLen},
{"imapQuery", maxBookmarkQueryLen},
} {
if e := limitText(d, l.key, l.max); e != nil {
return nil, e
}
}
if e := boolFields(d, "isPublic imapWatchEnabled"); e != nil {
return nil, e
}
if v, ok := d["url"]; ok && !validURL(v.(string)) {
return nil, bad("A valid HTTP(S) URL is required")
}
if s, ok := d["kanbanStatus"]; ok && !validStatus(s.(string)) {
return nil, bad("Invalid status")
}
for _, k := range []string{"dueDate", "alertAt"} {
if v, ok := d[k]; ok {
v, e := dateValue(v)
if e != nil {
return nil, e
}
d[k] = v
if k == "alertAt" {
d["alertSent"] = false
}
}
}
for _, k := range []string{"contactId", "kanbanColumnId"} {
if d[k] == "" {
d[k] = nil
}
}
return d, nil
}
func checkRelations(r *http.Request, tx *sql.Tx, d object, tags []string) error {
for k, table := range map[string]string{"contactId": "Contact", "kanbanColumnId": "KanbanColumn"} {
if v, ok := d[k]; ok && v != nil {
if _, e := owned(r, tx, table, v.(string), "userId"); e != nil {
return bad("Invalid " + k)
}
}
}
// A tag shared WITH you may be attached to your own bookmarks. Note the
// group semantics this implies (by design, see db.BookmarkVisible): the
// bookmark then becomes visible to every recipient of that tag.
for _, tag := range tags {
if _, e := db.One(r.Context(), tx, `SELECT to_jsonb(t) FROM "Tag" t WHERE id=$1 AND ("ownerId"=$2 OR EXISTS(SELECT 1 FROM "TagShare" s WHERE s."tagId"=t.id AND s."userId"=$2))`, tag, user(r)); e != nil {
return bad("Invalid tag")
}
}
return nil
}
func setTags(r *http.Request, tx *sql.Tx, id string, tags []string) error {
if _, e := tx.ExecContext(r.Context(), `DELETE FROM "BookmarkTag" WHERE "bookmarkId"=$1`, id); e != nil {
return e
}
for _, t := range tags {
if _, e := tx.ExecContext(r.Context(), `INSERT INTO "BookmarkTag"("bookmarkId","tagId") VALUES($1,$2)`, id, t); e != nil {
return e
}
}
return nil
}
// addPreview fetches og/favicon metadata for the bookmark's URL through the
// shared preview budget (review CR-06): bookmark mutations and the dedicated
// preview route draw from the SAME per-identity rate and global concurrency
// limits, so saves cannot bypass the route's throttle. Preview work is
// decorative — when the budget is exhausted the bookmark still persists,
// just without fresh og metadata.
func addPreview(ctx context.Context, key string, d object, create bool) {
p := preview.FetchPreview(ctx, key, str(d, "url"))
d["favicon"], d["ogImage"], d["ogTitle"], d["ogDescription"] = p.Favicon, p.OgImage, p.OgTitle, p.OgDescription
if create {
if d["title"] == nil {
if p.OgTitle != nil {
d["title"] = *p.OgTitle
} else {
d["title"] = d["url"]
}
}
if d["description"] == nil {
d["description"] = p.OgDescription
}
}
}
func HandleBookmarksCreate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
d, e := bookmarkFields(b)
if e != nil {
respond(w, 0, nil, e)
return
}
if str(d, "url") == "" {
respond(w, 0, nil, bad("URL required"))
return
}
tags, e := ids(b, "tagIds")
if e != nil {
respond(w, 0, nil, e)
return
}
id := db.NewID()
d["id"], d["ownerId"] = id, user(r)
addPreview(r.Context(), previewKey(r), d, true)
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
// One locking protocol for board mutations (review CR-05): column
// deletion holds lockUser while it snapshots the affected bookmarks,
// moves them and drops the column. Bookmark writes that validate and
// assign kanbanColumnId take the SAME lock first, so a concurrent
// create can no longer land on a column the deletion already counted
// as moved (or escape its move set). Lock order is always user row →
// bookmarks/columns, matching every other lockUser transaction.
if e := lockUser(r, tx); e != nil {
return e
}
if e := checkRelations(r, tx, d, tags); e != nil {
return e
}
row, e := db.Insert(r.Context(), tx, "Bookmark", d)
if e != nil {
return e
}
if e = syncLegacyWatch(r, tx, row); e != nil {
return e
}
return setTags(r, tx, id, tags)
})
if e != nil {
respond(w, 0, nil, e)
return
}
v, e := bookmark(r, id, false)
respond(w, 201, v, e)
}
func HandleBookmarksGet(w http.ResponseWriter, r *http.Request) {
v, e := bookmark(r, r.PathValue("id"), false)
respond(w, 200, v, e)
}
func HandleBookmarksUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
existing, e := owned(r, database(r), "Bookmark", id, "ownerId")
if e != nil {
respond(w, 0, nil, e)
return
}
b, ok := read(w, r)
if !ok {
return
}
d, e := bookmarkFields(b)
if e != nil {
respond(w, 0, nil, e)
return
}
// Optimistic concurrency (review CR-04): the PUT is the owner-driven
// mutation surface (fields AND tag replacement), and concurrency is not
// optional on it — the same DATA-05 rule pages enforce. The client sends
// the version it edited from; a stale base is a 409 and the row is left
// untouched, so overlapping edits can no longer silently overwrite each
// other. Sharing (POST /share) and server-driven flags (alertSent) are
// deliberately outside this contract; the kanban column-deletion move
// bumps the version itself.
baseVersion, hasVersion := 0, false
if v, present := b["version"]; present {
f, isNum := v.(float64)
if !isNum || f < 0 || f != float64(int64(f)) {
respond(w, 0, nil, bad("version must be a non-negative integer"))
return
}
baseVersion, hasVersion = int(f), true
}
if !hasVersion {
respond(w, 0, nil, bad("version is required when updating a bookmark; refetch it and retry"))
return
}
tags, e := ids(b, "tagIds")
if e != nil {
respond(w, 0, nil, e)
return
}
if s := str(d, "url"); s != "" && s != str(existing, "url") {
addPreview(r.Context(), previewKey(r), d, false)
}
d["updatedAt"] = time.Now().UTC()
d["version"] = baseVersion + 1
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
// Review CR-05: same board-mutation locking protocol as create and
// column deletion — placement is validated (checkRelations) and
// written under the user lock, so a column deletion can neither race
// this assignment nor move a bookmark this write just placed.
if e := lockUser(r, tx); e != nil {
return e
}
// Re-read under the lock: the version check must see the latest
// committed state, not the pre-transaction snapshot.
cur, e := owned(r, tx, "Bookmark", id, "ownerId")
if e != nil {
return e
}
if stored, _ := cur["version"].(float64); int(stored) != baseVersion {
return conflict("Bookmark was modified elsewhere; reload to see the latest version")
}
if e := checkRelations(r, tx, d, tags); e != nil {
return e
}
row, e := db.Update(r.Context(), tx, "Bookmark", id, "ownerId", user(r), d)
if e != nil {
return e
}
if e = syncLegacyWatch(r, tx, row); e != nil {
return e
}
if _, ok := b["tagIds"]; ok {
return setTags(r, tx, id, tags)
}
return nil
})
if e != nil {
respond(w, 0, nil, e)
return
}
v, e := bookmark(r, id, false)
respond(w, 200, v, e)
}
func HandleBookmarksDelete(w http.ResponseWriter, r *http.Request) {
e := remove(r, "Bookmark", r.PathValue("id"), "ownerId")
respond(w, 200, OK{true}, e)
}
// The original mailbox-watch switch now drives an actual email signal source.
// Modern watchers added in the Signals panel are managed independently.
func syncLegacyWatch(r *http.Request, tx *sql.Tx, b object) error {
enabled, _ := b["imapWatchEnabled"].(bool)
raw, _ := json.Marshal(object{"subject": str(b, "imapQuery"), "legacy": true})
res, e := tx.ExecContext(r.Context(), `UPDATE "SignalSource" SET config=$1::json,enabled=$2,"updatedAt"=now() WHERE "bookmarkId"=$3 AND "ownerId"=$4 AND config->>'legacy'='true'`, string(raw), enabled, b["id"], user(r))
if e != nil {
return e
}
n, _ := res.RowsAffected()
if n == 0 && enabled {
_, e = db.Insert(r.Context(), tx, "SignalSource", object{"id": db.NewID(), "type": "email", "config": string(raw), "enabled": true, "bookmarkId": b["id"], "ownerId": user(r)})
}
return e
}
func HandleBookmarksShare(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, e := owned(r, database(r), "Bookmark", id, "ownerId"); e != nil {
respond(w, 0, nil, e)
return
}
b, ok := read(w, r)
if !ok {
return
}
list, e := ids(b, "userIds")
if e != nil {
respond(w, 0, nil, e)
return
}
if e = boolFields(b, "isPublic"); e != nil {
respond(w, 0, nil, e)
return
}
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := shareRows(r, tx, "BookmarkShare", "bookmarkId", id, list); e != nil {
return e
}
if _, ok := b["isPublic"]; ok {
_, e := db.Update(r.Context(), tx, "Bookmark", id, "ownerId", user(r), object{"isPublic": b["isPublic"], "updatedAt": time.Now().UTC()})
return e
}
return nil
})
respond(w, 200, OK{true}, e)
}
func HandleBookmarksMarkdown(w http.ResponseWriter, r *http.Request) {
// bookmark() already applies the permission-specific projection (review
// PRIV-02), so shared readers export only fields they may read.
v, e := bookmark(r, r.PathValue("id"), false)
if e != nil {
respond(w, 0, nil, e)
return
}
markdown.Send(w, markdown.BookmarkDocument(v), str(v, "title"))
}
// previewKey is the shared-budget identity for preview work (review CR-06):
// per user, across every entry point (route + mutations).
func previewKey(r *http.Request) string { return "user:" + user(r) }
func HandlePreview(w http.ResponseWriter, r *http.Request) {
u := r.URL.Query().Get("url")
if !validURL(u) {
respond(w, 0, nil, bad("A valid HTTP(S) URL is required"))
return
}
respond(w, 200, preview.FetchPreview(r.Context(), previewKey(r), u), nil)
}
File diff suppressed because it is too large Load Diff
+264
View File
@@ -0,0 +1,264 @@
package handlers
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/andrew/go-computer/internal/auth"
"github.com/andrew/go-computer/internal/db"
"github.com/jackc/pgx/v5/pgconn"
"io"
"log"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"unicode/utf8"
)
type object = db.Object
type clientError struct{ message string }
func (e clientError) Error() string { return e.message }
func bad(s string) error { return clientError{s} }
// conflictError maps to 409: the request collides with newer server state
// (optimistic concurrency, review DATA-01), not with a unique constraint.
type conflictError struct{ message string }
func (e conflictError) Error() string { return e.message }
func conflict(s string) error { return conflictError{s} }
func database(r *http.Request) *db.DB { return db.FromContext(r.Context()) }
func user(r *http.Request) string { s, _ := auth.SessionFromContext(r.Context()); return s.ID }
func read(w http.ResponseWriter, r *http.Request) (object, bool) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var b object
dec := json.NewDecoder(r.Body)
if dec.Decode(&b) != nil || b == nil {
writeJSON(w, 400, Error{"Invalid JSON object"})
return nil, false
}
var extra any
if dec.Decode(&extra) != io.EOF {
writeJSON(w, 400, Error{"Unexpected data after JSON"})
return nil, false
}
return b, true
}
func respond(w http.ResponseWriter, status int, v any, e error) {
if e != nil {
code, msg := 500, "Server error"
var ce clientError
var cfe conflictError
var pg *pgconn.PgError
switch {
case errors.Is(e, db.ErrNotFound):
code, msg = 404, "Not found"
case errors.As(e, &ce):
code, msg = 400, ce.message
case errors.As(e, &cfe):
code, msg = 409, cfe.message
case errors.As(e, &pg) && pg.Code == "23505":
code, msg = 409, "Already exists"
case errors.As(e, &pg) && (pg.Code == "23503" || pg.Code == "22P02"):
code, msg = 400, "Invalid related record or value"
default:
log.Printf("request: %v", e)
}
writeJSON(w, code, Error{msg})
return
}
writeJSON(w, status, v)
}
func str(b object, k string) string { v, _ := b[k].(string); return v }
func ids(b object, k string) ([]string, error) {
v, ok := b[k]
if !ok {
return nil, nil
}
list, ok := v.([]any)
if !ok {
return nil, bad(k + " must be an array")
}
out := []string{}
seen := map[string]bool{}
for _, a := range list {
s, ok := a.(string)
if !ok || s == "" {
return nil, bad("Invalid " + k)
}
if !seen[s] {
out = append(out, s)
seen[s] = true
} else {
return nil, bad("Duplicate " + k)
}
}
return out, nil
}
func fields(b object, allowed string) object {
out := object{}
for _, k := range strings.Fields(allowed) {
if v, ok := b[k]; ok {
out[k] = v
}
}
return out
}
func owned(r *http.Request, q db.Queryer, table, id, col string) (object, error) {
return db.One(r.Context(), q, `SELECT to_jsonb(t) FROM `+db.Quote(table)+` t WHERE id=$1 AND `+db.Quote(col)+`=$2`, id, user(r))
}
func remove(r *http.Request, table, id, col string) error {
res, e := database(r).ExecContext(r.Context(), `DELETE FROM `+db.Quote(table)+` WHERE id=$1 AND `+db.Quote(col)+`=$2`, id, user(r))
if e != nil {
return e
}
n, _ := res.RowsAffected()
if n == 0 {
return db.ErrNotFound
}
return nil
}
func dateValue(v any) (any, error) {
if v == nil {
return nil, nil
}
s, ok := v.(string)
if !ok {
return nil, bad("Invalid date")
}
if s == "" {
return nil, nil
}
for _, layout := range []string{time.RFC3339Nano, "2006-01-02T15:04", "2006-01-02T15:04:05", "2006-01-02"} {
if t, e := time.Parse(layout, s); e == nil {
return t.UTC(), nil
}
}
return nil, bad("Invalid date")
}
var colorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
func validColor(b object) error {
if v, ok := b["color"]; ok {
if s, ok := v.(string); !ok || !colorPattern.MatchString(s) {
return bad("Color must be #RRGGBB")
}
}
return nil
}
func validURL(s string) bool {
u, e := url.Parse(s)
return e == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.User == nil
}
func stringFields(b object, nullable, required string) error {
for _, k := range strings.Fields(nullable) {
if v, ok := b[k]; ok && v != nil {
if _, ok := v.(string); !ok {
return bad(k + " must be text")
}
}
}
for _, k := range strings.Fields(required) {
if v, ok := b[k]; ok {
if _, ok := v.(string); !ok {
return bad(k + " must be text")
}
}
}
return nil
}
// Per-field size limits (review "cap input sizes"). The 1 MiB request-body
// cap alone still lets a title, tag name or URL grow absurdly large, which
// bloats every list response and search index. These are deliberately
// generous product limits, not storage constraints; over-limit input is
// rejected with a 400 instead of being silently truncated so a client never
// believes it saved something it did not.
const (
maxBookmarkURLLen = 2048
maxBookmarkTitleLen = 500
maxBookmarkDescriptionLen = 2000
maxBookmarkNotesLen = 100000
maxBookmarkQueryLen = 500
maxPageContentLen = 512 << 10
maxPageIconLen = 64
maxTagNameLen = 80
maxSharedDateNoteLen = 2000
maxContactNameLen = 200
maxContactEmailLen = 320
maxContactPhoneLen = 64
maxContactUIDLen = 255
maxMailboxHostLen = 255
maxMailboxUsernameLen = 320
maxMailboxPasswordLen = 4096
maxMailboxFolderLen = 255
)
// limitText rejects a text field longer than max runes. Missing and null
// fields are fine; a non-string value is reported by stringFields first.
func limitText(d object, key string, max int) error {
v, ok := d[key]
if !ok || v == nil {
return nil
}
s, ok := v.(string)
if !ok {
return bad(key + " must be text")
}
if utf8.RuneCountInString(s) > max {
return bad(fmt.Sprintf("%s must be at most %d characters", key, max))
}
return nil
}
func boolFields(b object, keys string) error {
for _, k := range strings.Fields(keys) {
if v, ok := b[k]; ok {
if _, ok := v.(bool); !ok {
return bad(k + " must be boolean")
}
}
}
return nil
}
func lockUser(r *http.Request, tx *sql.Tx) error {
_, e := tx.ExecContext(r.Context(), `SELECT id FROM "User" WHERE id=$1 FOR UPDATE`, user(r))
return e
}
func reorder(r *http.Request, table, col string, order []string) error {
return db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
var count int
if e := tx.QueryRowContext(r.Context(), `SELECT count(*) FROM `+db.Quote(table)+` WHERE `+db.Quote(col)+`=$1`, user(r)).Scan(&count); e != nil {
return e
}
if count != len(order) {
return bad("Order must contain every record exactly once")
}
for i, id := range order {
res, e := tx.ExecContext(r.Context(), `UPDATE `+db.Quote(table)+` SET position=$1 WHERE id=$2 AND `+db.Quote(col)+`=$3`, i, id, user(r))
if e != nil {
return e
}
n, _ := res.RowsAffected()
if n == 0 {
return bad("Invalid order")
}
}
return nil
})
}
func shareRows(r *http.Request, tx *sql.Tx, table, key, id string, list []string) error {
for _, u := range list {
if _, e := tx.ExecContext(r.Context(), `INSERT INTO `+db.Quote(table)+` (`+db.Quote(key)+`,"userId") VALUES($1,$2) ON CONFLICT DO NOTHING`, id, u); e != nil {
return e
}
}
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package handlers
import (
"encoding/json"
"net/http"
)
// Common helpers shared by all handlers. Porting guide section 4.
//
// Every data endpoint follows the same pattern: resolve the effective user,
// return 401 if none, check ownership with a filtered lookup, return 404 if
// the record is not theirs, then act.
//
// All routes return JSON unless marked .md. Errors are {error: string}.
// OK is the success response shape for mutations.
type OK struct {
OK bool `json:"ok"`
}
// Error is the error response shape.
type Error struct {
Error string `json:"error"`
}
// NotFound returns a 404 {error} response.
func NotFound(message string) Error {
if message == "" {
message = "not found"
}
return Error{Error: message}
}
// Unauthorized returns a 401 {error} response.
func Unauthorized(message string) Error {
if message == "" {
message = "unauthorized"
}
return Error{Error: message}
}
// Forbidden returns a 403 {error} response.
func Forbidden(message string) Error {
if message == "" {
message = "forbidden"
}
return Error{Error: message}
}
// BadRequest returns a 400 {error} response.
func BadRequest(message string) Error {
if message == "" {
message = "bad request"
}
return Error{Error: message}
}
// Conflict returns a 409 {error} response.
func Conflict(message string) Error {
return Error{Error: message}
}
// WriteJSON writes v as JSON with the given status.
func WriteJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeJSON is the lowercase alias used by the stub handlers.
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
WriteJSON(w, status, v)
}
+269
View File
@@ -0,0 +1,269 @@
package handlers
import (
"database/sql"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/kanban"
"net/http"
"strings"
)
func HandleKanbanColumnsList(w http.ResponseWriter, r *http.Request) {
v, e := kanban.GetColumns(r.Context(), database(r), user(r))
respond(w, 200, v, e)
}
func columnData(b object) (object, error) {
d := fields(b, "label color")
if e := stringFields(d, "", "label color"); e != nil {
return nil, e
}
if _, ok := d["label"]; ok {
s := strings.TrimSpace(str(d, "label"))
if s == "" {
return nil, bad("Label required")
}
r := []rune(s)
if len(r) > 40 {
r = r[:40]
}
d["label"] = string(r)
}
return d, validColor(d)
}
func HandleKanbanColumnsCreate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
d, e := columnData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
if str(d, "label") == "" {
respond(w, 0, nil, bad("Label required"))
return
}
if _, ok := d["color"]; !ok {
d["color"] = "#8b5cf6"
}
d["id"], d["userId"] = db.NewID(), user(r)
var v object
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
var n int
if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "KanbanColumn" WHERE "userId"=$1`, user(r)).Scan(&n); e != nil {
return e
}
d["position"] = n
var e error
v, e = db.Insert(r.Context(), tx, "KanbanColumn", d)
return e
})
respond(w, 201, v, e)
}
func HandleKanbanColumnsReorder(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
order, e := ids(b, "order")
if e == nil {
e = reorder(r, "KanbanColumn", "userId", order)
}
if e != nil {
respond(w, 0, nil, e)
return
}
HandleKanbanColumnsList(w, r)
}
func HandleKanbanColumnsUpdate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
d, e := columnData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
v, e := db.Update(r.Context(), database(r), "KanbanColumn", r.PathValue("id"), "userId", user(r), d)
respond(w, 200, v, e)
}
// HandleKanbanCardsMove moves or reorders one card on the board. It carries
// the same optimistic-concurrency version as a bookmark PUT (review CR-04):
// a card edited or moved elsewhere is a 409, and the board reloads instead of
// silently clobbering it. The target column's positions are normalized in the
// same transaction, so dragging inside a column survives reloads.
func HandleKanbanCardsMove(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
b, ok := read(w, r)
if !ok {
return
}
columnID := strings.TrimSpace(str(b, "columnId"))
if columnID == "" {
respond(w, 0, nil, bad("columnId is required"))
return
}
rawPosition, hasPosition := b["position"]
if !hasPosition {
respond(w, 0, nil, bad("position is required"))
return
}
f, ok := rawPosition.(float64)
if !ok || f < 0 || f != float64(int64(f)) {
respond(w, 0, nil, bad("position must be a non-negative integer"))
return
}
position := int(f)
baseVersion, hasVersion := 0, false
if v, present := b["version"]; present {
f, isNum := v.(float64)
if !isNum || f < 0 || f != float64(int64(f)) {
respond(w, 0, nil, bad("version must be a non-negative integer"))
return
}
baseVersion, hasVersion = int(f), true
}
if !hasVersion {
respond(w, 0, nil, bad("version is required when moving a card; refetch it and retry"))
return
}
// Ensure the default board exists before the transaction reads columns.
if _, e := kanban.GetColumns(r.Context(), database(r), user(r)); e != nil {
respond(w, 0, nil, e)
return
}
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
cur, e := owned(r, tx, "Bookmark", id, "ownerId")
if e != nil {
return e
}
if stored, _ := cur["version"].(float64); int(stored) != baseVersion {
return conflict("Bookmark was modified elsewhere; reload to see the latest version")
}
if _, e := owned(r, tx, "KanbanColumn", columnID, "userId"); e != nil {
return bad("Invalid columnId")
}
cols, e := db.Many(r.Context(), tx, `SELECT to_jsonb(c) FROM "KanbanColumn" c WHERE "userId"=$1 ORDER BY position,id`, user(r))
if e != nil {
return e
}
// Resolve legacy cards (kanbanStatus only) the same way the board does,
// then normalize every card in the target column to explicit
// membership plus a contiguous position. That makes the new order
// durable without requiring a schema backfill.
rows, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',b.id,'kanbanColumnId',b."kanbanColumnId",'kanbanStatus',b."kanbanStatus") FROM "Bookmark" b WHERE b."ownerId"=$1 ORDER BY b."kanbanPosition",b."updatedAt" DESC,b.id DESC`, user(r))
if e != nil {
return e
}
target := make([]string, 0, len(rows))
for _, row := range rows {
if str(row, "id") == id {
continue
}
if kanban.ResolveColumnID(row, cols) == columnID {
target = append(target, str(row, "id"))
}
}
if position > len(target) {
position = len(target)
}
target = append(target, "")
copy(target[position+1:], target[position:])
target[position] = id
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" AS b SET "kanbanColumnId"=$1,"kanbanPosition"=t.ord-1 FROM unnest($2::text[]) WITH ORDINALITY AS t(bid,ord) WHERE b.id=t.bid AND b."ownerId"=$3`, columnID, target, user(r)); e != nil {
return e
}
// Only the moved card's revision advances; reindexing its neighbours is
// board placement, not a content edit.
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" SET version=version+1,"updatedAt"=now() WHERE id=$1 AND "ownerId"=$2`, id, user(r)); e != nil {
return e
}
return nil
})
if e != nil {
respond(w, 0, nil, e)
return
}
v, e := bookmark(r, id, false)
respond(w, 200, v, e)
}
func HandleKanbanColumnsDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var dest string
var moved int
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
// The user row is the single serialization point for board mutations
// (review CR-05): bookmark create/update take the same lock before
// validating/assigning kanbanColumnId, so this handler's snapshot of
// affected bookmarks, the move and the column delete cannot interleave
// with a competing assignment.
if e := lockUser(r, tx); e != nil {
return e
}
cols, e := db.Many(r.Context(), tx, `SELECT to_jsonb(c) FROM "KanbanColumn" c WHERE "userId"=$1 ORDER BY position,id`, user(r))
if e != nil {
return e
}
found := false
remaining := []object{}
for _, c := range cols {
if str(c, "id") == id {
found = true
} else {
remaining = append(remaining, c)
}
}
if !found {
return db.ErrNotFound
}
if len(remaining) == 0 {
return bad("A board needs at least one column")
}
dest = str(remaining[0], "id")
// Only the three columns ResolveColumnID needs, and one set-based
// UPDATE for the whole move instead of a round trip per bookmark.
bms, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',b.id,'kanbanColumnId',b."kanbanColumnId",'kanbanStatus',b."kanbanStatus") FROM "Bookmark" b WHERE "ownerId"=$1`, user(r))
if e != nil {
return e
}
moveIDs := make([]string, 0, len(bms))
for _, b := range bms {
if kanban.ResolveColumnID(b, cols) == id {
moveIDs = append(moveIDs, str(b, "id"))
}
}
if len(moveIDs) > 0 {
// The move is a mutation covered by the bookmark concurrency
// contract (review CR-04): bumping version turns any client
// snapshot that still shows the old column into a 409 on its
// next save instead of letting it silently move the bookmark
// back.
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" SET "kanbanColumnId"=$1,version=version+1,"updatedAt"=now() WHERE id=ANY($2::text[])`, dest, moveIDs); e != nil {
return e
}
moved = len(moveIDs)
}
if _, e := tx.ExecContext(r.Context(), `DELETE FROM "KanbanColumn" WHERE id=$1 AND "userId"=$2`, id, user(r)); e != nil {
return e
}
order := make([]string, len(remaining))
for i, c := range remaining {
order[i] = str(c, "id")
}
if _, e := tx.ExecContext(r.Context(), `UPDATE "KanbanColumn" AS k SET position=t.ord-1 FROM unnest($1::text[]) WITH ORDINALITY AS t(cid,ord) WHERE k.id=t.cid`, order); e != nil {
return e
}
return nil
})
respond(w, 200, object{"ok": true, "movedTo": dest, "moved": moved}, e)
}
+560
View File
@@ -0,0 +1,560 @@
package handlers
import (
"context"
"database/sql"
"encoding/xml"
"errors"
"github.com/andrew/go-computer/internal/config"
crypt "github.com/andrew/go-computer/internal/crypto"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/netguard"
"github.com/andrew/go-computer/internal/signals"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// carddavClient dials through netguard so a user-supplied Nextcloud URL can
// never point the server at internal infrastructure (same guard as URL
// previews). The transport is shared to avoid leaking idle sockets per sync.
var carddavClient = &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{DialContext: netguard.DialContext},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
func HandleAlertsCheck(w http.ResponseWriter, r *http.Request) {
v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(b) FROM "Bookmark" b WHERE "ownerId"=$1 AND "alertAt"<=now()+interval '15 minutes' AND NOT "alertSent" ORDER BY "alertAt"`, user(r))
respond(w, 200, v, e)
}
func HandleAlertsDismiss(w http.ResponseWriter, r *http.Request) {
_, e := db.Update(r.Context(), database(r), "Bookmark", r.PathValue("bookmarkId"), "ownerId", user(r), object{"alertSent": true, "updatedAt": time.Now().UTC()})
respond(w, 200, OK{true}, e)
}
func HandleUsersSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if len([]rune(q)) < 2 {
respond(w, 200, []object{}, nil)
return
}
// The share picker needs names and IDs; substring matching on emails would
// let any user enumerate the whole user base, so emails must match exactly
// and are returned masked. The name pattern escapes LIKE wildcards
// (review SEC-3): without that, "%%" or "a%" enumerated every user
// 10 rows at a time.
v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object('id',id,'name',name,'email',CASE WHEN position('@' in email)=0 THEN NULL ELSE left(email,1)||'***'||substr(email, position('@' in email)) END,'image',image) FROM "User" WHERE id<>$1 AND (name ILIKE $2 ESCAPE '\' OR lower(email)=lower($3)) ORDER BY name LIMIT 10`, user(r), "%"+likeEscaper.Replace(q)+"%", q)
respond(w, 200, v, e)
}
func HandleSharedDatesList(w http.ResponseWriter, r *http.Request) {
v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(s)||jsonb_build_object('creator',jsonb_build_object('id',c.id,'name',c.name,'email',c.email),'recipient',jsonb_build_object('id',u.id,'name',u.name,'email',u.email)) FROM "SharedDate" s JOIN "User" c ON c.id=s."creatorId" JOIN "User" u ON u.id=s."recipientId" WHERE s."creatorId"=$1 OR s."recipientId"=$1 ORDER BY date`, user(r))
respond(w, 200, v, e)
}
func HandleSharedDatesCreate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
dt, e := dateValue(b["date"])
if e != nil || dt == nil {
respond(w, 0, nil, bad("Date required"))
return
}
list, e := ids(b, "recipientIds")
if e != nil || len(list) == 0 {
respond(w, 0, nil, bad("Recipients required"))
return
}
if e = stringFields(b, "note", ""); e != nil {
respond(w, 0, nil, e)
return
}
if e = limitText(b, "note", maxSharedDateNoteLen); e != nil {
respond(w, 0, nil, e)
return
}
out := []object{}
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
for _, id := range list {
v, e := db.Insert(r.Context(), tx, "SharedDate", object{"id": db.NewID(), "date": dt, "creatorId": user(r), "recipientId": id, "note": b["note"]})
if e != nil {
return e
}
out = append(out, v)
}
return nil
})
respond(w, 201, out, e)
}
func HandleContactsList(w http.ResponseWriter, r *http.Request) {
v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(c)||jsonb_build_object('_count',jsonb_build_object('bookmarks',(SELECT count(*) FROM "Bookmark" b WHERE b."contactId"=c.id AND b."ownerId"=$1))) FROM "Contact" c WHERE "userId"=$1 ORDER BY "displayName"`, user(r))
respond(w, 200, v, e)
}
type davResponse struct {
Responses []struct {
Href string `xml:"href"`
Propstats []struct {
Status string `xml:"status"`
Prop struct {
Data string `xml:"address-data"`
} `xml:"prop"`
} `xml:"propstat"`
} `xml:"response"`
}
// resolveCardDAVTarget resolves the user-supplied cardDavPath against the
// configured base URL and revalidates the COMPLETE resolved destination
// (review CR-01): a same-host absolute path like "http://host/..." must not
// downgrade an https base, because Basic-auth credentials are attached to the
// resulting request. The resolved URL must keep the base's scheme and host
// and carry no embedded userinfo. The scheme check on the base URL itself
// (https unless the netguard fixture flag is set) happens at the caller, so
// pinning the resolved scheme to the base scheme enforces https end to end.
func resolveCardDAVTarget(base *url.URL, path string) (*url.URL, error) {
target, e := base.Parse(path)
if e != nil {
return nil, bad("Invalid CardDAV path")
}
if target.Host != base.Host || target.Scheme != base.Scheme || target.User != nil {
return nil, bad("CardDAV path must stay on the Nextcloud host and scheme")
}
return target, nil
}
func HandleContactsSync(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
if !validURL(str(b, "nextcloudUrl")) || str(b, "username") == "" || str(b, "password") == "" {
respond(w, 0, nil, bad("Nextcloud URL and credentials required"))
return
}
base, _ := url.Parse(str(b, "nextcloudUrl"))
// CardDAV transport (review recheck): Basic-auth credentials must never
// travel plaintext. https is required unless the netguard escape hatch is
// set (loopback test fakes / deliberate LAN deployments — never
// production Compose). Redirects are not followed at all
// (ErrUseLastResponse), so a verified origin cannot be downgraded, and
// the path resolution below is pinned to the same host AND the same
// scheme (review CR-01: the final resolved destination is revalidated,
// not just the configured base URL).
if base.Scheme != "https" && !netguard.AllowPrivate() {
respond(w, 0, nil, bad("Nextcloud URL must use https"))
return
}
path := str(b, "cardDavPath")
if path == "" {
path = "/remote.php/dav/addressbooks/users/" + url.PathEscape(str(b, "username")) + "/contacts/"
}
target, e := resolveCardDAVTarget(base, path)
if e != nil {
respond(w, 0, nil, e)
return
}
payload := `<?xml version="1.0"?><card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><card:address-data/></d:prop></card:addressbook-query>`
req, e := http.NewRequestWithContext(r.Context(), "REPORT", target.String(), strings.NewReader(payload))
if e != nil {
respond(w, 0, nil, bad("Invalid CardDAV URL"))
return
}
req.SetBasicAuth(str(b, "username"), str(b, "password"))
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml")
res, e := carddavClient.Do(req)
if e != nil {
respond(w, 0, nil, bad("Could not connect to Nextcloud"))
return
}
defer res.Body.Close()
if res.StatusCode != 207 && res.StatusCode != 200 {
respond(w, 0, nil, bad("Nextcloud rejected the CardDAV request"))
return
}
var dav davResponse
if e = xml.NewDecoder(io.LimitReader(res.Body, 8<<20)).Decode(&dav); e != nil {
respond(w, 0, nil, bad("Invalid CardDAV response"))
return
}
synced := 0
removed := 0
seenUIDs := []string{}
// complete tracks whether the server answered with full data for every
// card. Deletion reconciliation (review CR-07) runs ONLY on a complete,
// non-empty snapshot: a failed, truncated or partially-withheld response
// must never be read as "these contacts were deleted remotely".
complete := true
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
for _, entry := range dav.Responses {
gotData := false
for _, prop := range entry.Propstats {
if !strings.Contains(prop.Status, "200") {
continue
}
v := parseVCard(prop.Prop.Data)
uid, fn := vCardPick(v["UID"]), vCardPick(v["FN"])
if uid == "" || fn == "" {
// A card we cannot identify stays untouched locally — and
// blocks reconciliation, since we cannot prove which
// stored contact (if any) it corresponds to.
complete = false
continue
}
// Contact data comes from a remote server, so apply the same
// size discipline as user input. A UID is an identity key and
// must never be truncated; skip an absurd one and keep the
// snapshot incomplete so reconciliation stays conservative.
if len([]rune(uid)) > maxContactUIDLen {
complete = false
continue
}
gotData = true
seenUIDs = append(seenUIDs, uid)
email := trimLimit(vCardPick(v["EMAIL"]), maxContactEmailLen)
phone := trimLimit(vCardPick(v["TEL"]), maxContactPhoneLen)
fn = trimLimit(fn, maxContactNameLen)
_, e := tx.ExecContext(r.Context(), `INSERT INTO "Contact"(id,uid,"displayName",email,phone,"cardDavUrl",source,"userId") VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(uid,"userId") DO UPDATE SET "displayName"=EXCLUDED."displayName",email=EXCLUDED.email,phone=EXCLUDED.phone,"cardDavUrl"=EXCLUDED."cardDavUrl",source=EXCLUDED.source,"updatedAt"=now()`, db.NewID(), uid, fn, nullable(email), nullable(phone), entry.Href, target.String(), user(r))
if e != nil {
return e
}
synced++
}
if !gotData {
complete = false
}
}
// Remote-deletion reconciliation (review CR-07). Policy: the sync
// MIRRORS the configured address book, scoped by provenance — only
// contacts previously imported from THIS source URL are candidates,
// manually created contacts (source NULL) and other address books'
// imports are never touched, and bookmarks referencing a removed
// contact keep working (contactId is ON DELETE SET NULL). Guards:
// a non-empty, fully-answered snapshot only; an empty or partial
// response preserves everything (a genuinely emptied address book
// stops mirroring deletions until it has at least one card — the
// deliberate safe-side tradeoff).
if complete && synced > 0 {
res, e := tx.ExecContext(r.Context(), `DELETE FROM "Contact" WHERE "userId"=$1 AND source=$2 AND NOT (uid = ANY($3::text[]))`, user(r), target.String(), seenUIDs)
if e != nil {
return e
}
n, _ := res.RowsAffected()
removed = int(n)
}
return nil
})
if e != nil {
respond(w, 0, nil, e)
return
}
respond(w, 200, object{"synced": synced, "removed": removed}, nil)
}
func nullable(s string) any {
if s == "" {
return nil
}
return s
}
// vCardValue is one parsed property occurrence: its parameters (TYPE, PREF,
// ENCODING, ...) and its unescaped value. Repeated properties keep EVERY
// occurrence in file order (review CR-08) instead of the last one clobbering
// the rest.
type vCardValue struct {
params map[string][]string
value string
}
// splitVCardName splits the property name on unquoted semicolons so quoted
// parameter values ("...;X=a;b") survive.
func splitVCardName(name string) []string {
out := []string{}
var cur strings.Builder
inQuotes := false
for _, r := range name {
switch {
case r == '"':
inQuotes = !inQuotes
cur.WriteRune(r)
case r == ';' && !inQuotes:
out = append(out, cur.String())
cur.Reset()
default:
cur.WriteRune(r)
}
}
return append(out, cur.String())
}
// parseVCard parses the documented subset of vCard (RFC 2426/6350) the
// contact sync consumes: CRLF and bare-LF unfolding, group prefixes
// (item1.EMAIL), parameter lists (including bare vCard 2.1 tokens as
// implicit TYPE values), and the standard escapes. Unsupported data is
// dropped, not silently misparsed (review CR-08): properties declaring a
// base64 ENCODING are skipped — the stored fields (UID/FN/EMAIL/TEL) are
// never legitimately encoded, and keeping the raw text would pretend to be
// a correct parse. A colon inside a quoted parameter value is outside the
// supported subset.
func parseVCard(s string) map[string][]vCardValue {
s = strings.ReplaceAll(s, "\r\n ", "")
s = strings.ReplaceAll(s, "\r\n\t", "")
s = strings.ReplaceAll(s, "\n ", "")
s = strings.ReplaceAll(s, "\n\t", "")
out := map[string][]vCardValue{}
for _, line := range strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") {
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
fields := splitVCardName(parts[0])
key := strings.ToUpper(fields[0])
if i := strings.LastIndex(key, "."); i >= 0 {
key = key[i+1:] // strip group prefix (item1.EMAIL -> EMAIL)
}
if key == "" {
continue
}
params := map[string][]string{}
for _, p := range fields[1:] {
kv := strings.SplitN(p, "=", 2)
if len(kv) != 2 {
// Bare token (vCard 2.1 style "EMAIL;INTERNET;WORK"): an
// implicit TYPE value.
if tok := strings.TrimSpace(kv[0]); tok != "" {
params["TYPE"] = append(params["TYPE"], strings.ToUpper(tok))
}
continue
}
pk := strings.ToUpper(strings.TrimSpace(kv[0]))
for _, pv := range strings.Split(kv[1], ",") {
params[pk] = append(params[pk], strings.ToUpper(strings.Trim(strings.TrimSpace(pv), `"`)))
}
}
if enc := params["ENCODING"]; len(enc) > 0 && (enc[0] == "B" || enc[0] == "BASE64") {
continue // unsupported encoding: drop instead of misparsing
}
value := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`).Replace(parts[1])
out[key] = append(out[key], vCardValue{params: params, value: value})
}
return out
}
// vCardPick deterministically selects the display value among repeated
// occurrences (review CR-08): the lowest explicit PREF=n wins (vCard 4.0),
// then the first TYPE=PREF occurrence (vCard 3.0), then file order.
func vCardPick(values []vCardValue) string {
if len(values) == 0 {
return ""
}
best := values[0].value
bestPref := 1 << 30
for _, v := range values {
for _, p := range v.params["PREF"] {
if n, e := strconv.Atoi(p); e == nil && n < bestPref {
bestPref, best = n, v.value
}
}
}
if bestPref != 1<<30 {
return best
}
for _, v := range values {
for _, p := range v.params["TYPE"] {
if p == "PREF" {
return v.value
}
}
}
return best
}
func mailboxData(b object) (signals.Mailbox, error) {
if e := stringFields(b, "", "host username password folder"); e != nil {
return signals.Mailbox{}, e
}
if e := boolFields(b, "tls"); e != nil {
return signals.Mailbox{}, e
}
for _, l := range []struct {
key string
max int
}{
{"host", maxMailboxHostLen},
{"username", maxMailboxUsernameLen},
{"password", maxMailboxPasswordLen},
{"folder", maxMailboxFolderLen},
} {
if e := limitText(b, l.key, l.max); e != nil {
return signals.Mailbox{}, e
}
}
m := signals.Mailbox{Host: str(b, "host"), Username: str(b, "username"), Password: str(b, "password"), Folder: str(b, "folder"), Port: 993, TLS: true}
if n, ok := b["port"]; ok {
v, ok := n.(float64)
if !ok || v < 1 || v > 65535 || float64(int(v)) != v {
return m, bad("Invalid port")
}
m.Port = int(v)
}
if v, ok := b["tls"].(bool); ok {
m.TLS = v
}
// Normal IMAP ports only (review "outbound fetches aren't limited to
// normal ports"). The fixture flags keep loopback fakes and deliberate
// LAN deployments working on an ephemeral port.
if !signals.AllowInsecure() && !netguard.AllowPrivate() && m.Port != 993 && m.Port != 143 {
return m, bad("Mailbox port must be 143 or 993")
}
// Review NET-02: mailbox credentials must never travel over a plaintext
// socket. Insecure settings are rejected at the API boundary; the
// development-only INTEGRATIONS_ALLOW_INSECURE_IMAP escape hatch mirrors
// netguard's fixture flag and is never set in production Compose.
if !m.TLS && !signals.AllowInsecure() {
return m, bad("TLS is required for mailbox connections")
}
if m.Folder == "" {
m.Folder = "INBOX"
}
if m.Host == "" || m.Username == "" {
return m, bad("Host and username required")
}
return m, nil
}
func HandleImapGet(w http.ResponseWriter, r *http.Request) {
v, e := db.One(r.Context(), database(r), `SELECT to_jsonb(m)-'password'||jsonb_build_object('password','••••••••') FROM "ImapConfig" m WHERE "userId"=$1`, user(r))
if e == db.ErrNotFound {
respond(w, 200, nil, nil)
return
}
respond(w, 200, v, e)
}
func HandleImapSave(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
m, e := mailboxData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
// Review CR-21: a blank/masked password means "keep the stored secret".
// The old path read the existing ciphertext and re-submitted it in the
// upsert, so a password saved concurrently between that read and write
// was silently rolled back. The keep-stored path therefore never assigns
// the password column at all — whatever is stored at commit time wins.
if m.Password == "" || m.Password == "••••••••" {
res, e := database(r).ExecContext(r.Context(), `UPDATE "ImapConfig" SET host=$1,port=$2,tls=$3,username=$4,folder=$5 WHERE "userId"=$6`, m.Host, m.Port, m.TLS, m.Username, m.Folder, user(r))
if e != nil {
respond(w, 0, nil, e)
return
}
if n, _ := res.RowsAffected(); n == 0 {
// No configuration exists to keep a password from (or it was
// removed concurrently): initial creation requires a real one.
respond(w, 0, nil, bad("Password required"))
return
}
respond(w, 200, OK{true}, nil)
return
}
pw, e := crypt.Encrypt(m.Password, config.Load().EncryptionSecret())
if e != nil {
respond(w, 0, nil, e)
return
}
_, e = database(r).ExecContext(r.Context(), `INSERT INTO "ImapConfig"(id,host,port,tls,username,password,folder,"userId") VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT("userId") DO UPDATE SET host=EXCLUDED.host,port=EXCLUDED.port,tls=EXCLUDED.tls,username=EXCLUDED.username,password=EXCLUDED.password,folder=EXCLUDED.folder`, db.NewID(), m.Host, m.Port, m.TLS, m.Username, pw, m.Folder, user(r))
respond(w, 200, OK{true}, e)
}
func HandleImapTest(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
m, e := mailboxData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
if m.Password == "" || m.Password == "••••••••" {
saved, e := signals.LoadMailbox(r.Context(), database(r), user(r))
if errors.Is(e, db.ErrNotFound) {
respond(w, 0, nil, bad("Password required"))
return
}
if e != nil {
respond(w, 0, nil, e)
return
}
m.Password = saved.Password
}
c, e := signals.Connect(r.Context(), m)
if e != nil {
respond(w, 0, nil, bad("Mailbox connection or login failed"))
return
}
c.Logout()
respond(w, 200, OK{true}, nil)
}
func HandleImapCheck(w http.ResponseWriter, r *http.Request) {
// Bounded, single-connection pass: the watched-bookmark query is capped and
// all searches reuse one IMAP login under an overall deadline (matches the
// scheduler's 45s budget) instead of one fresh connection per bookmark.
bms, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(b) FROM "Bookmark" b WHERE "ownerId"=$1 AND "imapWatchEnabled" ORDER BY "createdAt" LIMIT 50`, user(r))
if e != nil {
respond(w, 0, nil, e)
return
}
matches := []object{}
watched := make([]object, 0, len(bms))
for _, b := range bms {
if str(b, "imapQuery") != "" {
watched = append(watched, b)
}
}
if len(watched) > 0 {
m, e := signals.LoadMailbox(r.Context(), database(r), user(r))
if e != nil {
respond(w, 0, nil, bad("Configure IMAP first"))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel()
c, e := signals.Connect(ctx, m)
if e != nil {
respond(w, 0, nil, bad("Mailbox connection or login failed"))
return
}
stop := context.AfterFunc(ctx, func() { c.Terminate() })
defer stop()
defer c.Logout()
searchFailed := false
for _, b := range watched {
events, e := signals.SearchConn(c, m, "", str(b, "imapQuery"))
// A search can fail after returning partial events (review
// follow-up). Surface those matches and the failure together;
// only a failure with nothing found stays a plain 400.
for _, event := range events {
matches = append(matches, object{"bookmarkId": b["id"], "title": event.Title, "from": event.Summary, "externalId": event.ExternalID, "occurredAt": event.OccurredAt})
}
if e != nil {
searchFailed = true
break
}
}
if searchFailed {
if len(matches) == 0 {
respond(w, 0, nil, bad("Mailbox search failed"))
return
}
respond(w, 200, object{"matches": matches, "error": "Mailbox search failed (partial results)"}, nil)
return
}
}
respond(w, 200, object{"matches": matches}, nil)
}
+120
View File
@@ -0,0 +1,120 @@
package handlers
import (
"net/url"
"testing"
)
func TestMailboxRejectsUnusualPort(t *testing.T) {
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "")
t.Setenv("INTEGRATIONS_ALLOW_INSECURE_IMAP", "")
if _, e := mailboxData(object{"host": "mail.example.com", "username": "u", "password": "p", "port": float64(22), "tls": true}); e == nil {
t.Fatal("mailbox accepted a non-IMAP port")
}
if _, e := mailboxData(object{"host": "mail.example.com", "username": "u", "password": "p", "port": float64(993), "tls": true}); e != nil {
t.Fatalf("normal IMAP port rejected: %v", e)
}
}
// TestResolveCardDAVTarget pins review CR-01: the COMPLETE resolved
// destination is revalidated, so a same-host absolute http:// path cannot
// downgrade an https base and carry Basic credentials over plaintext.
func TestResolveCardDAVTarget(t *testing.T) {
base, e := url.Parse("https://cloud.example.com/remote.php/dav")
if e != nil {
t.Fatal(e)
}
accepted := []string{
"/remote.php/dav/addressbooks/users/alice/contacts/",
"addressbooks/users/alice/contacts/",
"https://cloud.example.com/remote.php/dav/addressbooks/users/alice/contacts/",
}
for _, path := range accepted {
target, e := resolveCardDAVTarget(base, path)
if e != nil {
t.Fatalf("resolveCardDAVTarget(%q) rejected: %v", path, e)
}
if target.Scheme != "https" || target.Host != "cloud.example.com" {
t.Fatalf("resolveCardDAVTarget(%q) resolved to %s", path, target)
}
}
rejected := []string{
// Same host, downgraded scheme — the CR-01 credential-leak vector.
"http://cloud.example.com/remote.php/dav/addressbooks/users/alice/contacts/",
// Cross-host absolute URLs.
"https://evil.example.com/remote.php/dav/",
"http://evil.example.com/remote.php/dav/",
// Scheme-relative and userinfo tricks.
"//evil.example.com/dav/",
"https://alice:pw@cloud.example.com/dav/",
}
for _, path := range rejected {
if target, e := resolveCardDAVTarget(base, path); e == nil {
t.Fatalf("resolveCardDAVTarget(%q) accepted, resolved to %s", path, target)
}
}
}
// TestParseVCardSubset pins the documented parser subset (review CR-08):
// repeated properties are preserved, PREF selection is deterministic,
// groups/params/escapes/folding are handled, and base64-encoded properties
// are dropped instead of silently misparsed.
func TestParseVCardSubset(t *testing.T) {
card := "BEGIN:VCARD\r\n" +
"VERSION:3.0\r\n" +
"UID:uid-1\r\n" +
"item1.FN:Preferred Name\r\n" +
"item1.X-ABLabel:Main\r\n" +
"EMAIL;TYPE=WORK:work@computer.test\r\n" +
"EMAIL;TYPE=PREF:pref@computer.test\r\n" +
"EMAIL:plain@computer.test\r\n" +
"TEL;TYPE=HOME,VOICE:+1 555 0100\r\n" +
"NOTE:folded line\r\n" +
" continuation\r\n" +
"X-ESCAPED:a\\,b\\;c\\\\d\\ne\r\n" +
"PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRg==\r\n" +
"END:VCARD"
v := parseVCard(card)
if got := vCardPick(v["UID"]); got != "uid-1" {
t.Fatalf("UID = %q", got)
}
// Group prefix stripped (item1.FN -> FN).
if got := vCardPick(v["FN"]); got != "Preferred Name" {
t.Fatalf("FN = %q", got)
}
// All three EMAIL occurrences survive; TYPE=PREF wins deterministically.
if len(v["EMAIL"]) != 3 {
t.Fatalf("repeated EMAILs not preserved: %d", len(v["EMAIL"]))
}
if got := vCardPick(v["EMAIL"]); got != "pref@computer.test" {
t.Fatalf("PREF selection = %q", got)
}
// vCard 4.0 numeric PREF ranks below any TYPE=PREF-free occurrence.
num := parseVCard("EMAIL:a@x.test\r\nEMAIL;PREF=2:b@x.test\r\nEMAIL;PREF=1:c@x.test\r\n")
if got := vCardPick(num["EMAIL"]); got != "c@x.test" {
t.Fatalf("numeric PREF selection = %q", got)
}
// Multi-value parameters (TYPE=HOME,VOICE) are split.
tel := v["TEL"]
if len(tel) != 1 || tel[0].params["TYPE"][0] != "HOME" || tel[0].params["TYPE"][1] != "VOICE" {
t.Fatalf("TEL params = %+v", tel)
}
// CRLF-space folding unfolds into one value: the first space is the fold
// marker, the second is content.
if got := vCardPick(v["NOTE"]); got != "folded line continuation" {
t.Fatalf("NOTE unfolding = %q", got)
}
// Escapes decode.
if got := vCardPick(v["X-ESCAPED"]); got != "a,b;c\\d\ne" {
t.Fatalf("escapes = %q", got)
}
// Base64-encoded properties are dropped, not stored as garbage.
if _, ok := v["PHOTO"]; ok {
t.Fatal("base64-encoded PHOTO was parsed instead of dropped")
}
// Missing properties pick to "".
if got := vCardPick(v["ORG"]); got != "" {
t.Fatalf("missing property = %q", got)
}
}
+203
View File
@@ -0,0 +1,203 @@
package handlers
import (
"context"
"database/sql"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/markdown"
"net/http"
"strings"
"time"
)
// pageView is the canonical page projection over any queryer. HandlePagesUpdate
// reads it INSIDE the write transaction (review CR-18): a post-commit query
// could observe another writer's committed revision, and the acknowledgement
// would then describe state this write did not produce — a client adopting it
// could overwrite the other writer on its next save.
func pageView(ctx context.Context, q db.Queryer, id, uid string) (object, error) {
// Linked bookmarks use the viewer projection (review PRIV-02): a page can
// link bookmarks shared with its owner, and those arrive narrowed.
return db.One(ctx, q, `SELECT to_jsonb(p)||jsonb_build_object('bookmarks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('bookmark',`+db.BookmarkViewerView+`) ORDER BY pb.position) FROM "PageBookmark" pb JOIN "Bookmark" b ON b.id=pb."bookmarkId" WHERE pb."pageId"=p.id AND `+db.BookmarkVisible+`),'[]'::jsonb)) FROM "Page" p WHERE p.id=$1 AND p."ownerId"=$2`, id, uid)
}
func page(r *http.Request, id string) (object, error) {
return pageView(r.Context(), database(r), id, user(r))
}
func HandlePagesList(w http.ResponseWriter, r *http.Request) {
// Review PERF-01: the list is a metadata projection — content bodies stay
// out of it (the sidebar/tab strip only render titles) and are fetched on
// demand via GET /api/pages/{id}. Search still matches content server-side.
// Escape LIKE wildcards (review PERF-2): "%" and "_" in a search are
// literal characters, not patterns. The pg_trgm indexes on title/content
// let Postgres use the same ILIKE predicate without scanning every page.
search := "%" + likeEscaper.Replace(r.URL.Query().Get("search")) + "%"
v, e := db.Many(r.Context(), database(r), `SELECT (to_jsonb(p)-'content')||jsonb_build_object('_count',jsonb_build_object('bookmarks',(SELECT count(*) FROM "PageBookmark" pb WHERE pb."pageId"=p.id))) FROM "Page" p WHERE "ownerId"=$1 AND (title ILIKE $2 ESCAPE '\' OR content ILIKE $2 ESCAPE '\') ORDER BY pinned DESC,position,"createdAt"`, user(r), search)
respond(w, 200, v, e)
}
func pageData(b object) (object, error) {
d := fields(b, "title content icon pinned")
if e := stringFields(d, "icon", "title content"); e != nil {
return nil, e
}
if e := boolFields(d, "pinned"); e != nil {
return nil, e
}
if e := limitText(d, "content", maxPageContentLen); e != nil {
return nil, e
}
if e := limitText(d, "icon", maxPageIconLen); e != nil {
return nil, e
}
if _, ok := d["title"]; ok {
s := strings.TrimSpace(str(d, "title"))
if s == "" {
s = "Untitled page"
}
rs := []rune(s)
if len(rs) > 120 {
rs = rs[:120]
}
d["title"] = string(rs)
}
return d, nil
}
func HandlePagesCreate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
d, e := pageData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
if _, ok := d["title"]; !ok {
d["title"] = "Untitled page"
}
d["id"], d["ownerId"] = db.NewID(), user(r)
var v object
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
var n int
if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "Page" WHERE "ownerId"=$1`, user(r)).Scan(&n); e != nil {
return e
}
d["position"] = n
var e error
v, e = db.Insert(r.Context(), tx, "Page", d)
return e
})
respond(w, 201, v, e)
}
func HandlePagesReorder(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
list, e := ids(b, "order")
if e == nil {
e = reorder(r, "Page", "ownerId", list)
}
respond(w, 200, OK{true}, e)
}
func HandlePagesGet(w http.ResponseWriter, r *http.Request) {
v, e := page(r, r.PathValue("id"))
respond(w, 200, v, e)
}
func HandlePagesUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
b, ok := read(w, r)
if !ok {
return
}
d, e := pageData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
// Optimistic concurrency (review DATA-01): a client that sends the
// version it edited from gets a 409 instead of silently overwriting a
// newer save from another tab or device. The revision counter tracks
// content edits — metadata patches (title/pin/links) neither require nor
// bump it, so they cannot invalidate an editor's in-flight draft.
baseVersion, hasVersion := 0, false
if v, ok := b["version"]; ok {
f, isNum := v.(float64)
if !isNum || f < 0 || f != float64(int64(f)) {
respond(w, 0, nil, bad("version must be a non-negative integer"))
return
}
baseVersion, hasVersion = int(f), true
}
// Review DATA-05: concurrency must not be optional at the API boundary.
// A content write without a base version is rejected outright — otherwise
// any script or stale client could silently clobber newer content by
// omitting the field. Explicit overwrites re-fetch the latest version and
// send it (web `resolveOverwrite`); they never bypass this check.
if _, hasContent := d["content"]; hasContent && !hasVersion {
respond(w, 0, nil, bad("version is required when saving content; refetch the page and retry"))
return
}
d["updatedAt"] = time.Now().UTC()
// Review CR-18: the response snapshot is captured inside the same
// transaction as the write, so the acknowledged revision always
// describes exactly the state this request produced — never a later
// commit by a competing writer.
var v object
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
// Same locking discipline as page creation/reorder: serializes
// max(position)+1 so concurrent link adds cannot duplicate positions.
if e := lockUser(r, tx); e != nil {
return e
}
current, e := owned(r, tx, "Page", id, "ownerId")
if e != nil {
return e
}
if hasVersion {
stored, _ := current["version"].(float64)
if int(stored) != baseVersion {
return conflict("Page was modified elsewhere; reload to see the latest version")
}
}
if _, e := db.Update(r.Context(), tx, "Page", id, "ownerId", user(r), d); e != nil {
return e
}
if _, ok := d["content"]; ok {
if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET version=version+1 WHERE id=$1`, id); e != nil {
return e
}
}
if bid := str(b, "addBookmarkId"); bid != "" {
if _, e := db.One(r.Context(), tx, `SELECT to_jsonb(b) FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible, bid, user(r)); e != nil {
return bad("Bookmark is not accessible")
}
_, e := tx.ExecContext(r.Context(), `INSERT INTO "PageBookmark"("pageId","bookmarkId",position) SELECT $1,$2,COALESCE(max(position),-1)+1 FROM "PageBookmark" WHERE "pageId"=$1 ON CONFLICT DO NOTHING`, id, bid)
if e != nil {
return e
}
}
if bid := str(b, "removeBookmarkId"); bid != "" {
_, e := tx.ExecContext(r.Context(), `DELETE FROM "PageBookmark" WHERE "pageId"=$1 AND "bookmarkId"=$2`, id, bid)
if e != nil {
return e
}
}
v, e = pageView(r.Context(), tx, id, user(r))
return e
})
respond(w, 200, v, e)
}
func HandlePagesDelete(w http.ResponseWriter, r *http.Request) {
respond(w, 200, OK{true}, remove(r, "Page", r.PathValue("id"), "ownerId"))
}
func HandlePagesMarkdown(w http.ResponseWriter, r *http.Request) {
v, e := page(r, r.PathValue("id"))
if e != nil {
respond(w, 0, nil, e)
return
}
markdown.Send(w, markdown.PageDocument(v), str(v, "title"))
}
+15
View File
@@ -0,0 +1,15 @@
package handlers
import "net/http"
// HandlePublicBookmark serves the unauthenticated share page. The projection
// in bookmark(..., public=true) is an allowlist; private metadata (contact,
// owner identity, imap settings, alerts, kanban placement, shares, page
// links) is never loaded — review PRIV-04 removed the owner-name attribution
// so the API matches the README's promise that owner identity stays private.
// Notes and dueDate ARE published by product decision — the share UI states
// exactly what a public link exposes.
func HandlePublicBookmark(w http.ResponseWriter, r *http.Request) {
v, e := bookmark(r, r.PathValue("id"), true)
respond(w, 200, v, e)
}
+118
View File
@@ -0,0 +1,118 @@
package handlers
import (
"context"
"encoding/json"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/signals"
"net/http"
"strings"
"time"
)
func HandleSignalsList(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, e := bookmark(r, id, false); e != nil {
respond(w, 0, nil, e)
return
}
sources, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(s) FROM "SignalSource" s WHERE "bookmarkId"=$1 AND "ownerId"=$2 ORDER BY "createdAt"`, id, user(r))
if e != nil {
respond(w, 0, nil, e)
return
}
events, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(s) FROM "Signal" s WHERE "bookmarkId"=$1 AND "ownerId"=$2 ORDER BY "occurredAt" DESC LIMIT 50`, id, user(r))
respond(w, 200, object{"sources": sources, "signals": events, "types": signals.PublicSourceTypes()}, e)
}
func HandleSignalsCreate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, e := owned(r, database(r), "Bookmark", id, "ownerId"); e != nil {
respond(w, 0, nil, e)
return
}
b, ok := read(w, r)
if !ok {
return
}
if str(b, "action") == "check" {
// Review COR-4: a manual "Check now" must not race with the scheduler
// or another click. Take the SAME advisory lease the scheduler uses —
// identical key, held on a dedicated connection because session-level
// advisory locks are connection-bound (a pooled lock/unlock could land
// on different connections and leak the lock) — and answer 409 while
// it is held. The 45-second cap keeps the pass inside the server's
// WriteTimeout so it cannot be cut off mid-mutation.
ctx := r.Context()
leaseKey := "signal-poll:" + user(r)
conn, e := database(r).Conn(ctx)
if e != nil {
respond(w, 0, nil, e)
return
}
defer conn.Close()
var locked bool
if e := conn.QueryRowContext(ctx, `SELECT pg_try_advisory_lock(hashtextextended($1,0))`, leaseKey).Scan(&locked); e != nil {
respond(w, 0, nil, e)
return
}
if !locked {
respond(w, 0, nil, conflict("A check is already running"))
return
}
defer conn.ExecContext(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock(hashtextextended($1,0))`, leaseKey)
job, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
res, e := signals.RunSignalChecks(job, database(r), user(r), id)
if e != nil {
respond(w, 0, nil, e)
return
}
// Review CR-16: a manual check answers 200 with the structured
// per-source outcome (detail lives in each source's lastStatus);
// failed sources are counted, not folded into a blanket 500.
respond(w, 200, object{"checked": res.Sources, "created": res.Created, "failed": res.Failed}, nil)
return
}
if str(b, "type") != "email" {
respond(w, 0, nil, bad("Unknown signal type"))
return
}
cfg, ok := b["config"].(map[string]any)
if !ok {
respond(w, 0, nil, bad("Config must be an object"))
return
}
clean := object{}
for _, key := range []string{"from", "subject"} {
if v, ok := cfg[key]; ok {
s, ok := v.(string)
if !ok {
respond(w, 0, nil, bad("Config values must be text"))
return
}
rs := []rune(strings.TrimSpace(s))
if len(rs) > 200 {
rs = rs[:200]
}
clean[key] = string(rs)
}
}
raw, _ := json.Marshal(clean)
v, e := db.Insert(r.Context(), database(r), "SignalSource", object{"id": db.NewID(), "bookmarkId": id, "ownerId": user(r), "type": "email", "config": string(raw)})
respond(w, 201, v, e)
}
func HandleSignalSourcesUpdate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
if _, ok = b["enabled"].(bool); !ok {
respond(w, 0, nil, bad("Enabled must be boolean"))
return
}
v, e := db.Update(r.Context(), database(r), "SignalSource", r.PathValue("id"), "ownerId", user(r), object{"enabled": b["enabled"], "updatedAt": time.Now().UTC()})
respond(w, 200, v, e)
}
func HandleSignalSourcesDelete(w http.ResponseWriter, r *http.Request) {
respond(w, 200, OK{true}, remove(r, "SignalSource", r.PathValue("id"), "ownerId"))
}
+96
View File
@@ -0,0 +1,96 @@
package handlers
import (
"database/sql"
"github.com/andrew/go-computer/internal/db"
"net/http"
"strings"
"time"
)
// Sharing metadata (share list, total usage) belongs to the owner alone;
// recipients see the tag plus only their own usage count.
func HandleTagsList(w http.ResponseWriter, r *http.Request) {
v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(t)||jsonb_build_object(
'_count',jsonb_build_object('bookmarks',
CASE WHEN t."ownerId"=$1
THEN (SELECT count(*) FROM "BookmarkTag" bt WHERE bt."tagId"=t.id)
ELSE (SELECT count(*) FROM "BookmarkTag" bt JOIN "Bookmark" b ON b.id=bt."bookmarkId" WHERE bt."tagId"=t.id AND b."ownerId"=$1)
END),
'sharedWith',
CASE WHEN t."ownerId"=$1
THEN COALESCE((SELECT jsonb_agg(to_jsonb(s)||jsonb_build_object('user',jsonb_build_object('id',u.id,'name',u.name,'email',u.email))) FROM "TagShare" s JOIN "User" u ON u.id=s."userId" WHERE s."tagId"=t.id),'[]'::jsonb)
ELSE '[]'::jsonb
END)
FROM "Tag" t WHERE "ownerId"=$1 OR EXISTS(SELECT 1 FROM "TagShare" s WHERE s."tagId"=t.id AND s."userId"=$1) ORDER BY name`, user(r))
respond(w, 200, v, e)
}
func tagData(b object) (object, error) {
d := fields(b, "name color")
if e := stringFields(d, "", "name color"); e != nil {
return nil, e
}
if e := limitText(d, "name", maxTagNameLen); e != nil {
return nil, e
}
if _, ok := d["name"]; ok {
d["name"] = strings.TrimSpace(str(d, "name"))
if d["name"] == "" {
return nil, bad("Name required")
}
}
return d, validColor(d)
}
func HandleTagsCreate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
d, e := tagData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
if str(d, "name") == "" {
respond(w, 0, nil, bad("Name required"))
return
}
d["id"], d["ownerId"] = db.NewID(), user(r)
v, e := db.Insert(r.Context(), database(r), "Tag", d)
respond(w, 201, v, e)
}
func HandleTagsUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, e := owned(r, database(r), "Tag", id, "ownerId"); e != nil {
respond(w, 0, nil, e)
return
}
b, ok := read(w, r)
if !ok {
return
}
d, e := tagData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
list, e := ids(b, "shareUserIds")
if e != nil {
respond(w, 0, nil, e)
return
}
d["updatedAt"] = time.Now().UTC()
var v object
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
var e error
v, e = db.Update(r.Context(), tx, "Tag", id, "ownerId", user(r), d)
if e != nil {
return e
}
return shareRows(r, tx, "TagShare", "tagId", id, list)
})
respond(w, 200, v, e)
}
func HandleTagsDelete(w http.ResponseWriter, r *http.Request) {
respond(w, 200, OK{true}, remove(r, "Tag", r.PathValue("id"), "ownerId"))
}
+25
View File
@@ -0,0 +1,25 @@
package handlers
import (
"github.com/andrew/go-computer/internal/db"
"net/http"
)
func unshare(w http.ResponseWriter, r *http.Request, parent, join, key string) {
id := r.PathValue("id")
if _, e := owned(r, database(r), parent, id, "ownerId"); e != nil {
respond(w, 0, nil, e)
return
}
_, e := database(r).ExecContext(r.Context(), `DELETE FROM `+db.Quote(join)+` WHERE `+db.Quote(key)+`=$1 AND "userId"=$2`, id, r.PathValue("userId"))
respond(w, 200, OK{true}, e)
}
func HandleBookmarkUnshare(w http.ResponseWriter, r *http.Request) {
unshare(w, r, "Bookmark", "BookmarkShare", "bookmarkId")
}
func HandleTagUnshare(w http.ResponseWriter, r *http.Request) {
unshare(w, r, "Tag", "TagShare", "tagId")
}
func HandleSharedDateDelete(w http.ResponseWriter, r *http.Request) {
respond(w, 200, OK{true}, remove(r, "SharedDate", r.PathValue("id"), "creatorId"))
}