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} + + {:else if loaded} +

Ingen fejl registreret. Det er den rigtige tilstand.

+ {/if} +
+ +