Handlere til at forbinde, koble fra og hente nu, plus baggrundssynk hvert tiende minut ved siden af Gmails. Forbindelsen prøves FØR den gemmes: en postkasse, der blev taget imod og så fejler stille hvert tiende minut, er værre end en, der blev afvist ved døren, mens man stadig sidder med kodeordet i hånden. Ingen "set"-liste til deduplikering som Gmail har. Uid'er er monotone, så last_uid er hele svaret — og den skrives også, når budgettet løb ud, for en kørsel, der lavede opgaver og glemte hvor langt den nåede, laver dem alle igen næste gang. Rundturen køres nu mod go-imap's egen server i processen: flaget post kommer med, uflaget gør ikke, og fortsættelsen fra et uid henter kun det nyere. De hidtidige prøver dækkede de rene dele og ville alle have bestået med selve hentningen i stykker. Testen kræver et selvsigneret certifikat, og klienten nægter dem. Account.RootCAs lader en test TILFØJE tillid; der er stadig ingen måde at fjerne den på. Forskellen er en søm og ikke et hul — en prøve viser, at samme server afvises uden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
152 lines
5.9 KiB
Go
152 lines
5.9 KiB
Go
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"
|
|
|
|
// 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"
|
|
// 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"
|
|
|
|
// 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"
|
|
|
|
// 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"
|
|
)
|
|
|
|
// 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")
|
|
|
|
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"
|
|
}
|