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.
299 lines
9.8 KiB
Go
299 lines
9.8 KiB
Go
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")
|
|
// The destination column's legacy key, when it has one, becomes the
|
|
// moved cards' kanbanStatus so the two representations do not point
|
|
// at the deleted column (review M-5/L-12). Custom columns have no
|
|
// key; their cards keep the legacy status as a documented fallback.
|
|
destKey := ""
|
|
for _, c := range remaining {
|
|
if str(c, "id") == dest {
|
|
if k := str(c, "key"); validStatus(k) {
|
|
destKey = k
|
|
}
|
|
}
|
|
}
|
|
// Only the three columns ResolveColumnID needs, and one set-based
|
|
// UPDATE for the whole move instead of a round trip per bookmark.
|
|
// Positions are normalized below too (review M-5), so reload order is
|
|
// deterministic instead of interleaving with the destination's cards.
|
|
bms, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',b.id,'kanbanColumnId',b."kanbanColumnId",'kanbanStatus',b."kanbanStatus") FROM "Bookmark" b WHERE "ownerId"=$1 ORDER BY b."kanbanPosition",b."updatedAt" DESC,b.id DESC`, user(r))
|
|
if e != nil {
|
|
return e
|
|
}
|
|
moveIDs := make([]string, 0, len(bms))
|
|
moving := map[string]bool{}
|
|
for _, b := range bms {
|
|
if kanban.ResolveColumnID(b, cols) == id {
|
|
moveIDs = append(moveIDs, str(b, "id"))
|
|
moving[str(b, "id")] = true
|
|
}
|
|
}
|
|
if len(moveIDs) > 0 {
|
|
// 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,"kanbanStatus"=CASE WHEN $4='' THEN "kanbanStatus" ELSE $4::"KanbanStatus" END,version=version+1,"updatedAt"=now() WHERE id=ANY($2::text[]) AND "ownerId"=$3`, dest, moveIDs, user(r), destKey); e != nil {
|
|
return e
|
|
}
|
|
// Append the moved cards after the destination's existing order
|
|
// and renumber contiguously, exactly like a normal card move.
|
|
order := make([]string, 0, len(bms))
|
|
for _, b := range bms {
|
|
bid := str(b, "id")
|
|
if !moving[bid] && kanban.ResolveColumnID(b, remaining) == dest {
|
|
order = append(order, bid)
|
|
}
|
|
}
|
|
order = append(order, moveIDs...)
|
|
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" AS b SET "kanbanPosition"=t.ord-1 FROM unnest($1::text[]) WITH ORDINALITY AS t(bid,ord) WHERE b.id=t.bid AND b."ownerId"=$2`, order, user(r)); e != nil {
|
|
return e
|
|
}
|
|
moved = len(moveIDs)
|
|
}
|
|
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)
|
|
}
|