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.
311 lines
8.8 KiB
Go
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
|
|
}
|