From c2e2d66e76d8074ef28c67bd0dd5bca7a79fc6f5 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Mon, 17 Aug 2026 22:58:02 +0200 Subject: [PATCH] feat(admin): keep server errors where a restart cannot take them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel's watcher reported four 5xx today — twice from one address at 11:49, twice from another at 10:45 — and there is no way to find out what any of them were. verdande logs the real cause of every 500, but on a Rune the log belongs to the container and the container is replaced on every restart. So an operator is told that something broke and given nothing to act on, which is the worst of both: alarming and useless. Every 500 now writes a row: what the handler was attempting in its own words, the error it hit, the method and path, who was asking, and the request id that ties it back to the log line for whoever still has it. The "what" is the part a status code cannot give — "list projects" and "move task" are the difference between a fault you can find and one you can only count. Written best-effort and never checked. This is the path that is already reporting a failure, and a diagnostic that can itself fail the request would turn one broken screen into two. Threading the request through `internal()` and `storeError()` touched 108 call sites across 19 files, which is the price of not having had it there from the start. It is mechanical and the compiler checked all of it. Kept for thirty days by the nightly sweep: long enough to answer "what happened last week", short enough that a fault loop cannot fill the volume the tasks live on. Read at Indstillinger → Fejl, administrators only — it carries paths, account names and the server's own error strings. The empty state says "Ingen fejl registreret. Det er den rigtige tilstand", because a blank panel should not read as a broken one. The test drops a table and asks for the endpoint that reads it: the same code path a disk error takes, and the row that comes back has to name the operation, the account and the request. Co-Authored-By: Claude Opus 5 --- README.md | 2 + docs/openapi.yaml | 54 +++++++ internal/httpapi/auth_handlers.go | 56 ++++--- internal/httpapi/caldav_handlers.go | 12 +- internal/httpapi/comments_handlers.go | 24 +-- internal/httpapi/errorlog_handlers.go | 47 ++++++ internal/httpapi/feeds_handlers.go | 26 ++-- internal/httpapi/filters_handlers.go | 20 +-- internal/httpapi/gmail_handlers.go | 10 +- internal/httpapi/import_handlers.go | 16 +- internal/httpapi/integrations_handlers.go | 34 ++--- internal/httpapi/mcp_handlers.go | 2 +- internal/httpapi/middleware.go | 2 +- internal/httpapi/notifications_handlers.go | 12 +- internal/httpapi/projectgroups_handlers.go | 12 +- internal/httpapi/projects_handlers.go | 52 +++---- internal/httpapi/server.go | 1 + internal/httpapi/sessions_handlers.go | 4 +- internal/httpapi/tasks_handlers.go | 34 ++--- internal/httpapi/tokens_handlers.go | 6 +- internal/httpapi/totp_handlers.go | 18 +-- internal/httpapi/users_handlers.go | 16 +- internal/httpapi/users_test.go | 67 +++++++++ internal/httpapi/views_handlers.go | 16 +- internal/jobs/jobs.go | 15 ++ internal/store/errorlog.go | 77 ++++++++++ internal/store/migrations/0007_error_log.sql | 27 ++++ web/e2e/smoke.spec.js | 3 +- web/src/lib/api.js | 1 + web/src/routes/indstillinger/+layout.svelte | 7 +- .../routes/indstillinger/fejl/+page.svelte | 138 ++++++++++++++++++ 31 files changed, 634 insertions(+), 177 deletions(-) create mode 100644 internal/httpapi/errorlog_handlers.go create mode 100644 internal/store/errorlog.go create mode 100644 internal/store/migrations/0007_error_log.sql create mode 100644 web/src/routes/indstillinger/fejl/+page.svelte diff --git a/README.md b/README.md index 3b6087b..ae8cace 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector. | Settings: account, notifications, integrations, AI, tokens, data | Done | | Device list: where you are signed in, and signing one out | Done, tested | | User administration: invite to the instance, promote, remove | Done, tested | +| Server errors kept in the database, readable after a restart | Done, tested | +| PWA icons: PNG for launchers, apple-touch-icon for iOS | Done, smoke-tested | | Invite and password-reset links that open the right page | Done, smoke-tested | | Drag to reorder, in both the list and the board | Done | | Drag a task to another project or another day | Done, smoke-tested | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ea77339..701c28a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -856,6 +856,60 @@ paths: "204": { description: Deleted. } "404": { $ref: "#/components/responses/NotFound" } + /api/v1/errors: + get: + tags: [auth] + summary: What has gone wrong lately. Administrators only. + description: | + Every 500 the API has answered, newest first, with what the handler was + attempting and the error it hit. + + These are written to the database rather than only to the log because the + log does not survive. On a Rune the log belongs to the container, and the + container is replaced on every restart — so a panel watcher reports "HTTP + 5xx, twice, at 11:49" long after the line explaining it has gone, which + tells an operator that something broke and gives them no way to find out + what. + + Kept for thirty days by the nightly sweep: long enough to answer "what + happened last week", short enough that a fault loop cannot fill the volume + the tasks live on. + parameters: + - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + errors: + type: array + items: + type: object + properties: + id: { type: string } + at: { type: string, format: date-time } + method: { type: string } + path: { type: string } + status: { type: integer } + what: + type: string + description: What the handler was attempting, in its own words. + example: list projects + message: { type: string } + user_name: + type: string + description: | + Who was asking, when there was somebody. A fault only + one account hits is a different problem from one + everybody hits. + request_id: + type: string + description: Ties the row back to the log line, for whoever still has it. + "403": { $ref: "#/components/responses/Forbidden" } + /api/v1/users: get: tags: [auth] diff --git a/internal/httpapi/auth_handlers.go b/internal/httpapi/auth_handlers.go index b308537..7443558 100644 --- a/internal/httpapi/auth_handlers.go +++ b/internal/httpapi/auth_handlers.go @@ -8,6 +8,8 @@ import ( "time" "unicode/utf8" + "github.com/go-chi/chi/v5/middleware" + "github.com/kristianwind/verdande/internal/auth" "github.com/kristianwind/verdande/internal/store" ) @@ -88,7 +90,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled) if err != nil { - s.internal(w, "create session", err) + s.internal(w, r, "create session", err) return } s.setSessionCookie(w, token, ttl) @@ -130,7 +132,7 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) { } if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil { - s.internal(w, "promote session", err) + s.internal(w, r, "promote session", err) return } // The cookie is reissued so its lifetime matches the session's new one. @@ -210,13 +212,13 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { } if err := s.db.UpdateProfile(r.Context(), user.ID, name, timezone, locale); err != nil { - s.storeError(w, "update profile", err) + s.storeError(w, r, "update profile", err) return } updated, err := s.db.UserByID(r.Context(), user.ID) if err != nil { - s.internal(w, "reload user", err) + s.internal(w, r, "reload user", err) return } writeJSON(w, http.StatusOK, toUserJSON(updated)) @@ -263,7 +265,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { hash, err := auth.HashPassword(req.Password) if err != nil { - s.internal(w, "hash password", err) + s.internal(w, r, "hash password", err) return } @@ -277,14 +279,14 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { "an account already exists for that email address") return } - s.internal(w, "create user", err) + s.internal(w, r, "create user", err) return } // Accepting the invite is what adds the membership and burns the token, in one // transaction: a signup that half-succeeded would leave a usable invite behind. if err := s.db.AcceptInvite(r.Context(), invite.ID, user.ID); err != nil { - s.internal(w, "accept invite", err) + s.internal(w, r, "accept invite", err) return } @@ -328,7 +330,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) { hash, err := auth.HashPassword(req.Password) if err != nil { - s.internal(w, "hash password", err) + s.internal(w, r, "hash password", err) return } @@ -341,7 +343,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusConflict, CodeConflict, "this instance is already set up") return } - s.internal(w, "create first admin", err) + s.internal(w, r, "create first admin", err) return } @@ -355,7 +357,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) { n, err := s.db.UserCount(r.Context()) if err != nil { - s.internal(w, "count users", err) + s.internal(w, r, "count users", err) return } writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0}) @@ -364,7 +366,7 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) { func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) { token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), s.cfg.SessionTTL, false) if err != nil { - s.internal(w, "create session", err) + s.internal(w, r, "create session", err) return } s.setSessionCookie(w, token, s.cfg.SessionTTL) @@ -393,7 +395,7 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) { if err == nil { token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL) if err != nil { - s.internal(w, "create password reset", err) + s.internal(w, r, "create password reset", err) return } link := s.cfg.BaseURL + "/reset?token=" + token @@ -431,13 +433,13 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) { hash, err := auth.HashPassword(req.Password) if err != nil { - s.internal(w, "hash password", err) + s.internal(w, r, "hash password", err) return } // Every session ends: a password reset is what somebody does when they think // another person has been in their account. if err := s.db.UpdatePasswordHash(r.Context(), userID, hash, ""); err != nil { - s.internal(w, "update password", err) + s.internal(w, r, "update password", err) return } @@ -469,7 +471,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { hash, err := auth.HashPassword(req.NewPassword) if err != nil { - s.internal(w, "hash password", err) + s.internal(w, r, "hash password", err) return } keep := "" @@ -477,7 +479,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { keep = session.ID } if err := s.db.UpdatePasswordHash(r.Context(), user.ID, hash, keep); err != nil { - s.internal(w, "update password", err) + s.internal(w, r, "update password", err) return } w.WriteHeader(http.StatusNoContent) @@ -520,7 +522,27 @@ func inboxName(locale string) string { // internal logs the real error and tells the client nothing about it. The detail // belongs in the log, where the operator can see it; in the response it would be a // description of the server's internals handed to whoever asked. -func (s *Server) internal(w http.ResponseWriter, what string, err error) { +// +// It is also written to the database. On a Rune the log is the container's, and +// every restart begins a new one — so the panel reports "HTTP 5xx, twice, at +// 11:49" long after the line explaining it has gone, which tells an operator that +// something broke and gives them no way to find out what. The row outlives the +// container; Indstillinger → Fejl is where it is read. +func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) { s.log.Error(what, "err", err) + + userID := "" + if u := userFrom(r.Context()); u != nil { + userID = u.ID + } + // Best effort, and deliberately not checked: this is the path that is already + // reporting a failure, and a diagnostic that can fail the request would turn + // one broken screen into two. + s.db.RecordError(r.Context(), store.ServerError{ + Method: r.Method, Path: r.URL.Path, Status: http.StatusInternalServerError, + What: what, Message: err.Error(), UserID: userID, + RequestID: middleware.GetReqID(r.Context()), + }) + writeError(w, http.StatusInternalServerError, CodeInternal, "something went wrong") } diff --git a/internal/httpapi/caldav_handlers.go b/internal/httpapi/caldav_handlers.go index 48f0c7f..d4c4f4a 100644 --- a/internal/httpapi/caldav_handlers.go +++ b/internal/httpapi/caldav_handlers.go @@ -85,7 +85,7 @@ func (s *Server) handleCalDAVHome(w http.ResponseWriter, r *http.Request) { projects, err := s.db.ListProjects(r.Context(), user.ID, false) if err != nil { - s.internal(w, "caldav home", err) + s.internal(w, r, "caldav home", err) return } @@ -131,7 +131,7 @@ func (s *Server) handleCalDAVCalendar(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Depth") != "0" { tasks, err := s.caldavTasks(r, user, projectID) if err != nil { - s.internal(w, "caldav tasks", err) + s.internal(w, r, "caldav tasks", err) return } for _, t := range tasks { @@ -157,7 +157,7 @@ func (s *Server) handleCalDAVReport(w http.ResponseWriter, r *http.Request) { tasks, err := s.caldavTasks(r, user, projectID) if err != nil { - s.internal(w, "caldav report", err) + s.internal(w, r, "caldav report", err) return } byID := map[string]store.Task{} @@ -240,7 +240,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) { update.DueDatetime = parsed.DueDatetime if err := s.db.UpdateTask(r.Context(), taskID, user.ID, update); err != nil { - s.internal(w, "caldav update", err) + s.internal(w, r, "caldav update", err) return } if parsed.Completed && !existing.Completed() { @@ -275,7 +275,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) { task.Content = "(uden titel)" } if err := s.db.CreateTask(r.Context(), task, nil); err != nil { - s.internal(w, "caldav create", err) + s.internal(w, r, "caldav create", err) return } s.publish(projectID, "task.created", toTaskJSON(*task)) @@ -294,7 +294,7 @@ func (s *Server) handleCalDAVDelete(w http.ResponseWriter, r *http.Request) { return } if err := s.db.DeleteTask(r.Context(), taskID); err != nil { - s.internal(w, "caldav delete", err) + s.internal(w, r, "caldav delete", err) return } w.WriteHeader(http.StatusNoContent) diff --git a/internal/httpapi/comments_handlers.go b/internal/httpapi/comments_handlers.go index 3fa8ad8..8ab4b33 100644 --- a/internal/httpapi/comments_handlers.go +++ b/internal/httpapi/comments_handlers.go @@ -68,7 +68,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) { comments, err := s.db.ListComments(r.Context(), taskID) if err != nil { - s.internal(w, "list comments", err) + s.internal(w, r, "list comments", err) return } out := make([]commentJSON, 0, len(comments)) @@ -78,7 +78,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) { files, err := s.db.ListTaskAttachments(r.Context(), taskID) if err != nil { - s.internal(w, "list attachments", err) + s.internal(w, r, "list attachments", err) return } direct := make([]attachmentJSON, 0, len(files)) @@ -119,7 +119,7 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) { comment, err := s.db.CreateComment(r.Context(), taskID, user.ID, body) if err != nil { - s.internal(w, "create comment", err) + s.internal(w, r, "create comment", err) return } comment.UserName, comment.UserColor = user.Name, user.AvatarColor @@ -149,7 +149,7 @@ func (s *Server) handleUpdateComment(w http.ResponseWriter, r *http.Request) { err := s.db.UpdateComment(r.Context(), chi.URLParam(r, "commentID"), userFrom(r.Context()).ID, body) if err != nil { - s.storeError(w, "update comment", err) + s.storeError(w, r, "update comment", err) return } w.WriteHeader(http.StatusNoContent) @@ -161,7 +161,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) { taskID, err := s.db.CommentTask(r.Context(), commentID) if err != nil { - s.storeError(w, "comment task", err) + s.storeError(w, r, "comment task", err) return } role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID) @@ -171,7 +171,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) { } if err := s.db.DeleteComment(r.Context(), commentID, user.ID, role.CanManage()); err != nil { - s.storeError(w, "delete comment", err) + s.storeError(w, r, "delete comment", err) return } w.WriteHeader(http.StatusNoContent) @@ -219,7 +219,7 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request) stored, size, err := s.storeUpload(file) if err != nil { - s.internal(w, "store upload", err) + s.internal(w, r, "store upload", err) return } @@ -240,7 +240,7 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request) if err := s.db.CreateAttachment(r.Context(), a); err != nil { os.Remove(filepath.Join(s.cfg.FilesDir(), stored)) - s.internal(w, "record attachment", err) + s.internal(w, r, "record attachment", err) return } @@ -306,7 +306,7 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request taskID, err := s.db.AttachmentTask(r.Context(), attachmentID) if err != nil { - s.storeError(w, "attachment task", err) + s.storeError(w, r, "attachment task", err) return } if _, err := store.TaskRole(r.Context(), s.db, taskID, user.ID); err != nil { @@ -316,7 +316,7 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request a, err := s.db.GetAttachment(r.Context(), attachmentID) if err != nil { - s.storeError(w, "get attachment", err) + s.storeError(w, r, "get attachment", err) return } @@ -358,7 +358,7 @@ func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) taskID, err := s.db.AttachmentTask(r.Context(), attachmentID) if err != nil { - s.storeError(w, "attachment task", err) + s.storeError(w, r, "attachment task", err) return } role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID) @@ -369,7 +369,7 @@ func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) path, err := s.db.DeleteAttachment(r.Context(), attachmentID) if err != nil { - s.storeError(w, "delete attachment", err) + s.storeError(w, r, "delete attachment", err) return } diff --git a/internal/httpapi/errorlog_handlers.go b/internal/httpapi/errorlog_handlers.go new file mode 100644 index 0000000..8901ac8 --- /dev/null +++ b/internal/httpapi/errorlog_handlers.go @@ -0,0 +1,47 @@ +package httpapi + +import ( + "net/http" + "time" +) + +// What has gone wrong lately, kept where a restart cannot take it. +// +// The panel's watcher reports "HTTP 5xx, twice, at 11:49" — which is enough to +// know something broke and nothing at all to act on. The line that explains it is +// in the container's log, and a Rune's container is replaced on every restart, so +// by the time anybody looks the explanation is gone. These rows are the same +// information written somewhere that survives. + +type serverErrorJSON struct { + ID string `json:"id"` + At string `json:"at"` + Method string `json:"method"` + Path string `json:"path"` + Status int `json:"status"` + // What the handler was doing, in its own words — "list projects", "move task". + What string `json:"what"` + Message string `json:"message"` + // Who was asking, when there was somebody: a fault only one account hits is a + // different problem from one everybody hits. + UserName string `json:"user_name,omitempty"` + // RequestID ties the row back to the log line, for whoever still has it. + RequestID string `json:"request_id,omitempty"` +} + +func (s *Server) handleListErrors(w http.ResponseWriter, r *http.Request) { + errs, err := s.db.ListErrors(r.Context(), parseLimit(r.URL.Query().Get("limit"), 100, 500)) + if err != nil { + s.internal(w, r, "list errors", err) + return + } + out := make([]serverErrorJSON, 0, len(errs)) + for _, e := range errs { + out = append(out, serverErrorJSON{ + ID: e.ID, At: e.At.Format(time.RFC3339), Method: e.Method, Path: e.Path, + Status: e.Status, What: e.What, Message: e.Message, + UserName: e.UserName, RequestID: e.RequestID, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"errors": out}) +} diff --git a/internal/httpapi/feeds_handlers.go b/internal/httpapi/feeds_handlers.go index 3745236..1335629 100644 --- a/internal/httpapi/feeds_handlers.go +++ b/internal/httpapi/feeds_handlers.go @@ -36,13 +36,13 @@ func (s *Server) handleICSFeed(w http.ResponseWriter, r *http.Request) { Limit: 2000, }) if err != nil { - s.internal(w, "ics feed", err) + s.internal(w, r, "ics feed", err) return } projects, err := s.db.ListProjects(r.Context(), user.ID, true) if err != nil { - s.internal(w, "ics feed projects", err) + s.internal(w, r, "ics feed projects", err) return } names := map[string]string{} @@ -89,7 +89,7 @@ func (s *Server) handleGetFeed(w http.ResponseWriter, r *http.Request) { token, err := s.db.EnsureICSToken(r.Context(), user.ID) if err != nil { - s.internal(w, "ics token", err) + s.internal(w, r, "ics token", err) return } writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"}) @@ -103,11 +103,11 @@ func (s *Server) handleRotateFeed(w http.ResponseWriter, r *http.Request) { token, err := auth.NewToken() if err != nil { - s.internal(w, "generate feed token", err) + s.internal(w, r, "generate feed token", err) return } if err := s.db.SetICSToken(r.Context(), user.ID, token); err != nil { - s.internal(w, "set feed token", err) + s.internal(w, r, "set feed token", err) return } writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"}) @@ -151,7 +151,7 @@ func (s *Server) handleListReminders(w http.ResponseWriter, r *http.Request) { reminders, err := s.db.ListReminders(r.Context(), taskID) if err != nil { - s.internal(w, "list reminders", err) + s.internal(w, r, "list reminders", err) return } out := make([]reminderJSON, 0, len(reminders)) @@ -197,7 +197,7 @@ func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) { rem, err := s.db.CreateReminder(r.Context(), taskID, user.ID, at, req.OffsetMin) if err != nil { - s.internal(w, "create reminder", err) + s.internal(w, r, "create reminder", err) return } @@ -211,7 +211,7 @@ func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteReminder(w http.ResponseWriter, r *http.Request) { err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID) if err != nil { - s.storeError(w, "delete reminder", err) + s.storeError(w, r, "delete reminder", err) return } w.WriteHeader(http.StatusNoContent) @@ -238,7 +238,7 @@ func toTemplateJSON(t store.Template) templateJSON { func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) { templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID) if err != nil { - s.internal(w, "list templates", err) + s.internal(w, r, "list templates", err) return } out := make([]templateJSON, 0, len(templates)) @@ -273,7 +273,7 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request) { tpl, err := s.db.SaveProjectAsTemplate(r.Context(), req.ProjectID, user.ID, strings.TrimSpace(req.Name), strings.TrimSpace(req.Description)) if err != nil { - s.storeError(w, "save template", err) + s.storeError(w, r, "save template", err) return } writeJSON(w, http.StatusCreated, toTemplateJSON(*tpl)) @@ -295,7 +295,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) { tpl, err := s.db.GetTemplate(r.Context(), user.ID, chi.URLParam(r, "templateID")) if err != nil { - s.storeError(w, "get template", err) + s.storeError(w, r, "get template", err) return } @@ -311,7 +311,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) { project, err := s.db.CreateProjectFromTemplate(r.Context(), tpl, user.ID, strings.TrimSpace(req.Name), start) if err != nil { - s.internal(w, "create from template", err) + s.internal(w, r, "create from template", err) return } s.activity(r, project.ID, "", "project.created", map[string]any{ @@ -323,7 +323,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) { err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID")) if err != nil { - s.storeError(w, "delete template", err) + s.storeError(w, r, "delete template", err) return } w.WriteHeader(http.StatusNoContent) diff --git a/internal/httpapi/filters_handlers.go b/internal/httpapi/filters_handlers.go index de855fc..17fd89c 100644 --- a/internal/httpapi/filters_handlers.go +++ b/internal/httpapi/filters_handlers.go @@ -27,7 +27,7 @@ func toFilterJSON(f store.Filter) filterJSON { func (s *Server) handleListFilters(w http.ResponseWriter, r *http.Request) { filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID) if err != nil { - s.internal(w, "list filters", err) + s.internal(w, r, "list filters", err) return } out := make([]filterJSON, 0, len(filters)) @@ -74,7 +74,7 @@ func (s *Server) handleCreateFilter(w http.ResponseWriter, r *http.Request) { f.SortOrder = *req.SortOrder } if err := s.db.CreateFilter(r.Context(), f); err != nil { - s.internal(w, "create filter", err) + s.internal(w, r, "create filter", err) return } writeJSON(w, http.StatusCreated, toFilterJSON(*f)) @@ -108,7 +108,7 @@ func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) { Name: req.Name, Query: req.Query, Color: req.Color, SortOrder: req.SortOrder, }) if err != nil { - s.storeError(w, "update filter", err) + s.storeError(w, r, "update filter", err) return } w.WriteHeader(http.StatusNoContent) @@ -117,7 +117,7 @@ func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteFilter(w http.ResponseWriter, r *http.Request) { err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID")) if err != nil { - s.storeError(w, "delete filter", err) + s.storeError(w, r, "delete filter", err) return } w.WriteHeader(http.StatusNoContent) @@ -129,7 +129,7 @@ func (s *Server) handleRunFilter(w http.ResponseWriter, r *http.Request) { f, err := s.db.GetFilter(r.Context(), user.ID, chi.URLParam(r, "filterID")) if err != nil { - s.storeError(w, "get filter", err) + s.storeError(w, r, "get filter", err) return } s.runQuery(w, r, f.Query) @@ -167,7 +167,7 @@ func (s *Server) runQuery(w http.ResponseWriter, r *http.Request, query string) Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500), }) if err != nil { - s.internal(w, "run filter", err) + s.internal(w, r, "run filter", err) return } writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)}) @@ -210,7 +210,7 @@ type labelJSON struct { func (s *Server) handleListLabels(w http.ResponseWriter, r *http.Request) { labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID) if err != nil { - s.internal(w, "list labels", err) + s.internal(w, r, "list labels", err) return } out := make([]labelJSON, 0, len(labels)) @@ -242,7 +242,7 @@ func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name") return } - s.internal(w, "create label", err) + s.internal(w, r, "create label", err) return } s.publishToOwner(r, "label.changed", labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0}) @@ -261,7 +261,7 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name") return } - s.storeError(w, "update label", err) + s.storeError(w, r, "update label", err) return } s.publishToOwner(r, "label.changed", nil) @@ -271,7 +271,7 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) { err := s.db.DeleteLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID")) if err != nil { - s.storeError(w, "delete label", err) + s.storeError(w, r, "delete label", err) return } s.publishToOwner(r, "label.changed", nil) diff --git a/internal/httpapi/gmail_handlers.go b/internal/httpapi/gmail_handlers.go index 36c6591..e468a1d 100644 --- a/internal/httpapi/gmail_handlers.go +++ b/internal/httpapi/gmail_handlers.go @@ -37,13 +37,13 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) { pkce, err := gmail.NewPKCE() if err != nil { - s.internal(w, "gmail pkce", err) + s.internal(w, r, "gmail pkce", err) return } settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail") if err != nil { - s.internal(w, "gmail settings", err) + s.internal(w, r, "gmail settings", err) return } settings["pkce_verifier"] = pkce.Verifier @@ -53,7 +53,7 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) { settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix() if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil { - s.internal(w, "gmail settings", err) + s.internal(w, r, "gmail settings", err) return } writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)}) @@ -83,7 +83,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) { settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail") if err != nil { - s.internal(w, "gmail settings", err) + s.internal(w, r, "gmail settings", err) return } verifier, _ := settings["pkce_verifier"].(string) @@ -138,7 +138,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) { } if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil { - s.internal(w, "save gmail tokens", err) + s.internal(w, r, "save gmail tokens", err) return } s.log.Info("gmail connected", "user", user.ID, "mailbox", email) diff --git a/internal/httpapi/import_handlers.go b/internal/httpapi/import_handlers.go index 75997b3..e461fc9 100644 --- a/internal/httpapi/import_handlers.go +++ b/internal/httpapi/import_handlers.go @@ -69,7 +69,7 @@ func (s *Server) handleImportTodoist(w http.ResponseWriter, r *http.Request) { project := todoist.FromRows(rows, name) result, err := s.importProject(r, user, project) if err != nil { - s.internal(w, "import project", err) + s.internal(w, r, "import project", err) return } writeJSON(w, http.StatusCreated, result) @@ -240,7 +240,7 @@ func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) { result, err := s.importProject(r, user, project) if err != nil { - s.internal(w, "import csv", err) + s.internal(w, r, "import csv", err) return } writeJSON(w, http.StatusCreated, result) @@ -255,12 +255,12 @@ func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) { project, err := s.db.GetProject(r.Context(), projectID, user.ID) if err != nil { - s.storeError(w, "get project", err) + s.storeError(w, r, "get project", err) return } exported, err := s.buildExport(r, user, project) if err != nil { - s.internal(w, "export project", err) + s.internal(w, r, "export project", err) return } @@ -346,7 +346,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) { projects, err := s.db.ListProjects(ctx, user.ID, true) if err != nil { - s.internal(w, "export projects", err) + s.internal(w, r, "export projects", err) return } @@ -390,7 +390,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) { ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000, }) if err != nil { - s.internal(w, "export tasks", err) + s.internal(w, r, "export tasks", err) return } for _, t := range tasks { @@ -445,14 +445,14 @@ func (s *Server) handleExportProjectICS(w http.ResponseWriter, r *http.Request) project, err := s.db.GetProject(r.Context(), projectID, user.ID) if err != nil { - s.storeError(w, "get project", err) + s.storeError(w, r, "get project", err) return } tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{ ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000, }) if err != nil { - s.internal(w, "export ics", err) + s.internal(w, r, "export ics", err) return } diff --git a/internal/httpapi/integrations_handlers.go b/internal/httpapi/integrations_handlers.go index 9cc7f41..02e5a5f 100644 --- a/internal/httpapi/integrations_handlers.go +++ b/internal/httpapi/integrations_handlers.go @@ -37,7 +37,7 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) { token, err := s.db.EnsureMailToken(r.Context(), user.ID) if err != nil { - s.internal(w, "mail token", err) + s.internal(w, r, "mail token", err) return } writeJSON(w, http.StatusOK, mailAddressResponse{ @@ -49,11 +49,11 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) { token, err := auth.NewToken() if err != nil { - s.internal(w, "generate mail token", err) + s.internal(w, r, "generate mail token", err) return } if err := s.db.SetMailToken(r.Context(), userFrom(r.Context()).ID, token); err != nil { - s.internal(w, "set mail token", err) + s.internal(w, r, "set mail token", err) return } writeJSON(w, http.StatusOK, mailAddressResponse{ @@ -134,7 +134,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) { } if projectID == "" { if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil { - s.internal(w, "inbox", err) + s.internal(w, r, "inbox", err) return } } @@ -145,7 +145,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) { RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID, } if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil { - s.internal(w, "create task from mail", err) + s.internal(w, r, "create task from mail", err) return } @@ -187,7 +187,7 @@ type aiSettings struct { func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) { cfg, err := s.aiConfig(r, userFrom(r.Context()).ID) if err != nil { - s.internal(w, "ai settings", err) + s.internal(w, r, "ai settings", err) return } // The key is never sent back. A settings page that repopulates a password @@ -216,7 +216,7 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) { existing, err := s.aiConfig(r, user.ID) if err != nil { - s.internal(w, "ai settings", err) + s.internal(w, r, "ai settings", err) return } // An empty key means "leave it alone", not "delete it" — otherwise saving a @@ -231,7 +231,7 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) { "model": req.Model, "api_key": key, } if err := s.db.SetUserSettings(r.Context(), user.ID, "ai", settings); err != nil { - s.internal(w, "save ai settings", err) + s.internal(w, r, "save ai settings", err) return } w.WriteHeader(http.StatusNoContent) @@ -266,7 +266,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) { } cfg, err := s.aiConfig(r, user.ID) if err != nil { - s.internal(w, "ai settings", err) + s.internal(w, r, "ai settings", err) return } if !cfg.Configured() { @@ -278,7 +278,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) { task, err := s.db.GetTask(r.Context(), taskID, user.ID) if err != nil { - s.storeError(w, "get task", err) + s.storeError(w, r, "get task", err) return } @@ -299,7 +299,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) { Priority: task.Priority, CreatedBy: user.ID, } if err := s.db.CreateTask(r.Context(), child, nil); err != nil { - s.internal(w, "create subtask", err) + s.internal(w, r, "create subtask", err) return } created = append(created, toTaskJSON(*child)) @@ -315,7 +315,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) { cfg, err := s.aiConfig(r, user.ID) if err != nil { - s.internal(w, "ai settings", err) + s.internal(w, r, "ai settings", err) return } if !cfg.Configured() { @@ -326,7 +326,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) { tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100}) if err != nil { - s.internal(w, "list tasks", err) + s.internal(w, r, "list tasks", err) return } if len(tasks) == 0 { @@ -372,7 +372,7 @@ type gmailSettings struct { func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) { values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail") if err != nil { - s.internal(w, "gmail settings", err) + s.internal(w, r, "gmail settings", err) return } str := func(key string) string { @@ -396,14 +396,14 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) { values, err := s.db.UserSettings(r.Context(), user.ID, "gmail") if err != nil { - s.internal(w, "gmail settings", err) + s.internal(w, r, "gmail settings", err) return } values["trigger"] = req.Trigger values["label"] = req.Label if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil { - s.internal(w, "save gmail settings", err) + s.internal(w, r, "save gmail settings", err) return } w.WriteHeader(http.StatusNoContent) @@ -412,7 +412,7 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) { // handleDisconnectGmail forgets the connection, tokens and all. func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) { if err := s.db.SetUserSettings(r.Context(), userFrom(r.Context()).ID, "gmail", map[string]any{}); err != nil { - s.internal(w, "disconnect gmail", err) + s.internal(w, r, "disconnect gmail", err) return } w.WriteHeader(http.StatusNoContent) diff --git a/internal/httpapi/mcp_handlers.go b/internal/httpapi/mcp_handlers.go index acc00c6..003959c 100644 --- a/internal/httpapi/mcp_handlers.go +++ b/internal/httpapi/mcp_handlers.go @@ -154,7 +154,7 @@ func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) { user, err := s.db.UserByAPIToken(r.Context(), key) if err != nil { if !errors.Is(err, store.ErrNotFound) { - s.internal(w, "mcp key", err) + s.internal(w, r, "mcp key", err) return } writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid") diff --git a/internal/httpapi/middleware.go b/internal/httpapi/middleware.go index 477cdb4..8e6f5a1 100644 --- a/internal/httpapi/middleware.go +++ b/internal/httpapi/middleware.go @@ -83,7 +83,7 @@ func (s *Server) requireAuth(next http.Handler) http.Handler { // as everybody being logged out at once — the frontend clears its state // and shows the sign-in screen — and nothing in the logs says why. if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) { - s.internal(w, "authenticate", err) + s.internal(w, r, "authenticate", err) return } writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in") diff --git a/internal/httpapi/notifications_handlers.go b/internal/httpapi/notifications_handlers.go index 85f1546..891b2a4 100644 --- a/internal/httpapi/notifications_handlers.go +++ b/internal/httpapi/notifications_handlers.go @@ -27,12 +27,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) list, err := s.db.ListNotifications(r.Context(), user.ID, 30) if err != nil { - s.internal(w, "list notifications", err) + s.internal(w, r, "list notifications", err) return } unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID) if err != nil { - s.internal(w, "count notifications", err) + s.internal(w, r, "count notifications", err) return } @@ -50,7 +50,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) func (s *Server) handleMarkNotificationsRead(w http.ResponseWriter, r *http.Request) { err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID")) if err != nil { - s.internal(w, "mark notifications read", err) + s.internal(w, r, "mark notifications read", err) return } w.WriteHeader(http.StatusNoContent) @@ -148,7 +148,7 @@ type pushSubscriptionRequest struct { func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) { public, _, err := s.vapidKeys(r.Context()) if err != nil { - s.internal(w, "vapid keys", err) + s.internal(w, r, "vapid keys", err) return } writeJSON(w, http.StatusOK, map[string]string{"public_key": public}) @@ -172,7 +172,7 @@ func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) { UserAgent: r.UserAgent(), }) if err != nil { - s.internal(w, "save push subscription", err) + s.internal(w, r, "save push subscription", err) return } w.WriteHeader(http.StatusNoContent) @@ -184,7 +184,7 @@ func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) { return } if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil { - s.internal(w, "delete push subscription", err) + s.internal(w, r, "delete push subscription", err) return } w.WriteHeader(http.StatusNoContent) diff --git a/internal/httpapi/projectgroups_handlers.go b/internal/httpapi/projectgroups_handlers.go index 5eb7dad..12eaef0 100644 --- a/internal/httpapi/projectgroups_handlers.go +++ b/internal/httpapi/projectgroups_handlers.go @@ -34,7 +34,7 @@ func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON { func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) { groups, err := s.db.ListProjectGroups(r.Context(), userFrom(r.Context()).ID) if err != nil { - s.internal(w, "list project groups", err) + s.internal(w, r, "list project groups", err) return } out := make([]projectGroupJSON, 0, len(groups)) @@ -77,7 +77,7 @@ func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request g.Collapsed = *req.Collapsed } if err := s.db.CreateProjectGroup(r.Context(), g); err != nil { - s.internal(w, "create project group", err) + s.internal(w, r, "create project group", err) return } s.publishToOwner(r, "project_group.created", toProjectGroupJSON(*g)) @@ -108,12 +108,12 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request if err := s.db.UpdateProjectGroup(r.Context(), groupID, userID, store.ProjectGroupUpdate{ Name: req.Name, Color: req.Color, Collapsed: req.Collapsed, }); err != nil { - s.storeError(w, "update project group", err) + s.storeError(w, r, "update project group", err) return } g, err := s.db.GetProjectGroup(r.Context(), groupID, userID) if err != nil { - s.storeError(w, "get project group", err) + s.storeError(w, r, "get project group", err) return } s.publishToOwner(r, "project_group.updated", toProjectGroupJSON(*g)) @@ -126,7 +126,7 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request func (s *Server) handleDeleteProjectGroup(w http.ResponseWriter, r *http.Request) { groupID := chi.URLParam(r, "groupID") if err := s.db.DeleteProjectGroup(r.Context(), groupID, userFrom(r.Context()).ID); err != nil { - s.storeError(w, "delete project group", err) + s.storeError(w, r, "delete project group", err) return } s.publishToOwner(r, "project_group.deleted", map[string]string{"id": groupID}) @@ -145,7 +145,7 @@ func (s *Server) handleReorderProjectGroups(w http.ResponseWriter, r *http.Reque return } if err := s.db.ReorderProjectGroups(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil { - s.internal(w, "reorder project groups", err) + s.internal(w, r, "reorder project groups", err) return } w.WriteHeader(http.StatusNoContent) diff --git a/internal/httpapi/projects_handlers.go b/internal/httpapi/projects_handlers.go index cf89c9f..6e219ec 100644 --- a/internal/httpapi/projects_handlers.go +++ b/internal/httpapi/projects_handlers.go @@ -45,7 +45,7 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) { projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived) if err != nil { - s.internal(w, "list projects", err) + s.internal(w, r, "list projects", err) return } out := make([]projectJSON, 0, len(projects)) @@ -59,7 +59,7 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) { user := userFrom(r.Context()) p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID) if err != nil { - s.storeError(w, "get project", err) + s.storeError(w, r, "get project", err) return } writeJSON(w, http.StatusOK, toProjectJSON(*p)) @@ -105,7 +105,7 @@ func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) { } if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil { - s.internal(w, "reorder projects", err) + s.internal(w, r, "reorder projects", err) return } w.WriteHeader(http.StatusNoContent) @@ -138,7 +138,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { p.ViewMode = *req.ViewMode } if err := s.db.CreateProject(r.Context(), p); err != nil { - s.internal(w, "create project", err) + s.internal(w, r, "create project", err) return } s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name}) @@ -174,7 +174,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) { ViewMode: req.ViewMode, Archived: req.Archived, }) if err != nil { - s.storeError(w, "update project", err) + s.storeError(w, r, "update project", err) return } // Grouping is written separately because it is scoped differently: the fields @@ -184,7 +184,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) { if req.GroupID != nil { if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID, userFrom(r.Context()).ID); err != nil { - s.storeError(w, "set project group", err) + s.storeError(w, r, "set project group", err) return } } @@ -192,7 +192,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) { p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID) if err != nil { - s.storeError(w, "get project", err) + s.storeError(w, r, "get project", err) return } s.publishToOwner(r, "project.updated", toProjectJSON(*p)) @@ -206,7 +206,7 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted") return } - s.storeError(w, "delete project", err) + s.storeError(w, r, "delete project", err) return } s.activity(r, projectID, "", "project.deleted", nil) @@ -230,7 +230,7 @@ type trashedProjectJSON struct { func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) { trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID) if err != nil { - s.internal(w, "list trashed projects", err) + s.internal(w, r, "list trashed projects", err) return } @@ -252,7 +252,7 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) { // deliberately invisible to it. Ownership is what governs restoring. owner, err := s.db.ProjectOwner(r.Context(), projectID) if err != nil { - s.storeError(w, "project owner", err) + s.storeError(w, r, "project owner", err) return } if owner != userFrom(r.Context()).ID { @@ -260,7 +260,7 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) { return } if err := s.db.RestoreProject(r.Context(), projectID); err != nil { - s.storeError(w, "restore project", err) + s.storeError(w, r, "restore project", err) return } w.WriteHeader(http.StatusNoContent) @@ -306,7 +306,7 @@ type sectionJSON struct { func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) { sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID")) if err != nil { - s.internal(w, "list sections", err) + s.internal(w, r, "list sections", err) return } out := make([]sectionJSON, 0, len(sections)) @@ -336,7 +336,7 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) { sec.SortOrder = *req.SortOrder } if err := s.db.CreateSection(r.Context(), sec); err != nil { - s.internal(w, "create section", err) + s.internal(w, r, "create section", err) return } s.activity(r, sec.ProjectID, "", "section.created", map[string]any{"name": sec.Name}) @@ -350,7 +350,7 @@ func (s *Server) sectionAccess(w http.ResponseWriter, r *http.Request, min store sectionID := chi.URLParam(r, "sectionID") projectID, err := s.db.SectionProject(r.Context(), sectionID) if err != nil { - s.storeError(w, "section project", err) + s.storeError(w, r, "section project", err) return "", "", false } if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, userFrom(r.Context()).ID, min); err != nil { @@ -381,7 +381,7 @@ func (s *Server) handleUpdateSection(w http.ResponseWriter, r *http.Request) { if err := s.db.UpdateSection(r.Context(), sectionID, store.SectionUpdate{ Name: req.Name, SortOrder: req.SortOrder, }); err != nil { - s.storeError(w, "update section", err) + s.storeError(w, r, "update section", err) return } s.activity(r, projectID, "", "section.updated", nil) @@ -394,7 +394,7 @@ func (s *Server) handleDeleteSection(w http.ResponseWriter, r *http.Request) { return } if err := s.db.DeleteSection(r.Context(), sectionID); err != nil { - s.storeError(w, "delete section", err) + s.storeError(w, r, "delete section", err) return } s.activity(r, projectID, "", "section.deleted", nil) @@ -414,7 +414,7 @@ type memberJSON struct { func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) { members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID")) if err != nil { - s.internal(w, "list members", err) + s.internal(w, r, "list members", err) return } out := make([]memberJSON, 0, len(members)) @@ -463,7 +463,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) { project, err := s.db.GetProject(r.Context(), projectID, inviter.ID) if err != nil { - s.storeError(w, "get project", err) + s.storeError(w, r, "get project", err) return } @@ -475,7 +475,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) { return } if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil { - s.internal(w, "add member", err) + s.internal(w, r, "add member", err) return } s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)}) @@ -485,7 +485,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) { token, _, err := s.db.CreateInvite(r.Context(), email, projectID, role, inviter.ID, s.cfg.InviteTTL) if err != nil { - s.internal(w, "create invite", err) + s.internal(w, r, "create invite", err) return } link := s.cfg.BaseURL + "/invite?token=" + token @@ -531,7 +531,7 @@ func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) { // unhelpful. Say the real reason instead. owner, err := s.db.ProjectOwner(r.Context(), projectID) if err != nil { - s.storeError(w, "project owner", err) + s.storeError(w, r, "project owner", err) return } if userID == owner { @@ -541,7 +541,7 @@ func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) { } if err := s.db.SetMemberRole(r.Context(), projectID, userID, role); err != nil { - s.storeError(w, "set member role", err) + s.storeError(w, r, "set member role", err) return } s.activity(r, projectID, "", "member.role_changed", @@ -555,7 +555,7 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) { owner, err := s.db.ProjectOwner(r.Context(), projectID) if err != nil { - s.storeError(w, "project owner", err) + s.storeError(w, r, "project owner", err) return } if userID == owner { @@ -564,7 +564,7 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) { return } if err := s.db.RemoveMember(r.Context(), projectID, userID); err != nil { - s.storeError(w, "remove member", err) + s.storeError(w, r, "remove member", err) return } s.activity(r, projectID, "", "member.removed", map[string]any{"user_id": userID}) @@ -576,13 +576,13 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) { // Everything a caller is not allowed to see is reported as 404 rather than 403: a // 403 confirms the thing exists, which is exactly what somebody probing ids wants // to learn. -func (s *Server) storeError(w http.ResponseWriter, what string, err error) { +func (s *Server) storeError(w http.ResponseWriter, r *http.Request, what string, err error) { switch { case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess): writeError(w, http.StatusNotFound, CodeNotFound, "not found") case errors.Is(err, store.ErrEmailInUse): writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account") default: - s.internal(w, what, err) + s.internal(w, r, what, err) } } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index b49daca..0bded68 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -305,6 +305,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server // delete somebody else's. r.Group(func(r chi.Router) { r.Use(s.requireSession, s.requireAdmin) + r.Get("/errors", s.handleListErrors) r.Get("/users", s.handleListUsers) r.Post("/users", s.handleCreateUser) r.Patch("/users/{userID}", s.handleUpdateUser) diff --git a/internal/httpapi/sessions_handlers.go b/internal/httpapi/sessions_handlers.go index 9891236..43c478f 100644 --- a/internal/httpapi/sessions_handlers.go +++ b/internal/httpapi/sessions_handlers.go @@ -41,7 +41,7 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { sessions, err := s.db.ListSessions(r.Context(), user.ID) if err != nil { - s.internal(w, "list sessions", err) + s.internal(w, r, "list sessions", err) return } @@ -69,7 +69,7 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) { sessionID := chi.URLParam(r, "sessionID") if err := s.db.DeleteUserSession(r.Context(), sessionID, user.ID); err != nil { - s.storeError(w, "delete session", err) + s.storeError(w, r, "delete session", err) return } if current := sessionFrom(r.Context()); current != nil && current.ID == sessionID { diff --git a/internal/httpapi/tasks_handlers.go b/internal/httpapi/tasks_handlers.go index c81ef90..655601e 100644 --- a/internal/httpapi/tasks_handlers.go +++ b/internal/httpapi/tasks_handlers.go @@ -99,7 +99,7 @@ func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) { tasks, err := s.db.ListTasks(r.Context(), user.ID, f) if err != nil { - s.internal(w, "list tasks", err) + s.internal(w, r, "list tasks", err) return } writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)}) @@ -116,7 +116,7 @@ func toTaskList(tasks []store.Task) []taskJSON { func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) { t, err := s.db.GetTask(r.Context(), chi.URLParam(r, "taskID"), userFrom(r.Context()).ID) if err != nil { - s.storeError(w, "get task", err) + s.storeError(w, r, "get task", err) return } writeJSON(w, http.StatusOK, toTaskJSON(*t)) @@ -162,7 +162,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { if projectID == "" { var err error if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil { - s.internal(w, "inbox", err) + s.internal(w, r, "inbox", err) return } } @@ -226,7 +226,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { } if err := s.db.CreateTask(r.Context(), t, req.Labels); err != nil { - s.internal(w, "create task", err) + s.internal(w, r, "create task", err) return } // The store wrote the labels; the struct it was handed does not know that, and @@ -302,13 +302,13 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) { } if err := s.db.UpdateTask(r.Context(), taskID, user.ID, u); err != nil { - s.storeError(w, "update task", err) + s.storeError(w, r, "update task", err) return } t, err := s.db.GetTask(r.Context(), taskID, user.ID) if err != nil { - s.storeError(w, "get task", err) + s.storeError(w, r, "get task", err) return } s.activity(r, t.ProjectID, t.ID, "task.updated", nil) @@ -327,13 +327,13 @@ func (s *Server) handleCompleteTask(w http.ResponseWriter, r *http.Request) { } result, err := s.db.CompleteTask(r.Context(), taskID, user.ID) if err != nil { - s.storeError(w, "complete task", err) + s.storeError(w, r, "complete task", err) return } t, err := s.db.GetTask(r.Context(), taskID, user.ID) if err != nil { - s.storeError(w, "get task", err) + s.storeError(w, r, "get task", err) return } @@ -369,13 +369,13 @@ func (s *Server) handleReopenTask(w http.ResponseWriter, r *http.Request) { return } if err := s.db.ReopenTask(r.Context(), taskID); err != nil { - s.storeError(w, "reopen task", err) + s.storeError(w, r, "reopen task", err) return } t, err := s.db.GetTask(r.Context(), taskID, user.ID) if err != nil { - s.storeError(w, "get task", err) + s.storeError(w, r, "get task", err) return } s.activity(r, t.ProjectID, t.ID, "task.reopened", nil) @@ -394,11 +394,11 @@ func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) { } t, err := s.db.GetTask(r.Context(), taskID, user.ID) if err != nil { - s.storeError(w, "get task", err) + s.storeError(w, r, "get task", err) return } if err := s.db.DeleteTask(r.Context(), taskID); err != nil { - s.storeError(w, "delete task", err) + s.storeError(w, r, "delete task", err) return } s.activity(r, t.ProjectID, "", "task.deleted", map[string]any{"content": t.Content}) @@ -427,7 +427,7 @@ func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) { current, err := s.db.GetTask(r.Context(), taskID, user.ID) if err != nil { - s.storeError(w, "get task", err) + s.storeError(w, r, "get task", err) return } role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID) @@ -449,12 +449,12 @@ func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) { } if _, err := s.db.MoveTask(r.Context(), taskID, projectID, req.SectionID, req.AfterID, req.BeforeID); err != nil { - s.storeError(w, "move task", err) + s.storeError(w, r, "move task", err) return } t, err := s.db.GetTask(r.Context(), taskID, user.ID) if err != nil { - s.storeError(w, "get task", err) + s.storeError(w, r, "get task", err) return } s.publish(t.ProjectID, "task.moved", toTaskJSON(*t)) @@ -502,7 +502,7 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) { if projectID == "" { var err error if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil { - s.internal(w, "inbox", err) + s.internal(w, r, "inbox", err) return } } @@ -523,7 +523,7 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) { } if err := s.db.CreateTask(r.Context(), t, parsed.Labels); err != nil { - s.internal(w, "create task", err) + s.internal(w, r, "create task", err) return } t.Labels = parsed.Labels diff --git a/internal/httpapi/tokens_handlers.go b/internal/httpapi/tokens_handlers.go index 875ab86..9436ca4 100644 --- a/internal/httpapi/tokens_handlers.go +++ b/internal/httpapi/tokens_handlers.go @@ -36,7 +36,7 @@ type createdTokenJSON struct { func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) { tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID) if err != nil { - s.internal(w, "list api tokens", err) + s.internal(w, r, "list api tokens", err) return } @@ -96,7 +96,7 @@ func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) { plaintext, token, err := s.db.CreateAPIToken(r.Context(), userFrom(r.Context()).ID, name, expires) if err != nil { - s.internal(w, "create api token", err) + s.internal(w, r, "create api token", err) return } @@ -117,7 +117,7 @@ func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) { err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID")) if err != nil { - s.storeError(w, "delete api token", err) + s.storeError(w, r, "delete api token", err) return } w.WriteHeader(http.StatusNoContent) diff --git a/internal/httpapi/totp_handlers.go b/internal/httpapi/totp_handlers.go index 853d307..ee9f6e6 100644 --- a/internal/httpapi/totp_handlers.go +++ b/internal/httpapi/totp_handlers.go @@ -29,12 +29,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) { secret, uri, err := auth.NewTOTPSecret(s.issuer(), user.Email) if err != nil { - s.internal(w, "generate totp secret", err) + s.internal(w, r, "generate totp secret", err) return } // Stored but not enabled: confirm decides that. if err := s.db.SetTOTPSecret(r.Context(), user.ID, secret, false); err != nil { - s.internal(w, "store totp secret", err) + s.internal(w, r, "store totp secret", err) return } writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri}) @@ -63,15 +63,15 @@ func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) { codes, hashes, err := auth.NewRecoveryCodes() if err != nil { - s.internal(w, "generate recovery codes", err) + s.internal(w, r, "generate recovery codes", err) return } if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil { - s.internal(w, "store recovery codes", err) + s.internal(w, r, "store recovery codes", err) return } if err := s.db.SetTOTPSecret(r.Context(), user.ID, user.TOTPSecret, true); err != nil { - s.internal(w, "enable totp", err) + s.internal(w, r, "enable totp", err) return } @@ -98,7 +98,7 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) { return } if err := s.db.SetTOTPSecret(r.Context(), user.ID, "", false); err != nil { - s.internal(w, "disable totp", err) + s.internal(w, r, "disable totp", err) return } if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, nil); err != nil { @@ -127,11 +127,11 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re codes, hashes, err := auth.NewRecoveryCodes() if err != nil { - s.internal(w, "generate recovery codes", err) + s.internal(w, r, "generate recovery codes", err) return } if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil { - s.internal(w, "store recovery codes", err) + s.internal(w, r, "store recovery codes", err) return } writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes}) @@ -140,7 +140,7 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re func (s *Server) handleRecoveryCodesCount(w http.ResponseWriter, r *http.Request) { n, err := s.db.CountRecoveryCodes(r.Context(), userFrom(r.Context()).ID) if err != nil { - s.internal(w, "count recovery codes", err) + s.internal(w, r, "count recovery codes", err) return } writeJSON(w, http.StatusOK, map[string]int{"remaining": n}) diff --git a/internal/httpapi/users_handlers.go b/internal/httpapi/users_handlers.go index 627a0d7..e461bc9 100644 --- a/internal/httpapi/users_handlers.go +++ b/internal/httpapi/users_handlers.go @@ -54,12 +54,12 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { users, err := s.db.ListUsers(r.Context()) if err != nil { - s.internal(w, "list users", err) + s.internal(w, r, "list users", err) return } invites, err := s.db.ListPendingInvites(r.Context()) if err != nil { - s.internal(w, "list pending invites", err) + s.internal(w, r, "list pending invites", err) return } @@ -128,7 +128,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { token, _, err := s.db.CreateInvite(r.Context(), email, "", store.RoleEditor, inviter.ID, s.cfg.InviteTTL) if err != nil { - s.internal(w, "create instance invite", err) + s.internal(w, r, "create instance invite", err) return } link := s.cfg.BaseURL + "/invite?token=" + token @@ -170,7 +170,7 @@ func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) { } if err := s.db.SetUserAdmin(r.Context(), userID, *req.IsAdmin); err != nil { - s.storeError(w, "set user admin", err) + s.storeError(w, r, "set user admin", err) return } s.log.Info("admin changed", "by", me.ID, "user", userID, "is_admin", *req.IsAdmin) @@ -197,7 +197,7 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { } if err := s.db.DeleteUser(r.Context(), userID); err != nil { - s.storeError(w, "delete user", err) + s.storeError(w, r, "delete user", err) return } s.log.Info("user deleted", "by", me.ID, "user", userID) @@ -206,7 +206,7 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) { if err := s.db.DeleteInvite(r.Context(), chi.URLParam(r, "inviteID")); err != nil { - s.storeError(w, "delete invite", err) + s.storeError(w, r, "delete invite", err) return } w.WriteHeader(http.StatusNoContent) @@ -218,7 +218,7 @@ func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) { func (s *Server) refuseIfLastAdmin(w http.ResponseWriter, r *http.Request, userID, message string) error { target, err := s.db.UserByID(r.Context(), userID) if err != nil { - s.storeError(w, "get user", err) + s.storeError(w, r, "get user", err) return err } if !target.IsAdmin { @@ -226,7 +226,7 @@ func (s *Server) refuseIfLastAdmin(w http.ResponseWriter, r *http.Request, userI } admins, err := s.db.CountAdmins(r.Context()) if err != nil { - s.internal(w, "count admins", err) + s.internal(w, r, "count admins", err) return err } if admins <= 1 { diff --git a/internal/httpapi/users_test.go b/internal/httpapi/users_test.go index 517408c..0b49091 100644 --- a/internal/httpapi/users_test.go +++ b/internal/httpapi/users_test.go @@ -277,3 +277,70 @@ func indexAfter(s, needle string) int { } return 0 } + +// A 500 has to leave something behind that a restart cannot take. +// +// The panel's watcher already reports that one happened. What it cannot report is +// what it was: that lives in the container's log, and a Rune replaces the +// container on every restart, so the explanation is usually gone before anybody +// looks. +func TestAServerErrorIsKeptWhereARestartCannotTakeIt(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + // Nothing has gone wrong yet. + _, empty := ts.do(t, "GET", "/api/v1/errors", nil) + if rows, _ := empty["errors"].([]any); len(rows) != 0 { + t.Fatalf("a fresh instance has errors already: %v", rows) + } + + // Break something the handler cannot survive: the table it reads is gone, so + // the query fails and the handler answers 500. Dropping it is a blunt way to + // provoke that, and a faithful one — it is the same code path a disk error or + // a corrupt page would take. + if _, err := ts.db.ExecContext(t.Context(), `DROP TABLE project_groups`); err != nil { + t.Fatal(err) + } + resp, _ := ts.do(t, "GET", "/api/v1/project-groups", nil) + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status %d, want 500 — the fault was not provoked", resp.StatusCode) + } + + _, body := ts.do(t, "GET", "/api/v1/errors", nil) + rows, _ := body["errors"].([]any) + if len(rows) != 1 { + t.Fatalf("want one recorded error, got %v", rows) + } + row := rows[0].(map[string]any) + if row["what"] != "list project groups" { + t.Errorf("what = %v — the operation is the part a status code cannot give", row["what"]) + } + if row["path"] != "/api/v1/project-groups" || row["method"] != "GET" { + t.Errorf("path/method = %v %v", row["method"], row["path"]) + } + if row["status"] != float64(500) { + t.Errorf("status = %v", row["status"]) + } + if row["message"] == "" { + t.Error("no message: the row says something broke and not what") + } + if row["user_name"] != "Kristian" { + t.Errorf("user_name = %v — a fault only one account hits is a different problem", + row["user_name"]) + } + if row["request_id"] == "" { + t.Error("no request id, so the row cannot be tied back to the log line") + } +} + +// The list is an administrator's: it carries paths, accounts and the server's own +// error strings. +func TestTheErrorLogIsAdminsOnly(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + other := ts.newUser(t, "anden@example.dk", "Anden") + + if resp, _ := other.do(t, "GET", "/api/v1/errors", nil); resp.StatusCode != http.StatusForbidden { + t.Errorf("status %d, want 403", resp.StatusCode) + } +} diff --git a/internal/httpapi/views_handlers.go b/internal/httpapi/views_handlers.go index 6646ca9..2d623a9 100644 --- a/internal/httpapi/views_handlers.go +++ b/internal/httpapi/views_handlers.go @@ -31,7 +31,7 @@ func (s *Server) handleToday(w http.ResponseWriter, r *http.Request) { Limit: 500, }) if err != nil { - s.internal(w, "today", err) + s.internal(w, r, "today", err) return } @@ -71,7 +71,7 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) { Limit: 1000, }) if err != nil { - s.internal(w, "upcoming", err) + s.internal(w, r, "upcoming", err) return } @@ -101,7 +101,7 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePeople(w http.ResponseWriter, r *http.Request) { people, err := s.db.ListPeople(r.Context(), userFrom(r.Context()).ID) if err != nil { - s.internal(w, "list people", err) + s.internal(w, r, "list people", err) return } out := make([]personJSON, 0, len(people)) @@ -142,7 +142,7 @@ func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) { Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500), }) if err != nil { - s.internal(w, "delegated", err) + s.internal(w, r, "delegated", err) return } @@ -157,7 +157,7 @@ func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) { people, err := s.db.PeopleByIDs(r.Context(), ids) if err != nil { - s.internal(w, "delegated people", err) + s.internal(w, r, "delegated people", err) return } @@ -209,7 +209,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100), }) if err != nil { - s.internal(w, "search tasks", err) + s.internal(w, r, "search tasks", err) return } @@ -217,7 +217,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { // second FTS index for that would be more machinery than the problem needs. projects, err := s.db.ListProjects(r.Context(), user.ID, false) if err != nil { - s.internal(w, "search projects", err) + s.internal(w, r, "search projects", err) return } matched := []projectJSON{} @@ -239,7 +239,7 @@ func (s *Server) handleActivity(w http.ResponseWriter, r *http.Request) { entries, err := s.db.ListActivity(r.Context(), chi.URLParam(r, "projectID"), parseLimit(r.URL.Query().Get("limit"), 50, 200)) if err != nil { - s.internal(w, "list activity", err) + s.internal(w, r, "list activity", err) return } diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index 8eb9405..e83ab87 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -55,6 +55,7 @@ func (r *Runner) Start(ctx context.Context) { r.every(ctx, "backup", time.Hour, r.nightlyBackup) r.every(ctx, "trash", time.Hour, r.emptyTrash) r.every(ctx, "sessions", 6*time.Hour, r.purgeSessions) + r.every(ctx, "errors", 12*time.Hour, r.purgeErrors) // Ten minutes is a compromise: Gmail's push notifications would be instant but // need a public webhook and a Cloud Pub/Sub topic, which is a lot of Google // account for a to-do app. Ten minutes is fast enough for something somebody @@ -265,6 +266,20 @@ func (r *Runner) emptyTrash(ctx context.Context) error { return nil } +// purgeErrors keeps the diagnostic bounded. Thirty days is long enough to answer +// "what happened last week" and short enough that a fault loop cannot fill the +// volume the tasks live on. +func (r *Runner) purgeErrors(ctx context.Context) error { + n, err := r.db.PurgeOldErrors(ctx, 30*24*time.Hour) + if err != nil { + return err + } + if n > 0 { + r.log.Debug("old error rows removed", "count", n) + } + return nil +} + func (r *Runner) purgeSessions(ctx context.Context) error { n, err := r.db.PurgeExpiredSessions(ctx) if err != nil { diff --git a/internal/store/errorlog.go b/internal/store/errorlog.go new file mode 100644 index 0000000..e45dec5 --- /dev/null +++ b/internal/store/errorlog.go @@ -0,0 +1,77 @@ +package store + +import ( + "context" + "time" +) + +// A ServerError is one 500 the API answered, kept so it can be read after the +// container that produced it has gone. +type ServerError struct { + ID string + At time.Time + Method string + Path string + Status int + What string + Message string + UserID string + UserName string + RequestID string +} + +// RecordError stores one failure. +// +// Errors here are dropped rather than returned: this is called from the path that +// is already reporting a failure to somebody, and a diagnostic that can itself +// fail the request would turn one broken screen into two. +func (db *DB) RecordError(ctx context.Context, e ServerError) { + var user any + if e.UserID != "" { + user = e.UserID + } + _, _ = db.ExecContext(ctx, + `INSERT INTO error_log (id, at, method, path, status, what, message, user_id, request_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + NewID(), time.Now().Unix(), e.Method, truncate(e.Path, 500), e.Status, + truncate(e.What, 200), truncate(e.Message, 2000), user, truncate(e.RequestID, 100)) +} + +// ListErrors returns the most recent failures, newest first. +func (db *DB) ListErrors(ctx context.Context, limit int) ([]ServerError, error) { + rows, err := db.QueryContext(ctx, ` + SELECT e.id, e.at, e.method, e.path, e.status, e.what, e.message, + COALESCE(e.user_id, ''), COALESCE(u.name, ''), e.request_id + FROM error_log e + LEFT JOIN users u ON u.id = e.user_id + ORDER BY e.at DESC + LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []ServerError{} + for rows.Next() { + var e ServerError + var at int64 + if err := rows.Scan(&e.ID, &at, &e.Method, &e.Path, &e.Status, &e.What, + &e.Message, &e.UserID, &e.UserName, &e.RequestID); err != nil { + return nil, err + } + e.At = time.Unix(at, 0).UTC() + out = append(out, e) + } + return out, rows.Err() +} + +// PurgeOldErrors is run by the nightly sweep. A diagnostic that grows without +// limit is a disk that fills for a reason nobody expected. +func (db *DB) PurgeOldErrors(ctx context.Context, keep time.Duration) (int64, error) { + res, err := db.ExecContext(ctx, + `DELETE FROM error_log WHERE at < ?`, time.Now().Add(-keep).Unix()) + if err != nil { + return 0, err + } + return res.RowsAffected() +} diff --git a/internal/store/migrations/0007_error_log.sql b/internal/store/migrations/0007_error_log.sql new file mode 100644 index 0000000..3c5f4b4 --- /dev/null +++ b/internal/store/migrations/0007_error_log.sql @@ -0,0 +1,27 @@ +-- Server errors, kept where they can outlive the container that produced them. +-- +-- verdande already logs the real cause of every 500 to stdout. On a Rune that is +-- the container's log, and every restart starts a new one — so the panel can +-- report "HTTP 5xx, twice, at 11:49" long after the line explaining it has gone. +-- An operator is then told that something broke and given no way to find out what. +-- +-- Bounded by the sweeper rather than by size: this is a diagnostic, and one that +-- grows without limit is a disk that fills for a reason nobody expected. +CREATE TABLE error_log ( + id TEXT PRIMARY KEY, + at INTEGER NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + status INTEGER NOT NULL, + -- what: the operation the handler was attempting, in the words it uses in the + -- log — "list projects", "move task". The detail a stack trace would give and + -- a status code cannot. + what TEXT NOT NULL, + message TEXT NOT NULL, + -- Whose request it was, so a fault that only one account hits is visible as + -- one. NULL for anything that failed before authentication. + user_id TEXT REFERENCES users (id) ON DELETE SET NULL, + request_id TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX idx_error_log_at ON error_log (at DESC); diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 70fe2e9..5a5e47d 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -153,9 +153,10 @@ test('hver fane under indstillinger renderer', async ({ page }) => { 'Integrationer', 'AI', 'API-tokens', - // Only an administrator sees this one, and the account these tests run as + // Only an administrator sees these two, and the account these tests run as // is the first account, which is one. 'Brugere', + 'Fejl', 'Data og skabeloner' ]) { await tabs.getByRole('link', { name: label, exact: true }).click(); diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 2690407..69148ac 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -178,6 +178,7 @@ export const api = { setUserAdmin: (id, isAdmin) => patch(`/users/${id}`, { is_admin: isAdmin }), deleteUser: (id) => del(`/users/${id}`), revokeInvite: (id) => del(`/invites/${id}`), + listErrors: () => get('/errors'), recoveryCodes: () => get('/auth/recovery-codes'), regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }), diff --git a/web/src/routes/indstillinger/+layout.svelte b/web/src/routes/indstillinger/+layout.svelte index b0ccaf1..01f2b12 100644 --- a/web/src/routes/indstillinger/+layout.svelte +++ b/web/src/routes/indstillinger/+layout.svelte @@ -25,7 +25,12 @@ { href: '/indstillinger/integrationer', label: 'Integrationer' }, { href: '/indstillinger/ai', label: 'AI' }, { href: '/indstillinger/tokens', label: 'API-tokens' }, - ...(app.user?.is_admin ? [{ href: '/indstillinger/brugere', label: 'Brugere' }] : []), + ...(app.user?.is_admin + ? [ + { href: '/indstillinger/brugere', label: 'Brugere' }, + { href: '/indstillinger/fejl', label: 'Fejl' } + ] + : []), { href: '/indstillinger/data', label: 'Data og skabeloner' } ]); diff --git a/web/src/routes/indstillinger/fejl/+page.svelte b/web/src/routes/indstillinger/fejl/+page.svelte new file mode 100644 index 0000000..8082574 --- /dev/null +++ b/web/src/routes/indstillinger/fejl/+page.svelte @@ -0,0 +1,138 @@ + + +
+
+

Serverfejl

+

+ Hver 500, API'et har svaret, med hvad handleren var i gang med og fejlen den + ramte. Gemt i databasen, ikke kun i loggen — containerens log starter forfra + ved hver genstart, så forklaringen er som regel væk, når nogen kigger. + Ryddes efter 30 dage. +

+
+ + {#if errors.length} +
    + {#each errors as error (error.id)} +
  • +
    + + {error.status} + {error.what} + + {error.message} + + {error.method} + {error.path}{#if error.user_name}{' · '}{error.user_name}{/if}{#if error.request_id}{' · ' + + error.request_id}{/if} + +
    + {when(error.at)} +
  • + {/each} +
+ {:else if loaded} +

Ingen fejl registreret. Det er den rigtige tilstand.

+ {/if} +
+ +