2026-08-16 23:31:29 +02:00
|
|
|
package httpapi
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// APIError is the one shape every failure takes. A client that has learned to read
|
|
|
|
|
// it once can read all of them.
|
|
|
|
|
//
|
|
|
|
|
// `code` is a stable machine-readable string; `error` is English prose for a
|
|
|
|
|
// developer reading a log. Neither is what the user sees — the frontend maps `code`
|
|
|
|
|
// to a Danish or English message, because the server has no business deciding which
|
|
|
|
|
// language somebody's browser is in.
|
|
|
|
|
type APIError struct {
|
|
|
|
|
Code string `json:"code"`
|
|
|
|
|
Message string `json:"error"`
|
|
|
|
|
Fields map[string]string `json:"fields,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e APIError) Error() string { return e.Message }
|
|
|
|
|
|
|
|
|
|
// The codes the API can return. Kept in one list so the frontend's mapping can be
|
|
|
|
|
// checked against it rather than discovered from whatever a handler happened to write.
|
|
|
|
|
const (
|
|
|
|
|
CodeBadRequest = "bad_request"
|
|
|
|
|
CodeValidation = "validation_failed"
|
|
|
|
|
CodeUnauthorized = "unauthorized"
|
|
|
|
|
CodeTOTPRequired = "totp_required"
|
|
|
|
|
CodeForbidden = "forbidden"
|
|
|
|
|
CodeNotFound = "not_found"
|
|
|
|
|
CodeConflict = "conflict"
|
|
|
|
|
CodeRateLimited = "rate_limited"
|
|
|
|
|
CodeInternal = "internal_error"
|
|
|
|
|
CodePayloadTooLarge = "payload_too_large"
|
2026-08-17 14:32:42 +02:00
|
|
|
|
|
|
|
|
// Four situations that used to answer plain `conflict`, which the frontend
|
|
|
|
|
// renders as "that already exists". For these that is not merely vague, it is
|
|
|
|
|
// wrong: an unconfigured Gmail is not a Gmail that is already there, and a
|
|
|
|
|
// second factor that is off is not one that is on. A code exists so the
|
|
|
|
|
// interface can say the true thing — the server's own prose is English for a
|
|
|
|
|
// log, and deciding what a person reads is the frontend's job.
|
|
|
|
|
CodeGmailNotConfigured = "gmail_not_configured"
|
|
|
|
|
CodeAINotConfigured = "ai_not_configured"
|
|
|
|
|
CodeTOTPNotEnabled = "totp_not_enabled"
|
|
|
|
|
CodeInboxProtected = "inbox_protected"
|
2026-08-17 21:10:46 +02:00
|
|
|
// And a fifth, for the same reason: refusing to remove the last administrator
|
|
|
|
|
// is not "that already exists" either, and it is the one refusal on the user
|
|
|
|
|
// page that a person needs explained rather than merely reported.
|
|
|
|
|
CodeLastAdmin = "last_admin"
|
2026-08-18 13:28:44 +02:00
|
|
|
|
|
|
|
|
// Gmail said no. Its own words are the diagnosis — "invalid_grant",
|
|
|
|
|
// "insufficient authentication scopes" — and they are useless behind a generic
|
|
|
|
|
// sentence, which is what `internal_error` gave: a person clicked Fetch now,
|
|
|
|
|
// read "something went wrong", and had nowhere to go. The interface wraps the
|
|
|
|
|
// message in one that says where it came from.
|
|
|
|
|
CodeGmailFailed = "gmail_failed"
|
2026-08-18 22:15:50 +02:00
|
|
|
|
|
|
|
|
// And the same for a mailbox read over IMAP. A host's refusal is the whole
|
|
|
|
|
// diagnosis — a wrong app password, a host that is not listening, a folder that
|
|
|
|
|
// is not there — and it says it in its own words.
|
|
|
|
|
CodeMailboxFailed = "mailbox_failed"
|
2026-08-20 07:01:14 +02:00
|
|
|
|
|
|
|
|
// And for a calendar read from Google. Its own code rather than the Gmail one,
|
|
|
|
|
// because the sentence the interface wraps it in names where it came from — and
|
|
|
|
|
// "Gmail said no" about a calendar sends somebody to the wrong panel.
|
|
|
|
|
CodeCalendarFailed = "calendar_failed"
|
2026-08-16 23:31:29 +02:00
|
|
|
)
|
|
|
|
|
|
2026-08-17 21:10:46 +02:00
|
|
|
// errLastAdmin is returned to the caller that already wrote the response, so it
|
|
|
|
|
// stops rather than carrying on. It is never rendered anywhere.
|
|
|
|
|
var errLastAdmin = errors.New("httpapi: refused to remove the last administrator")
|
|
|
|
|
|
2026-08-16 23:31:29 +02:00
|
|
|
func writeError(w http.ResponseWriter, status int, code, message string) {
|
|
|
|
|
writeJSON(w, status, APIError{Code: code, Message: message})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeFieldErrors(w http.ResponseWriter, fields map[string]string) {
|
|
|
|
|
writeJSON(w, http.StatusUnprocessableEntity, APIError{
|
|
|
|
|
Code: CodeValidation,
|
|
|
|
|
Message: "one or more fields are not valid",
|
|
|
|
|
Fields: fields,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// maxBodyBytes caps a JSON request body at 1 MiB. Task text and comments are small;
|
|
|
|
|
// anything larger is either a mistake or an attempt to make the server allocate.
|
|
|
|
|
// File uploads do not come through here.
|
|
|
|
|
const maxBodyBytes = 1 << 20
|
|
|
|
|
|
|
|
|
|
// decodeJSON reads a request body strictly: unknown fields are an error rather than
|
|
|
|
|
// being ignored.
|
|
|
|
|
//
|
|
|
|
|
// Silently dropping a field the client sent is the worst of the options — a typo in
|
|
|
|
|
// `priorty` becomes "the API accepted my request and ignored half of it", which is
|
|
|
|
|
// discovered much later and by the user rather than by the developer.
|
|
|
|
|
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
|
|
|
|
|
if ct := r.Header.Get("Content-Type"); ct != "" {
|
|
|
|
|
if mt := strings.TrimSpace(strings.Split(ct, ";")[0]); mt != "application/json" {
|
|
|
|
|
writeError(w, http.StatusUnsupportedMediaType, CodeBadRequest,
|
|
|
|
|
"request body must be application/json")
|
|
|
|
|
return errors.New("wrong content type")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
|
|
|
|
dec := json.NewDecoder(r.Body)
|
|
|
|
|
dec.DisallowUnknownFields()
|
|
|
|
|
|
|
|
|
|
if err := dec.Decode(dst); err != nil {
|
|
|
|
|
var maxErr *http.MaxBytesError
|
|
|
|
|
switch {
|
|
|
|
|
case errors.As(err, &maxErr):
|
|
|
|
|
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
|
|
|
|
"request body is too large")
|
|
|
|
|
case errors.Is(err, io.EOF):
|
|
|
|
|
writeError(w, http.StatusBadRequest, CodeBadRequest, "request body is empty")
|
|
|
|
|
default:
|
|
|
|
|
writeError(w, http.StatusBadRequest, CodeBadRequest, jsonErrorMessage(err))
|
|
|
|
|
}
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Exactly one JSON value, so a body with a second object appended cannot smuggle
|
|
|
|
|
// anything past a handler that only reads the first.
|
|
|
|
|
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
|
|
|
|
writeError(w, http.StatusBadRequest, CodeBadRequest,
|
|
|
|
|
"request body must contain a single JSON object")
|
|
|
|
|
return errors.New("trailing data")
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// jsonErrorMessage turns the standard library's decoder errors into something that
|
|
|
|
|
// names the offending field, since "json: cannot unmarshal string into Go struct
|
|
|
|
|
// field taskRequest.priority of type int" is not a sentence to put in an API.
|
|
|
|
|
func jsonErrorMessage(err error) string {
|
|
|
|
|
var typeErr *json.UnmarshalTypeError
|
|
|
|
|
if errors.As(err, &typeErr) {
|
|
|
|
|
if typeErr.Field != "" {
|
|
|
|
|
return "field " + typeErr.Field + " has the wrong type"
|
|
|
|
|
}
|
|
|
|
|
return "a field has the wrong type"
|
|
|
|
|
}
|
|
|
|
|
var syntaxErr *json.SyntaxError
|
|
|
|
|
if errors.As(err, &syntaxErr) {
|
|
|
|
|
return "request body is not valid JSON"
|
|
|
|
|
}
|
|
|
|
|
if strings.HasPrefix(err.Error(), "json: unknown field ") {
|
|
|
|
|
return "unknown field " + strings.TrimPrefix(err.Error(), "json: unknown field ")
|
|
|
|
|
}
|
|
|
|
|
return "request body could not be read"
|
|
|
|
|
}
|