2026-08-16 23:05:19 +02:00
|
|
|
// 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"
|
2026-08-19 08:57:59 +02:00
|
|
|
"sync"
|
2026-08-16 23:05:19 +02:00
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
|
|
2026-08-18 11:56:54 +02:00
|
|
|
"github.com/kristianwind/verdande/internal/auth"
|
2026-08-16 23:05:19 +02:00
|
|
|
"github.com/kristianwind/verdande/internal/config"
|
2026-08-16 23:31:29 +02:00
|
|
|
"github.com/kristianwind/verdande/internal/mail"
|
2026-08-17 07:53:53 +02:00
|
|
|
"github.com/kristianwind/verdande/internal/mcp"
|
2026-08-16 23:44:55 +02:00
|
|
|
"github.com/kristianwind/verdande/internal/realtime"
|
2026-08-16 23:05:19 +02:00
|
|
|
"github.com/kristianwind/verdande/internal/store"
|
2026-08-17 08:21:51 +02:00
|
|
|
"github.com/kristianwind/verdande/internal/update"
|
2026-08-16 23:05:19 +02:00
|
|
|
)
|
|
|
|
|
|
2026-08-17 08:21:51 +02:00
|
|
|
// 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"
|
|
|
|
|
|
2026-08-16 23:05:19 +02:00
|
|
|
type Server struct {
|
2026-08-17 08:21:51 +02:00
|
|
|
cfg *config.Config
|
|
|
|
|
db *store.DB
|
|
|
|
|
log *slog.Logger
|
|
|
|
|
web fs.FS
|
|
|
|
|
mail *mail.Sender
|
|
|
|
|
hub *realtime.Hub
|
|
|
|
|
mcp *mcp.Server
|
|
|
|
|
updates *update.Checker
|
2026-08-16 23:31:29 +02:00
|
|
|
|
2026-08-19 08:57:59 +02:00
|
|
|
// 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
|
|
|
|
|
|
2026-08-18 11:56:54 +02:00
|
|
|
// 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
|
|
|
|
|
|
2026-08-16 23:31:29 +02:00
|
|
|
// 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
|
2026-08-20 08:29:43 +02:00
|
|
|
// 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
|
2026-08-16 23:31:29 +02:00
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
// Computed once from the built index.html; see csp.go.
|
|
|
|
|
csp string
|
|
|
|
|
|
2026-08-16 23:05:19 +02:00
|
|
|
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 {
|
2026-08-16 23:31:29 +02:00
|
|
|
s := &Server{
|
|
|
|
|
cfg: cfg, db: db, log: log, web: web,
|
|
|
|
|
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
2026-08-16 23:44:55 +02:00
|
|
|
hub: realtime.NewHub(log),
|
2026-08-16 23:31:29 +02:00
|
|
|
loginLimiter: newLimiter(10, 15*time.Minute),
|
|
|
|
|
resetLimiter: newLimiter(5, time.Hour),
|
2026-08-20 08:29:43 +02:00
|
|
|
// 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),
|
2026-08-16 23:31:29 +02:00
|
|
|
}
|
2026-08-17 00:03:20 +02:00
|
|
|
s.csp = contentSecurityPolicy(scriptHashes(web))
|
2026-08-18 11:56:54 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-08-17 07:53:53 +02:00
|
|
|
s.mcp = s.buildMCP()
|
2026-08-17 08:21:51 +02:00
|
|
|
s.updates = update.New(Version, cfg.UpdateCheck)
|
2026-08-16 23:05:19 +02:00
|
|
|
|
|
|
|
|
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))
|
2026-08-17 00:03:20 +02:00
|
|
|
r.Use(s.securityHeaders)
|
2026-08-16 23:05:19 +02:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
2026-08-17 07:05:24 +02:00
|
|
|
// 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)
|
|
|
|
|
|
2026-08-17 07:53:53 +02:00
|
|
|
// 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)
|
2026-08-17 08:21:51 +02:00
|
|
|
|
2026-08-17 14:00:39 +02:00
|
|
|
// 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)
|
|
|
|
|
|
2026-08-17 08:21:51 +02:00
|
|
|
// 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)
|
2026-08-20 07:01:14 +02:00
|
|
|
// 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)
|
2026-08-17 08:21:51 +02:00
|
|
|
})
|
2026-08-17 07:53:53 +02:00
|
|
|
r.Route("/caldav", s.caldavRoutes)
|
|
|
|
|
|
2026-08-16 23:05:19 +02:00
|
|
|
r.Route("/api/v1", func(r chi.Router) {
|
2026-08-16 23:31:29 +02:00
|
|
|
r.Use(s.requireCSRF)
|
|
|
|
|
|
2026-08-16 23:05:19 +02:00
|
|
|
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
|
|
|
})
|
2026-08-16 23:31:29 +02:00
|
|
|
|
2026-08-20 06:59:50 +02:00
|
|
|
// 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)
|
|
|
|
|
|
2026-08-16 23:31:29 +02:00
|
|
|
// 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)))
|
|
|
|
|
|
2026-08-18 11:56:54 +02:00
|
|
|
// 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)))
|
|
|
|
|
|
2026-08-16 23:31:29 +02:00
|
|
|
// 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)
|
2026-08-17 10:39:23 +02:00
|
|
|
r.Patch("/me", s.handleUpdateProfile)
|
2026-08-18 09:04:12 +02:00
|
|
|
r.Put("/sidebar-sections", s.handleSetSidebarSections)
|
2026-08-19 17:19:46 +02:00
|
|
|
r.Put("/nav-order", s.handleSetNavOrder)
|
2026-08-16 23:31:29 +02:00
|
|
|
r.Post("/logout", s.handleLogout)
|
|
|
|
|
r.Post("/password/change", s.handleChangePassword)
|
|
|
|
|
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
|
2026-08-17 19:43:49 +02:00
|
|
|
|
|
|
|
|
// 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)
|
2026-08-18 11:56:54 +02:00
|
|
|
|
2026-08-20 08:29:43 +02:00
|
|
|
// 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)
|
|
|
|
|
|
2026-08-18 11:56:54 +02:00
|
|
|
// 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)
|
2026-08-17 19:43:49 +02:00
|
|
|
})
|
2026-08-16 23:31:29 +02:00
|
|
|
})
|
|
|
|
|
})
|
2026-08-16 23:44:55 +02:00
|
|
|
|
|
|
|
|
// Everything past this point needs a complete login.
|
|
|
|
|
r.Group(func(r chi.Router) {
|
|
|
|
|
r.Use(s.requireAuth)
|
|
|
|
|
|
|
|
|
|
r.Get("/ws", s.handleWebSocket)
|
|
|
|
|
|
2026-08-17 07:53:53 +02:00
|
|
|
// The MCP endpoint. Reachable with a personal API token, so a connector
|
|
|
|
|
// sees exactly what its owner sees.
|
|
|
|
|
r.Post("/mcp", s.handleMCP)
|
|
|
|
|
|
2026-08-16 23:44:55 +02:00
|
|
|
r.Get("/today", s.handleToday)
|
2026-08-17 06:57:43 +02:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-17 07:05:24 +02:00
|
|
|
r.Get("/feed", s.handleGetFeed)
|
2026-08-17 07:42:55 +02:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
})
|
2026-08-17 07:05:24 +02:00
|
|
|
r.Post("/feed/rotate", s.handleRotateFeed)
|
|
|
|
|
|
2026-08-17 07:53:53 +02:00
|
|
|
r.Get("/mail-address", s.handleGetMailAddress)
|
|
|
|
|
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
|
|
|
|
|
|
2026-08-17 10:39:23 +02:00
|
|
|
// 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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-17 07:53:53 +02:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-17 08:21:51 +02:00
|
|
|
r.Get("/version", s.handleVersion)
|
|
|
|
|
|
2026-08-18 22:15:50 +02:00
|
|
|
// 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.
|
2026-08-19 13:00:41 +02:00
|
|
|
// 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)
|
2026-08-19 19:12:54 +02:00
|
|
|
r.Post("/import", s.handleImportNotes)
|
2026-08-19 13:00:41 +02:00
|
|
|
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
|
|
|
|
|
r.Get("/{noteID}", s.handleGetNote)
|
|
|
|
|
r.Patch("/{noteID}", s.handleUpdateNote)
|
|
|
|
|
r.Delete("/{noteID}", s.handleDeleteNote)
|
2026-08-20 07:32:03 +02:00
|
|
|
// 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)
|
2026-08-20 11:03:02 +02:00
|
|
|
// 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)
|
2026-08-19 13:00:41 +02:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 22:15:50 +02:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-17 07:53:53 +02:00
|
|
|
r.Route("/gmail", func(r chi.Router) {
|
|
|
|
|
r.Get("/", s.handleGetGmail)
|
|
|
|
|
r.Put("/", s.handleSetGmail)
|
|
|
|
|
r.Delete("/", s.handleDisconnectGmail)
|
2026-08-17 08:21:51 +02:00
|
|
|
r.Post("/authorize", s.handleGmailAuthorize)
|
|
|
|
|
r.Post("/sync", s.handleGmailSyncNow)
|
2026-08-18 09:04:12 +02:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
})
|
2026-08-17 07:53:53 +02:00
|
|
|
})
|
|
|
|
|
|
2026-08-20 07:01:14 +02:00
|
|
|
// 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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-17 07:42:55 +02:00
|
|
|
r.Route("/import", func(r chi.Router) {
|
|
|
|
|
r.Post("/todoist", s.handleImportTodoist)
|
|
|
|
|
r.Post("/csv", s.handleImportCSV)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
r.Get("/export/account", s.handleExportAccount)
|
2026-08-19 19:03:25 +02:00
|
|
|
r.Get("/export/notes.zip", s.handleExportNotes)
|
2026-08-17 07:42:55 +02:00
|
|
|
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
|
|
|
|
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
|
|
|
|
|
2026-08-17 07:05:24 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-08-17 06:57:43 +02:00
|
|
|
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)
|
|
|
|
|
})
|
2026-08-16 23:44:55 +02:00
|
|
|
r.Get("/upcoming", s.handleUpcoming)
|
2026-08-17 19:36:37 +02:00
|
|
|
r.Get("/delegated", s.handleDelegated)
|
2026-08-17 22:31:43 +02:00
|
|
|
r.Get("/people", s.handlePeople)
|
2026-08-16 23:44:55 +02:00
|
|
|
r.Get("/search", s.handleSearch)
|
|
|
|
|
|
|
|
|
|
r.Route("/projects", func(r chi.Router) {
|
|
|
|
|
r.Get("/", s.handleListProjects)
|
|
|
|
|
r.Post("/", s.handleCreateProject)
|
2026-08-17 15:49:52 +02:00
|
|
|
// Above /{projectID}, or chi reads "reorder" as an id.
|
|
|
|
|
r.Post("/reorder", s.handleReorderProjects)
|
2026-08-16 23:44:55 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-08-20 14:56:26 +02:00
|
|
|
r.Post("/sections/reorder", s.handleReorderSections)
|
2026-08-16 23:44:55 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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)
|
2026-08-17 22:14:33 +02:00
|
|
|
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
2026-08-16 23:44:55 +02:00
|
|
|
r.Delete("/members/{userID}", s.handleRemoveMember)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-17 21:10:46 +02:00
|
|
|
// 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)
|
2026-08-17 22:58:02 +02:00
|
|
|
r.Get("/errors", s.handleListErrors)
|
2026-08-18 12:18:35 +02:00
|
|
|
// 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)
|
2026-08-20 06:59:50 +02:00
|
|
|
// 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)
|
2026-08-18 09:04:12 +02:00
|
|
|
// 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)
|
2026-08-17 21:10:46 +02:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
// 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)
|
2026-08-18 09:20:03 +02:00
|
|
|
r.Get("/{groupID}", s.handleGetProjectGroup)
|
2026-08-17 19:25:10 +02:00
|
|
|
r.Patch("/{groupID}", s.handleUpdateProjectGroup)
|
|
|
|
|
r.Delete("/{groupID}", s.handleDeleteProjectGroup)
|
2026-08-18 09:20:03 +02:00
|
|
|
r.Post("/{groupID}/attachments", s.handleUploadGroupAttachment)
|
2026-08-17 19:25:10 +02:00
|
|
|
})
|
|
|
|
|
|
2026-08-17 14:59:51 +02:00
|
|
|
// 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)
|
2026-08-16 23:44:55 +02:00
|
|
|
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)
|
2026-08-17 07:42:55 +02:00
|
|
|
r.Get("/comments", s.handleListComments)
|
|
|
|
|
r.Post("/comments", s.handleCreateComment)
|
|
|
|
|
r.Post("/attachments", s.handleUploadAttachment)
|
2026-08-17 07:05:24 +02:00
|
|
|
r.Get("/reminders", s.handleListReminders)
|
|
|
|
|
r.Post("/reminders", s.handleCreateReminder)
|
2026-08-16 23:44:55 +02:00
|
|
|
r.Post("/complete", s.handleCompleteTask)
|
|
|
|
|
r.Post("/reopen", s.handleReopenTask)
|
|
|
|
|
r.Post("/move", s.handleMoveTask)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-08-16 23:05:19 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
r.NotFound(s.serveWeb)
|
|
|
|
|
|
|
|
|
|
s.router = r
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.router.ServeHTTP(w, r) }
|
|
|
|
|
|
2026-08-17 07:05:24 +02:00
|
|
|
// 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 }
|
|
|
|
|
|
2026-08-16 23:05:19 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-08-17 14:33:00 +02:00
|
|
|
// 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})
|
2026-08-16 23:05:19 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
2026-08-17 15:19:35 +02:00
|
|
|
|
|
|
|
|
// /.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
|
|
|
|
|
}
|
2026-08-16 23:05:19 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
// securityHeaders sets the headers that are the same on every response.
|
|
|
|
|
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
2026-08-16 23:05:19 +02:00
|
|
|
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")
|
2026-08-17 00:03:20 +02:00
|
|
|
h.Set("Content-Security-Policy", s.csp)
|
2026-08-16 23:05:19 +02:00
|
|
|
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}))
|
|
|
|
|
}
|