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
+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
}