Files
brain/internal/handlers/common.go
T
Andrew S Erwin 4e421f6ea2
release gate / check (push) Failing after 6m43s
Address code review findings across API, web and deployment
High severity:
- wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup
- bucket public rate limits on effective client IP, add per-bookmark budget,
  and forward the signed browser identity from the share page
- disable first-run bootstrap in server deployments and document seeding
- serialize last-admin demotion in one advisory-locked transaction

Medium/low:
- cap and batch share recipient inserts
- add a shared outbound budget for preview/CardDAV/IMAP/signal work
- pin CardDAV dials to 443 and bound IMAP logout
- fix rightward kanban column drags and normalize cards on column delete
- classify backend outages in auth guards, add request timeouts and an
  error boundary, remove the dead login route, surface throttling
- enforce pinned/unpinned page reorder grouping server-side
- avoid preview work for stale bookmark versions; drop duplicate dialog fetch
- sequence-guard collection/tag/calendar/quick-find loads
- validate property config and fix MULTI_SELECT/DATE filter inputs
- document index maintenance; apply same-origin check to sign-out
- trim signup names, unique shared dates and kanban keys (migration 010),
  reset reference cache on identity changes, hide OIDC issuer, return 413
  for oversized direct bodies, escape Markdown link destinations
- remove dead template files and duplicate test helper

Verified with go build/vet, full PostgreSQL race suite, db convergence,
tsc, both ESLint configs and a Next production build.
2026-10-20 00:13:30 -05:00

311 lines
8.8 KiB
Go

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"
"strconv"
"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 err := dec.Decode(&b); err != nil {
// Review L-8: an oversized body is 413, not malformed JSON. The Next
// proxy already answers 413; direct API callers deserve the same
// class of error.
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeJSON(w, 413, Error{"Request body too large (limit 1 MiB)"})
return nil, false
}
writeJSON(w, 400, Error{"Invalid JSON object"})
return nil, false
}
if 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
}
// Share-list caps (review M-1). A 1 MiB JSON body can carry tens of
// thousands of ids; each one used to become an INSERT in a user-locked
// transaction. These are product limits, not storage limits.
const (
maxShareRecipients = 100
maxSharedDateRecipients = 200
)
// cappedIDs is ids plus a length cap (review M-1), returning a clear 400.
func cappedIDs(b object, key string, max int) ([]string, error) {
list, e := ids(b, key)
if e != nil {
return nil, e
}
if len(list) > max {
return nil, bad(fmt.Sprintf("%s may contain at most %d entries", key, max))
}
return list, 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
})
}
// outboundBusy answers 429 with Retry-After when the shared outbound budget
// is exhausted (review M-2).
func outboundBusy(w http.ResponseWriter, retry time.Duration) {
if retry < time.Second {
retry = time.Second
}
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
writeJSON(w, 429, Error{"Too much outbound work right now; try again shortly"})
}
// shareRows inserts every recipient in ONE statement (review M-1). table and
// key come only from the server-side call sites, never from request input.
func shareRows(r *http.Request, tx *sql.Tx, table, key, id string, list []string) error {
if len(list) == 0 {
return nil
}
_, e := tx.ExecContext(r.Context(), `INSERT INTO `+db.Quote(table)+` (`+db.Quote(key)+`,"userId") SELECT $1,unnest($2::text[]) ON CONFLICT DO NOTHING`, id, list)
return e
}