Fyrre kopier af én mail ankom i samme sekund. Én kørsel kan højst lave femogtyve, så der var to i gang: et tryk på "Hent nu" mens ti-minutters-fejningen allerede var inde i den samme postkasse. Begge læste markøren som den stod, begge hentede de samme beskeder, begge lavede opgaver ud af dem. Markør-rettelsen fra i går var altså rigtig, men ikke nok — den virker, når der kun er én læser. Rækken læses nu igen under låsen, og låsen holdes hele vejen igennem: slap man den, før markøren var skrevet, ville hullet stå åbent præcis der, hvor det skulle lukkes. Et kort pr. postkasse frem for én lås. To menneskers postkasser har intet at sige hinanden, og én fælles lås ville gøre én langsom vært til alles problem. Prøven kører to kørsler oven i hinanden med vilje og tæller, hvor mange der er inde ad gangen — en lås er kun værd at have, hvis noget prøver døren. Den holder også øje med, at to forskellige postkasser ikke venter på hinanden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
615 lines
23 KiB
Go
615 lines
23 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
|
|
resetLimiter *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),
|
|
resetLimiter: newLimiter(5, 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)
|
|
r.Use(middleware.RealIP)
|
|
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.
|
|
r.Post("/inbound/mail", s.handleInboundMail)
|
|
|
|
// 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)
|
|
})
|
|
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"})
|
|
})
|
|
|
|
// 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.Post("/logout", s.handleLogout)
|
|
r.Post("/password/change", s.handleChangePassword)
|
|
r.Post("/totp/setup", s.handleTOTPSetup)
|
|
r.Post("/totp/confirm", s.handleTOTPConfirm)
|
|
r.Post("/totp/disable", s.handleTOTPDisable)
|
|
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
|
|
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
|
|
|
// 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)
|
|
|
|
// 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)
|
|
})
|
|
|
|
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)
|
|
|
|
// 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)
|
|
})
|
|
|
|
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.
|
|
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)
|
|
})
|
|
})
|
|
|
|
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/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)
|
|
r.Get("/activity", s.handleActivity)
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(s.requireProject(store.RoleEditor))
|
|
r.Post("/sections", s.handleCreateSection)
|
|
})
|
|
|
|
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.Patch("/members/{userID}", s.handleSetMemberRole)
|
|
r.Delete("/members/{userID}", s.handleRemoveMember)
|
|
})
|
|
})
|
|
})
|
|
|
|
// 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)
|
|
// 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.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)
|
|
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}))
|
|
}
|