// 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 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), 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))) // 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) // 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. // 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) 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) 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.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) // 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) // 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 // "" 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})) }