Files
verdande/internal/httpapi/server.go
T
kristianwindandClaude Opus 5 7f09b35db5 feat(projekter): en deling siger til, og der er en vej ud af den
Spørgsmålet var, om modtageren ikke burde godkende først. Svaret er nej — men
det, spørgsmålet fik øje på, var rigtigt, og værre end det lød.

Tre ting gjaldt samtidig: man blev tilføjet uden at blive spurgt, man fik ikke
besked, og man kunne ikke gå ud igen. En delt note gav en klokkebesked; et delt
projekt gav ingenting — det stod bare i sidebjælken næste gang, man kiggede,
uden at noget sagde hvornår eller fra hvem. Og handleRemoveMember lå bag
ejer-kravet, så kun ejeren kunne fjerne nogen, også en selv.

Hvert enkelt af de tre er til at leve med. Alle tre sammen gør en deling til
noget, der sker for en.

Ikke et godkendelsestrin: en invitation, der venter på et ja, betyder, at den,
der delte, tror det er gjort, mens arbejdet ligger stille — og noter deles
allerede uden. Det, der manglede, var besked og en udgang.

`project.shared` er ny. Klokkens follow() kunne allerede føre et project_id hen
til projektet, så beskeden bærer sit eget mål og krævede ingen særregel.

Ruten til at fjerne et medlem er flyttet ud af ejer-gruppen, og handleren skiller
nu de to ting ad: ejeren, der fjerner nogen, og den, der selv går. At pege på en
andens medlemskab uden at eje projektet svarer 404 og ikke 403 — "det må du
ikke" bekræfter, at medlemskabet findes. Ejeren kan stadig ikke gå fra sit eget
projekt; det ville være en overdragelse, og den findes ikke endnu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 08:07:46 +02:00

804 lines
34 KiB
Go

