This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user