// Package httpapi wires verdande's HTTP surface: the REST API under /api/v1, the
// WebSocket endpoint, and the static PWA that the same binary serves.
package httpapi
import (
"encoding/json"
"errors"
"io/fs"
"log/slog"
"net/http"
"os"
"path"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/mail"
"github.com/kristianwind/verdande/internal/mcp"
"github.com/kristianwind/verdande/internal/realtime"
"github.com/kristianwind/verdande/internal/store"
"github.com/kristianwind/verdande/internal/update"
)
// Version is stamped by main at build time, and is what the update check compares
// against. A package-level variable rather than a config field because it is a
// property of the binary, not of how it was configured.
var Version = "dev"
type Server struct {
cfg *config.Config
db *store.DB
log *slog.Logger
web fs.FS
mail *mail.Sender
hub *realtime.Hub
mcp *mcp.Server
updates *update.Checker
// syncing serialises reads of one mailbox against itself. Two runs at once —
// somebody pressing "Fetch now" while the ten-minute sweep is in the middle of
// the same mailbox — both start from the marker as it was, both fetch the same
// messages, and both make tasks out of them. That is how forty copies of one
// mail arrived in a single second.
//
// A map of mutexes rather than one lock: two people's mailboxes have nothing to
// say to each other, and a single lock would make one slow host everybody's
// problem. Never cleaned out, because the key is a mailbox id and there are as
// many of those as there are mailboxes.
syncing sync.Map
// Nil when the base URL is not something an authenticator will accept — a bare
// hostname with no scheme, say. The endpoints answer 503 rather than the
// process refusing to start: passkeys are one way in among three, and an
// instance should not be unreachable because one of them cannot be offered.
passkeys *auth.WebAuthn
// Password guessing is the attack this application is actually exposed to, so
// the endpoints that check a secret are limited separately and more tightly
// than the rest of the API.
loginLimiter *limiter
// Sat kun af prøver; se icsClient().
icsFetch *http.Client
mailLimiter *limiter
hookLimiter *limiter
resetLimiter *limiter
// Second factors are counted against the *account*, not the address. An
// address bucket is defeated by anybody with more than one address, which is
// the ordinary case — and a six-digit code is only safe while guesses are
// scarce, so the scarcity has to be counted against the thing being attacked.
totpLimiter *limiter
// Computed once from the built index.html; see csp.go.
csp string
router chi.Router
}
// New builds the router. `web` is the built SvelteKit app, embedded into the binary
// at build time; a nil value serves an explanatory placeholder instead so that a
// backend-only build still runs and reports its health.
func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server {
s := &Server{
cfg: cfg, db: db, log: log, web: web,
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
hub: realtime.NewHub(log),
loginLimiter: newLimiter(10, 15*time.Minute),
// Generous on purpose: the caller is the mail server, one address delivering
// everything, and a personal instance gets a handful of forwarded mails a
// day. Sixty a minute never touches real delivery and still puts a ceiling
// on the one public write path that has none.
mailLimiter: newLimiter(60, time.Minute),
hookLimiter: newLimiter(60, time.Minute),
resetLimiter: newLimiter(5, time.Hour),
// Tighter than the password bucket, and for a whole hour. Somebody typing
// their own code gets it right on the first or second try; ten in an hour is
// generous for a person and hopeless for a search through a million.
totpLimiter: newLimiter(10, time.Hour),
}
s.csp = contentSecurityPolicy(scriptHashes(web))
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
s.passkeys = w
} else {
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
}
s.mcp = s.buildMCP()
s.updates = update.New(Version, cfg.UpdateCheck)
r := chi.NewRouter()
r.Use(middleware.RequestID)
// Not middleware.RealIP: that believes a forwarded header from anyone, which
// lets a caller pick the address the rate limiter and audit log key on. This
// one reads the header only from a trusted proxy — see realip.go.
r.Use(realIP(cfg))
r.Use(requestLogger(log))
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
r.Use(s.securityHeaders)
// Unauthenticated, and deliberately outside /api/v1: the Rune healthcheck and
// Docker both need this to answer before anyone has logged in.
r.Get("/healthz", s.handleHealth)
// The calendar feed is outside /api/v1 and outside the session: a calendar
// client cannot log in, so the token in the path is the whole credential.
r.Get("/ics/{token}", s.handleICSFeed)
// CalDAV, outside /api/v1 because clients do well-known-path discovery
// against the root and authenticate with Basic rather than a session.
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
// Inbound mail. Delivered by the mail server rather than by a browser, and
// authenticated by the token in the recipient address.
//
// Rate limited for the same reason the beacon is: it is reachable by anybody
// who can route to this instance. It is the weaker case of the two — the
// beacon writes a row for a stranger, this one writes nothing without a
// 256-bit address it has to already know — but an address is shared with mail
// servers and printed on a settings page, and once it is known there was no
// ceiling at all on what could be made with it.
r.Method(http.MethodPost, "/inbound/mail",
s.rateLimit(s.mailLimiter, http.HandlerFunc(s.handleInboundMail)))
// En krog ind i indbakken: en URL, et andet program kan skubbe en linje tekst
// til. Uden for /api/v1 og uden for sessionen af samme grund som feedet og
// posten ovenfor — den, der kalder, er en genvej på en telefon eller et script,
// og kan ikke logge ind, så tokenet i stien er hele legitimationen.
//
// Kun POST. En GET ville være nemmere at kalde, og det er præcis problemet:
// forudindlæsning i en browser, en linkkontrol i en chat og en historik, der
// bliver besøgt igen, ville alle lave opgaver, ingen har bedt om.
//
// Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke
// for den indgående post, og omvendt.
r.Method(http.MethodPost, "/inbound/hook/{token}",
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
// MCP with the token in the query string, for clients that cannot send a
// header. Claude's custom-connector dialog takes a URL and nothing else, so
// /api/v1/mcp — which wants Authorization: Bearer — cannot be configured from
// it at all. Same reasoning as the calendar feed above: a client that cannot
// be told to send a header means the token in the URL is the credential.
//
// Outside /api/v1 deliberately, because it must not be reachable with a
// session cookie. A cookie-authenticated POST with no CSRF check is a
// cross-site request waiting to happen; handleMCPWithKey accepts the key and
// nothing else.
r.Post("/mcp", s.handleMCPWithKey)
// Google sends the browser here after consent. Outside /api/v1 because it is a
// top-level navigation rather than a fetch, but still behind the session — the
// tokens it exchanges belong to whoever is signed in.
r.Group(func(r chi.Router) {
r.Use(s.requireAuth)
r.Get("/oauth/gmail/callback", s.handleGmailCallback)
// The calendar comes back on a URI of its own rather than sharing Gmail's.
// One callback deciding from stored state which of the two flows had
// returned would put the choice of *which token store to write* behind a
// lookup — and when that goes wrong it goes wrong by overwriting a working
// Gmail connection with calendar tokens.
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
})
r.Route("/caldav", s.caldavRoutes)
r.Route("/api/v1", func(r chi.Router) {
r.Use(s.requireCSRF)
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// The beacon's receiving end, and the number it produces. Both without a
// session, because the caller is a stranger's installation — that is the
// whole feature. Rate limited like the login routes: it is the one endpoint
// anybody on the internet can write to, and a row per request is a row per
// request whether or not the request is honest.
//
// Refused with a 404 unless this instance has been told it is the collector,
// so an ordinary installation is not a spam sink that answers 200 to
// anything.
r.Method(http.MethodPost, "/beacon",
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeaconPing)))
r.Get("/beacon/count", s.handleBeaconCount)
// Reachable without being signed in — these are how you become signed in.
r.Route("/auth", func(r chi.Router) {
r.Get("/setup", s.handleSetupState)
r.Method(http.MethodPost, "/setup",
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBootstrap)))
r.Method(http.MethodPost, "/login",
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleLogin)))
r.Method(http.MethodPost, "/signup",
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleSignup)))
r.Method(http.MethodPost, "/password/forgot",
s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleForgotPassword)))
r.Method(http.MethodPost, "/password/reset",
s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleResetPassword)))
// Signing in with a passkey. Rate-limited like the password path: a
// signature cannot be guessed, but the endpoint can still be used to
// hammer the database.
r.Method(http.MethodPost, "/passkey/login/begin",
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeginPasskeyLogin)))
r.Method(http.MethodPost, "/passkey/login/finish",
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleFinishPasskeyLogin)))
// The second step of a login: a session that has passed the password
// but not yet the code, and can reach nothing else.
r.Group(func(r chi.Router) {
r.Use(s.requirePendingSession)
r.Method(http.MethodPost, "/login/totp",
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleLoginTOTP)))
})
r.Group(func(r chi.Router) {
r.Use(s.requireAuth)
r.Get("/me", s.handleMe)
r.Patch("/me", s.handleUpdateProfile)
r.Put("/sidebar-sections", s.handleSetSidebarSections)
r.Put("/nav-order", s.handleSetNavOrder)
r.Post("/logout", s.handleLogout)
r.Post("/password/change", s.handleChangePassword)
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
// Behind requireSession, like the API tokens and for the same
// reason: a leaked token must not be able to end the sessions of
// the person it was stolen from, or list their devices.
r.Group(func(r chi.Router) {
r.Use(s.requireSession)
r.Get("/sessions", s.handleListSessions)
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
// Turning the second factor on, and the codes that get you back
// in. Sessions only, and setup asks for the password.
//
// These sat outside this group, which meant the door opened with
// a token and closed only with a password: `disable` has always
// asked for the password, `setup` asked for nothing. A stolen
// token could therefore fetch the secret, load it into its own
// authenticator, confirm it, and keep the only copy of the
// recovery codes. The owner's next login would stop at a code
// they do not have, and they could not turn it off without
// logging in first. A theft becomes a tenancy — the same
// sentence written next door about passkeys, and the same rule.
r.Post("/totp/setup", s.handleTOTPSetup)
r.Post("/totp/confirm", s.handleTOTPConfirm)
r.Post("/totp/disable", s.handleTOTPDisable)
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
// Registering a key, and the list of them. Sessions only, for
// the same reason: a leaked token must not be able to add its
// own way in — that would turn a theft into a tenancy.
r.Get("/passkeys", s.handleListPasskeys)
r.Post("/passkeys/register/begin", s.handleBeginPasskeyRegistration)
r.Post("/passkeys/register/finish", s.handleFinishPasskeyRegistration)
r.Patch("/passkeys/{passkeyID}", s.handleRenamePasskey)
r.Delete("/passkeys/{passkeyID}", s.handleDeletePasskey)
})
})
})
// Everything past this point needs a complete login.
r.Group(func(r chi.Router) {
r.Use(s.requireAuth)
r.Get("/ws", s.handleWebSocket)
// The MCP endpoint. Reachable with a personal API token, so a connector
// sees exactly what its owner sees.
r.Post("/mcp", s.handleMCP)
r.Get("/today", s.handleToday)
r.Get("/filters/preview", s.handlePreviewFilter)
r.Route("/filters", func(r chi.Router) {
r.Get("/", s.handleListFilters)
r.Post("/", s.handleCreateFilter)
r.Get("/{filterID}/tasks", s.handleRunFilter)
r.Patch("/{filterID}", s.handleUpdateFilter)
r.Delete("/{filterID}", s.handleDeleteFilter)
})
r.Get("/feed", s.handleGetFeed)
r.Route("/notifications", func(r chi.Router) {
r.Get("/", s.handleListNotifications)
r.Post("/read", s.handleMarkNotificationsRead)
r.Post("/{notificationID}/read", s.handleMarkNotificationsRead)
})
r.Route("/push", func(r chi.Router) {
r.Get("/key", s.handlePushKey)
r.Post("/subscribe", s.handleSubscribePush)
r.Post("/unsubscribe", s.handleUnsubscribePush)
// One notification, to this account's own devices, reporting what
// each of them did with it. The answer to "der kommer ikke nogen".
r.Post("/test", s.handlePushTest)
})
r.Route("/attachments/{attachmentID}", func(r chi.Router) {
r.Get("/", s.handleDownloadAttachment)
r.Delete("/", s.handleDeleteAttachment)
})
r.Route("/comments/{commentID}", func(r chi.Router) {
r.Patch("/", s.handleUpdateComment)
r.Delete("/", s.handleDeleteComment)
})
r.Post("/feed/rotate", s.handleRotateFeed)
r.Get("/mail-address", s.handleGetMailAddress)
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
r.Get("/hook-url", s.handleGetHookURL)
r.Post("/hook-url/rotate", s.handleRotateHookURL)
// Personal API tokens. Behind requireSession rather than the ambient
// auth: a token must not be able to mint or revoke another.
r.Route("/tokens", func(r chi.Router) {
r.Use(s.requireSession)
r.Get("/", s.handleListAPITokens)
r.Post("/", s.handleCreateAPIToken)
r.Delete("/{tokenID}", s.handleDeleteAPIToken)
})
r.Route("/ai", func(r chi.Router) {
r.Get("/settings", s.handleGetAISettings)
r.Put("/settings", s.handleSetAISettings)
r.Post("/summary", s.handleAISummary)
r.Post("/tasks/{taskID}/split", s.handleAISplit)
// Forslag, ikke skrivninger. De to første svarer med linjer i
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
// der sker, når nogen siger ja til én af dem.
r.Post("/inbox/tidy", s.handleAITidyInbox)
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
r.Post("/notes/{noteID}/actions", s.handleAINoteActions)
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
// Dagens plan: indstillingen, og en knap til at se den nu. En
// indstilling, man skal vente til i morgen for at se virkningen af,
// er en indstilling, ingen tør slå til.
r.Get("/plan", s.handleGetPlanSettings)
r.Put("/plan", s.handleSetPlanSettings)
r.Post("/plan/now", s.handlePlanNow)
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
// det er bygget på.
r.Post("/ask", s.handleAIAsk)
})
r.Get("/version", s.handleVersion)
// Mailboxes read over IMAP — iCloud, Fastmail, any ordinary host. Each
// belongs to the person who connected it, so there is no admin gate
// here and no shared registration: the credential is theirs.
// Notes. Listing and searching are the same endpoint because they answer
// the same question with different words; the backwards one — what has
// been written about this task — hangs off the thing being asked about.
r.Route("/notes", func(r chi.Router) {
r.Get("/", s.handleListNotes)
r.Post("/", s.handleCreateNote)
r.Post("/import", s.handleImportNotes)
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
// Sharing a note with a person directly — its own access list, beside
// the project path. Owner only; see notes_share_handlers.go.
r.Get("/{noteID}/shares", s.handleListNoteShares)
r.Post("/{noteID}/shares", s.handleShareNote)
r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote)
// En invitation til en note er en deling, der venter på en konto.
// Den kan tages tilbage indtil den bruges; se handleShareNote.
r.Delete("/{noteID}/invites/{inviteID}", s.handleDeleteNoteInvite)
r.Get("/{noteID}", s.handleGetNote)
r.Patch("/{noteID}", s.handleUpdateNote)
r.Delete("/{noteID}", s.handleDeleteNote)
// A picture pasted or dropped into a note. The import could bring
// files in and nothing else could, which made the most ordinary act
// in a notes app — paste a screenshot — do nothing at all.
r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment)
// Mange på én gang. Efter en import af tolv hundrede noter er det at
// vælge halvtreds det almindelige tilfælde, ikke det yderste.
r.Post("/bulk", s.handleNoteBulk)
})
r.Route("/mailboxes", func(r chi.Router) {
r.Get("/", s.handleListMailboxes)
r.Post("/", s.handleAddMailbox)
r.Delete("/{mailboxID}", s.handleDeleteMailbox)
r.Post("/{mailboxID}/sync", s.handleSyncMailbox)
})
r.Route("/gmail", func(r chi.Router) {
r.Get("/", s.handleGetGmail)
r.Put("/", s.handleSetGmail)
r.Delete("/", s.handleDisconnectGmail)
r.Post("/authorize", s.handleGmailAuthorize)
r.Post("/sync", s.handleGmailSyncNow)
// The OAuth client itself is the instance's registration with
// Google, not anybody's mailbox — so unlike everything above it, it
// is administrators only, and behind a session as well: a leaked
// token must not be able to read or replace this instance's
// identity to Google.
r.Group(func(r chi.Router) {
r.Use(s.requireSession, s.requireAdmin)
r.Get("/client", s.handleGetGmailClient)
r.Put("/client", s.handleSetGmailClient)
})
})
// A calendar read from somewhere else, laid under verdande's own dated
// tasks. Read-only, so there is no write here to gate: the events are a
// copy of somebody else's calendar, and the only thing a person decides
// is which of them to look at.
r.Route("/calendar", func(r chi.Router) {
r.Get("/", s.handleGetCalendar)
r.Put("/calendars", s.handleSetCalendars)
r.Delete("/", s.handleDisconnectCalendar)
r.Post("/authorize", s.handleCalendarAuthorize)
r.Post("/sync", s.handleCalendarSyncNow)
r.Get("/events", s.handleCalendarEvents)
// Abonnementer: en kalender, man kun har en adresse til. Flere pr.
// person, hvor Google er én — det er adressen, der er identiteten,
// hvor tokenet er det for OAuth.
r.Post("/subscriptions", s.handleSubscribeCalendar)
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
})
r.Route("/import", func(r chi.Router) {
r.Post("/todoist", s.handleImportTodoist)
r.Post("/csv", s.handleImportCSV)
})
r.Get("/export/account", s.handleExportAccount)
r.Get("/export/notes.zip", s.handleExportNotes)
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
r.Route("/templates", func(r chi.Router) {
r.Get("/", s.handleListTemplates)
r.Post("/", s.handleSaveTemplate)
r.Post("/{templateID}/use", s.handleUseTemplate)
r.Delete("/{templateID}", s.handleDeleteTemplate)
})
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
r.Route("/labels", func(r chi.Router) {
r.Get("/", s.handleListLabels)
r.Post("/", s.handleCreateLabel)
r.Patch("/{labelID}", s.handleUpdateLabel)
r.Delete("/{labelID}", s.handleDeleteLabel)
})
r.Get("/upcoming", s.handleUpcoming)
r.Get("/delegated", s.handleDelegated)
r.Get("/people", s.handlePeople)
r.Get("/search", s.handleSearch)
r.Route("/projects", func(r chi.Router) {
r.Get("/", s.handleListProjects)
r.Post("/", s.handleCreateProject)
// Above /{projectID}, or chi reads "reorder" as an id.
r.Post("/reorder", s.handleReorderProjects)
r.Route("/{projectID}", func(r chi.Router) {
// Read access is the floor for the whole subtree; the writes
// below tighten it to editor or owner individually.
r.Use(s.requireProject(store.RoleViewer))
r.Get("/", s.handleGetProject)
r.Get("/sections", s.handleListSections)
r.Get("/members", s.handleListMembers)
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
r.Delete("/members/{userID}", s.handleRemoveMember)
r.Get("/activity", s.handleActivity)
r.Group(func(r chi.Router) {
r.Use(s.requireProject(store.RoleEditor))
r.Post("/sections", s.handleCreateSection)
r.Post("/sections/reorder", s.handleReorderSections)
})
r.Group(func(r chi.Router) {
r.Use(s.requireProject(store.RoleOwner))
r.Patch("/", s.handleUpdateProject)
r.Delete("/", s.handleDeleteProject)
r.Post("/invites", s.handleInvite)
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
r.Patch("/members/{userID}", s.handleSetMemberRole)
})
})
})
// The instance's membership. Administrators only, and behind
// requireSession as well as requireAdmin: an API token belongs to a
// person, and a leaked one must not be able to mint an account or
// delete somebody else's.
r.Group(func(r chi.Router) {
r.Use(s.requireSession, s.requireAdmin)
r.Get("/errors", s.handleListErrors)
// Restarting this instance from its own interface. The panel does
// the work — a container cannot replace its own image — and pulls
// `:latest` on the way, which is what makes a restart an update.
r.Get("/panel", s.handlePanelStatus)
r.Post("/panel/restart", s.handleRestartFromPanel)
// Who this instance reports to, and whether it does. An
// administrator's decision, not a person's: there is one
// installation and it either phones home or it does not.
r.Get("/beacon/settings", s.handleBeaconStatus)
r.Put("/beacon/settings", s.handleSetBeacon)
// Beskeden om, hvad der bliver sendt, og svaret på den. I samme
// gruppe som resten: den, der kan slå beaconet fra, er den, der
// skal have beskeden, og at spørge alle andre ville være at
// stille et spørgsmål, de ikke kan svare på.
r.Get("/beacon/notice", s.handleBeaconNotice)
r.Post("/beacon/notice", s.handleAckBeaconNotice)
// A backup file is a complete copy of the database, so this whole
// group is sessions-only as well as administrators-only: a leaked
// token must not be able to download everybody's data.
r.Get("/backups", s.handleListBackups)
r.Post("/backups", s.handleRunBackup)
r.Get("/backups/{backupID}", s.handleDownloadBackup)
// The instance-wide history. Above the per-project one in every
// sense: it crosses projects the caller is not a member of, which
// is the whole reason it is here and not there.
r.Get("/activity", s.handleAuditLog)
r.Get("/activity/events", s.handleAuditEvents)
r.Get("/users", s.handleListUsers)
r.Post("/users", s.handleCreateUser)
r.Patch("/users/{userID}", s.handleUpdateUser)
r.Delete("/users/{userID}", s.handleDeleteUser)
r.Delete("/invites/{inviteID}", s.handleDeleteInvite)
})
// Groups are a person's own headings over their projects, so there is
// no project permission to check — every query here is scoped to the
// caller.
r.Route("/project-groups", func(r chi.Router) {
r.Get("/", s.handleListProjectGroups)
r.Post("/", s.handleCreateProjectGroup)
// Above /{groupID}, or chi reads "reorder" as an id.
r.Post("/reorder", s.handleReorderProjectGroups)
r.Get("/{groupID}", s.handleGetProjectGroup)
r.Patch("/{groupID}", s.handleUpdateProjectGroup)
r.Delete("/{groupID}", s.handleDeleteProjectGroup)
r.Post("/{groupID}/attachments", s.handleUploadGroupAttachment)
})
// The trash is outside the block above: a deleted project is invisible
// to the permission check by design, so these handlers resolve
// ownership themselves.
r.Get("/trash/projects", s.handleListTrashedProjects)
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
r.Route("/sections/{sectionID}", func(r chi.Router) {
r.Patch("/", s.handleUpdateSection)
r.Delete("/", s.handleDeleteSection)
})
r.Route("/tasks", func(r chi.Router) {
r.Get("/", s.handleListTasks)
r.Post("/", s.handleCreateTask)
r.Post("/quick-add", s.handleQuickAdd)
r.Get("/quick-add/preview", s.handleQuickAddPreview)
r.Route("/{taskID}", func(r chi.Router) {
r.Get("/", s.handleGetTask)
r.Patch("/", s.handleUpdateTask)
r.Delete("/", s.handleDeleteTask)
r.Get("/comments", s.handleListComments)
r.Post("/comments", s.handleCreateComment)
r.Post("/attachments", s.handleUploadAttachment)
r.Get("/reminders", s.handleListReminders)
r.Post("/reminders", s.handleCreateReminder)
r.Post("/complete", s.handleCompleteTask)
r.Post("/reopen", s.handleReopenTask)
r.Post("/move", s.handleMoveTask)
r.Post("/snooze", s.handleSnoozeTask)
})
})
})
})
r.NotFound(s.serveWeb)
s.router = r
return s
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.router.ServeHTTP(w, r) }
// Mail and Hub are exposed so the background jobs can deliver through the same
// sender and the same live connections the request path uses — a reminder should
// arrive in an open tab exactly as a colleague's edit does.
func (s *Server) Mail() *mail.Sender { return s.mail }
func (s *Server) Hub() *realtime.Hub { return s.hub }
// handleHealth reports whether verdande can actually serve requests, which means
// reaching the database — a process that is up but cannot read its own data is not
// healthy, and answering 200 for it would keep a broken container in rotation.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := contextWithTimeout(r, 2*time.Second)
defer cancel()
var one int
if err := s.db.QueryRowContext(ctx, `SELECT 1`).Scan(&one); err != nil {
s.log.Error("health check failed", "err", err)
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"status": "unhealthy",
"error": "database unreachable",
})
return
}
// The version travels with the health check because this is the one endpoint
// that answers without a session. "Which version is actually running?" is
// otherwise a question you cannot ask from outside — and after a deploy it is
// the first question there is.
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "version": Version})
}
// serveWeb serves the PWA, falling back to index.html so that client-side routes
// survive a page reload. A request under /api that reaches here is a genuine 404 and
// must say so in JSON rather than being handed the HTML shell — a client parsing
// "<!doctype html>" as a JSON error message is a confusing way to learn about a typo.
func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
// /.well-known/ is reserved by RFC 8615 and is never a route in the app.
// Handing back the shell there does not merely waste a response: it tells
// whoever asked that the thing they were looking for exists.
//
// That is not hypothetical. Claude probes
// /.well-known/oauth-authorization-server before connecting to an MCP server,
// read 200 and a page of HTML as "there is an authorization server here", and
// then failed trying to register with it — reporting a broken sign-in service
// on a server that has none and needs none. A 404 means "no OAuth here", the
// client stops asking, and the key in the URL is used as intended.
if strings.HasPrefix(r.URL.Path, "/.well-known/") {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
if s.web == nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte("verdande — API is running. The web interface was not built into this binary.\n"))
return
}
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if name == "" || name == "." {
name = "index.html"
}
f, err := s.web.Open(name)
if errors.Is(err, fs.ErrNotExist) {
// A client-side route: hand back the shell and let the app route it.
name = "index.html"
f, err = s.web.Open(name)
}
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
defer f.Close()
info, err := f.Stat()
if err != nil || info.IsDir() {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
seeker, ok := f.(interface {
Read([]byte) (int, error)
Seek(int64, int) (int64, error)
})
if !ok {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot serve asset"})
return
}
// Hashed build assets are immutable; the shell must never be cached, or a
// deploy leaves people on the previous version until they hard-reload.
if strings.HasPrefix(name, "_app/immutable/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
http.ServeContent(w, r, name, info.ModTime(), seeker)
}
// securityHeaders sets the headers that are the same on every response.
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy", s.csp)
// The session lives in a __Host- cookie that only exists over HTTPS, so the
// whole security model already assumes TLS. HSTS is what makes that
// assumption hold against a network attacker: without it, the very first
// navigation can be stripped to http:// and the cookie handed over in the
// clear. Gated on the same condition as the cookie's Secure flag, so a
// plain-HTTP dev instance is never told to demand a certificate it cannot
// present. Two years, subdomains included; no preload directive, because
// asking to be hard-coded into browsers is a decision the operator makes
// for their domain, not one a self-hosted app makes for them.
if s.secureCookies() {
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
}
// verdande uses none of the powerful browser features, so the safest policy
// is to turn them all off: an injected element cannot ask for the camera,
// the microphone or the location if the page itself has already declined
// them. Named explicitly rather than with a wildcard because the wildcard
// form of this header is not universally understood, and a directive a
// browser ignores is not a control.
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), "+
"payment=(), usb=(), interest-cohort=()")
// Isolate the browsing context: a window this page opened, or one that
// opened it, cannot keep a handle across the origin boundary. verdande
// never scripts another window, so there is nothing to lose and a whole
// class of cross-window attacks to close.
h.Set("Cross-Origin-Opener-Policy", "same-origin")
next.ServeHTTP(w, r)
})
}
func requestLogger(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
level := slog.LevelInfo
if ww.Status() >= 500 {
level = slog.LevelError
}
// The health check runs every few seconds forever; logging it at info
// buries everything else.
if r.URL.Path == "/healthz" && ww.Status() < 400 {
level = slog.LevelDebug
}
log.Log(r.Context(), level, "request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"bytes", ww.BytesWritten(),
"duration", time.Since(start).Round(time.Millisecond).String(),
// RealIP has already resolved this through the proxy headers, so
// behind a Cloudflare Tunnel it is the caller and not the tunnel.
// The panel's log watchers roll failed logins up per address.
"remote", r.RemoteAddr,
"request_id", middleware.GetReqID(r.Context()),
)
})
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
// The status line is already sent, so there is nothing to report to the
// client; this only ever means a dropped connection.
slog.Debug("write json response", "err", err)
}
}
// NewLogger returns the process logger. Text in development because a person is
// reading it; JSON in production because the panel and log tooling are.
func NewLogger(dev bool) *slog.Logger {
if dev {
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
}
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
}