Compare commits

...
3 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 c2e2d66e76 feat(admin): keep server errors where a restart cannot take them
Release / Build and publish image (push) Failing after 12m56s
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 <noreply@anthropic.com>
2026-08-17 22:58:02 +02:00
Kristian WindandClaude Opus 5 4be934ccc1 feat(pwa): the PNG icons the manifest and iOS have always wanted
The manifest pointed at icon.svg and nothing else. Chrome and Firefox
install happily on that; Safari does not read the manifest's icons at all
and looks for /apple-touch-icon.png by name, so an installed PWA on iOS got
a screenshot of the page as its home-screen icon.

This was written down as blocked: there is no SVG renderer on this machine,
which is why the references were removed rather than left pointing at files
that did not exist. Playwright is an SVG renderer, and it has been a
dependency the whole time.

scripts/generate-icons.js renders the same SVG at 192, 512 and 180. A script
rather than a test, so CI never runs it and never rewrites a tracked binary
— run it when the icon changes. The smoke test already fetches every icon
the manifest names, and now asks for the Apple one by name too, so a
reference with no file behind it fails there rather than on somebody's
phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:37:52 +02:00
Kristian WindandClaude Opus 5 7094e05ddd feat(sidebar): drop a task on "I dag"
"Do it today" is the most-made rescheduling there is, and the sidebar is
where the pointer already is when you have picked a task up.

Only that one of the two date views. "Kommende" is a range rather than a
day — it starts today and runs a week — so a drop on it would have to
invent a date its own label does not name. The month grid inside it takes a
drop per cell, which is where a specific day is actually on offer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:35:40 +02:00
38 changed files with 753 additions and 179 deletions
+2
View File
@@ -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 | | Settings: account, notifications, integrations, AI, tokens, data | Done |
| Device list: where you are signed in, and signing one out | Done, tested | | Device list: where you are signed in, and signing one out | Done, tested |
| User administration: invite to the instance, promote, remove | 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 | | 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 to reorder, in both the list and the board | Done |
| Drag a task to another project or another day | Done, smoke-tested | | Drag a task to another project or another day | Done, smoke-tested |
+54
View File
@@ -856,6 +856,60 @@ paths:
"204": { description: Deleted. } "204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" } "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: /api/v1/users:
get: get:
tags: [auth] tags: [auth]
+39 -17
View File
@@ -8,6 +8,8 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/go-chi/chi/v5/middleware"
"github.com/kristianwind/verdande/internal/auth" "github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/store" "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) token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
if err != nil { if err != nil {
s.internal(w, "create session", err) s.internal(w, r, "create session", err)
return return
} }
s.setSessionCookie(w, token, ttl) 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 { 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 return
} }
// The cookie is reissued so its lifetime matches the session's new one. // 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 { 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 return
} }
updated, err := s.db.UserByID(r.Context(), user.ID) updated, err := s.db.UserByID(r.Context(), user.ID)
if err != nil { if err != nil {
s.internal(w, "reload user", err) s.internal(w, r, "reload user", err)
return return
} }
writeJSON(w, http.StatusOK, toUserJSON(updated)) 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) hash, err := auth.HashPassword(req.Password)
if err != nil { if err != nil {
s.internal(w, "hash password", err) s.internal(w, r, "hash password", err)
return return
} }
@@ -277,14 +279,14 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
"an account already exists for that email address") "an account already exists for that email address")
return return
} }
s.internal(w, "create user", err) s.internal(w, r, "create user", err)
return return
} }
// Accepting the invite is what adds the membership and burns the token, in one // 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. // 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 { 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 return
} }
@@ -328,7 +330,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.Password) hash, err := auth.HashPassword(req.Password)
if err != nil { if err != nil {
s.internal(w, "hash password", err) s.internal(w, r, "hash password", err)
return 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") writeError(w, http.StatusConflict, CodeConflict, "this instance is already set up")
return return
} }
s.internal(w, "create first admin", err) s.internal(w, r, "create first admin", err)
return 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) { func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
n, err := s.db.UserCount(r.Context()) n, err := s.db.UserCount(r.Context())
if err != nil { if err != nil {
s.internal(w, "count users", err) s.internal(w, r, "count users", err)
return return
} }
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0}) 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) { 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) token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), s.cfg.SessionTTL, false)
if err != nil { if err != nil {
s.internal(w, "create session", err) s.internal(w, r, "create session", err)
return return
} }
s.setSessionCookie(w, token, s.cfg.SessionTTL) s.setSessionCookie(w, token, s.cfg.SessionTTL)
@@ -393,7 +395,7 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
if err == nil { if err == nil {
token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL) token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL)
if err != nil { if err != nil {
s.internal(w, "create password reset", err) s.internal(w, r, "create password reset", err)
return return
} }
link := s.cfg.BaseURL + "/reset?token=" + token 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) hash, err := auth.HashPassword(req.Password)
if err != nil { if err != nil {
s.internal(w, "hash password", err) s.internal(w, r, "hash password", err)
return return
} }
// Every session ends: a password reset is what somebody does when they think // Every session ends: a password reset is what somebody does when they think
// another person has been in their account. // another person has been in their account.
if err := s.db.UpdatePasswordHash(r.Context(), userID, hash, ""); err != nil { 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 return
} }
@@ -469,7 +471,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.NewPassword) hash, err := auth.HashPassword(req.NewPassword)
if err != nil { if err != nil {
s.internal(w, "hash password", err) s.internal(w, r, "hash password", err)
return return
} }
keep := "" keep := ""
@@ -477,7 +479,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
keep = session.ID keep = session.ID
} }
if err := s.db.UpdatePasswordHash(r.Context(), user.ID, hash, keep); err != nil { 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 return
} }
w.WriteHeader(http.StatusNoContent) 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 // 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 // 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. // 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) 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") writeError(w, http.StatusInternalServerError, CodeInternal, "something went wrong")
} }
+6 -6
View File
@@ -85,7 +85,7 @@ func (s *Server) handleCalDAVHome(w http.ResponseWriter, r *http.Request) {
projects, err := s.db.ListProjects(r.Context(), user.ID, false) projects, err := s.db.ListProjects(r.Context(), user.ID, false)
if err != nil { if err != nil {
s.internal(w, "caldav home", err) s.internal(w, r, "caldav home", err)
return return
} }
@@ -131,7 +131,7 @@ func (s *Server) handleCalDAVCalendar(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Depth") != "0" { if r.Header.Get("Depth") != "0" {
tasks, err := s.caldavTasks(r, user, projectID) tasks, err := s.caldavTasks(r, user, projectID)
if err != nil { if err != nil {
s.internal(w, "caldav tasks", err) s.internal(w, r, "caldav tasks", err)
return return
} }
for _, t := range tasks { 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) tasks, err := s.caldavTasks(r, user, projectID)
if err != nil { if err != nil {
s.internal(w, "caldav report", err) s.internal(w, r, "caldav report", err)
return return
} }
byID := map[string]store.Task{} byID := map[string]store.Task{}
@@ -240,7 +240,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
update.DueDatetime = parsed.DueDatetime update.DueDatetime = parsed.DueDatetime
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, update); err != nil { 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 return
} }
if parsed.Completed && !existing.Completed() { if parsed.Completed && !existing.Completed() {
@@ -275,7 +275,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
task.Content = "(uden titel)" task.Content = "(uden titel)"
} }
if err := s.db.CreateTask(r.Context(), task, nil); err != nil { 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 return
} }
s.publish(projectID, "task.created", toTaskJSON(*task)) s.publish(projectID, "task.created", toTaskJSON(*task))
@@ -294,7 +294,7 @@ func (s *Server) handleCalDAVDelete(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.db.DeleteTask(r.Context(), taskID); err != nil { if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
s.internal(w, "caldav delete", err) s.internal(w, r, "caldav delete", err)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
+12 -12
View File
@@ -68,7 +68,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
comments, err := s.db.ListComments(r.Context(), taskID) comments, err := s.db.ListComments(r.Context(), taskID)
if err != nil { if err != nil {
s.internal(w, "list comments", err) s.internal(w, r, "list comments", err)
return return
} }
out := make([]commentJSON, 0, len(comments)) 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) files, err := s.db.ListTaskAttachments(r.Context(), taskID)
if err != nil { if err != nil {
s.internal(w, "list attachments", err) s.internal(w, r, "list attachments", err)
return return
} }
direct := make([]attachmentJSON, 0, len(files)) 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) comment, err := s.db.CreateComment(r.Context(), taskID, user.ID, body)
if err != nil { if err != nil {
s.internal(w, "create comment", err) s.internal(w, r, "create comment", err)
return return
} }
comment.UserName, comment.UserColor = user.Name, user.AvatarColor 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) err := s.db.UpdateComment(r.Context(), chi.URLParam(r, "commentID"), userFrom(r.Context()).ID, body)
if err != nil { if err != nil {
s.storeError(w, "update comment", err) s.storeError(w, r, "update comment", err)
return return
} }
w.WriteHeader(http.StatusNoContent) 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) taskID, err := s.db.CommentTask(r.Context(), commentID)
if err != nil { if err != nil {
s.storeError(w, "comment task", err) s.storeError(w, r, "comment task", err)
return return
} }
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID) 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 { 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 return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -219,7 +219,7 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
stored, size, err := s.storeUpload(file) stored, size, err := s.storeUpload(file)
if err != nil { if err != nil {
s.internal(w, "store upload", err) s.internal(w, r, "store upload", err)
return 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 { if err := s.db.CreateAttachment(r.Context(), a); err != nil {
os.Remove(filepath.Join(s.cfg.FilesDir(), stored)) os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
s.internal(w, "record attachment", err) s.internal(w, r, "record attachment", err)
return return
} }
@@ -306,7 +306,7 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID) taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil { if err != nil {
s.storeError(w, "attachment task", err) s.storeError(w, r, "attachment task", err)
return return
} }
if _, err := store.TaskRole(r.Context(), s.db, taskID, user.ID); err != nil { 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) a, err := s.db.GetAttachment(r.Context(), attachmentID)
if err != nil { if err != nil {
s.storeError(w, "get attachment", err) s.storeError(w, r, "get attachment", err)
return return
} }
@@ -358,7 +358,7 @@ func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request)
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID) taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil { if err != nil {
s.storeError(w, "attachment task", err) s.storeError(w, r, "attachment task", err)
return return
} }
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID) 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) path, err := s.db.DeleteAttachment(r.Context(), attachmentID)
if err != nil { if err != nil {
s.storeError(w, "delete attachment", err) s.storeError(w, r, "delete attachment", err)
return return
} }
+47
View File
@@ -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})
}
+13 -13
View File
@@ -36,13 +36,13 @@ func (s *Server) handleICSFeed(w http.ResponseWriter, r *http.Request) {
Limit: 2000, Limit: 2000,
}) })
if err != nil { if err != nil {
s.internal(w, "ics feed", err) s.internal(w, r, "ics feed", err)
return return
} }
projects, err := s.db.ListProjects(r.Context(), user.ID, true) projects, err := s.db.ListProjects(r.Context(), user.ID, true)
if err != nil { if err != nil {
s.internal(w, "ics feed projects", err) s.internal(w, r, "ics feed projects", err)
return return
} }
names := map[string]string{} 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) token, err := s.db.EnsureICSToken(r.Context(), user.ID)
if err != nil { if err != nil {
s.internal(w, "ics token", err) s.internal(w, r, "ics token", err)
return return
} }
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"}) 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() token, err := auth.NewToken()
if err != nil { if err != nil {
s.internal(w, "generate feed token", err) s.internal(w, r, "generate feed token", err)
return return
} }
if err := s.db.SetICSToken(r.Context(), user.ID, token); err != nil { 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 return
} }
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"}) 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) reminders, err := s.db.ListReminders(r.Context(), taskID)
if err != nil { if err != nil {
s.internal(w, "list reminders", err) s.internal(w, r, "list reminders", err)
return return
} }
out := make([]reminderJSON, 0, len(reminders)) 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) rem, err := s.db.CreateReminder(r.Context(), taskID, user.ID, at, req.OffsetMin)
if err != nil { if err != nil {
s.internal(w, "create reminder", err) s.internal(w, r, "create reminder", err)
return 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) { func (s *Server) handleDeleteReminder(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID) err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.storeError(w, "delete reminder", err) s.storeError(w, r, "delete reminder", err)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -238,7 +238,7 @@ func toTemplateJSON(t store.Template) templateJSON {
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID) templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "list templates", err) s.internal(w, r, "list templates", err)
return return
} }
out := make([]templateJSON, 0, len(templates)) 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, tpl, err := s.db.SaveProjectAsTemplate(r.Context(), req.ProjectID, user.ID,
strings.TrimSpace(req.Name), strings.TrimSpace(req.Description)) strings.TrimSpace(req.Name), strings.TrimSpace(req.Description))
if err != nil { if err != nil {
s.storeError(w, "save template", err) s.storeError(w, r, "save template", err)
return return
} }
writeJSON(w, http.StatusCreated, toTemplateJSON(*tpl)) 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")) tpl, err := s.db.GetTemplate(r.Context(), user.ID, chi.URLParam(r, "templateID"))
if err != nil { if err != nil {
s.storeError(w, "get template", err) s.storeError(w, r, "get template", err)
return 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) project, err := s.db.CreateProjectFromTemplate(r.Context(), tpl, user.ID, strings.TrimSpace(req.Name), start)
if err != nil { if err != nil {
s.internal(w, "create from template", err) s.internal(w, r, "create from template", err)
return return
} }
s.activity(r, project.ID, "", "project.created", map[string]any{ 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) { func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID")) err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID"))
if err != nil { if err != nil {
s.storeError(w, "delete template", err) s.storeError(w, r, "delete template", err)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
+10 -10
View File
@@ -27,7 +27,7 @@ func toFilterJSON(f store.Filter) filterJSON {
func (s *Server) handleListFilters(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListFilters(w http.ResponseWriter, r *http.Request) {
filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID) filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "list filters", err) s.internal(w, r, "list filters", err)
return return
} }
out := make([]filterJSON, 0, len(filters)) out := make([]filterJSON, 0, len(filters))
@@ -74,7 +74,7 @@ func (s *Server) handleCreateFilter(w http.ResponseWriter, r *http.Request) {
f.SortOrder = *req.SortOrder f.SortOrder = *req.SortOrder
} }
if err := s.db.CreateFilter(r.Context(), f); err != nil { if err := s.db.CreateFilter(r.Context(), f); err != nil {
s.internal(w, "create filter", err) s.internal(w, r, "create filter", err)
return return
} }
writeJSON(w, http.StatusCreated, toFilterJSON(*f)) 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, Name: req.Name, Query: req.Query, Color: req.Color, SortOrder: req.SortOrder,
}) })
if err != nil { if err != nil {
s.storeError(w, "update filter", err) s.storeError(w, r, "update filter", err)
return return
} }
w.WriteHeader(http.StatusNoContent) 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) { func (s *Server) handleDeleteFilter(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID")) err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID"))
if err != nil { if err != nil {
s.storeError(w, "delete filter", err) s.storeError(w, r, "delete filter", err)
return return
} }
w.WriteHeader(http.StatusNoContent) 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")) f, err := s.db.GetFilter(r.Context(), user.ID, chi.URLParam(r, "filterID"))
if err != nil { if err != nil {
s.storeError(w, "get filter", err) s.storeError(w, r, "get filter", err)
return return
} }
s.runQuery(w, r, f.Query) 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), Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
}) })
if err != nil { if err != nil {
s.internal(w, "run filter", err) s.internal(w, r, "run filter", err)
return return
} }
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)}) 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) { func (s *Server) handleListLabels(w http.ResponseWriter, r *http.Request) {
labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID) labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "list labels", err) s.internal(w, r, "list labels", err)
return return
} }
out := make([]labelJSON, 0, len(labels)) 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") writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
return return
} }
s.internal(w, "create label", err) s.internal(w, r, "create label", err)
return return
} }
s.publishToOwner(r, "label.changed", labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0}) 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") writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
return return
} }
s.storeError(w, "update label", err) s.storeError(w, r, "update label", err)
return return
} }
s.publishToOwner(r, "label.changed", nil) 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) { func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID")) err := s.db.DeleteLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID"))
if err != nil { if err != nil {
s.storeError(w, "delete label", err) s.storeError(w, r, "delete label", err)
return return
} }
s.publishToOwner(r, "label.changed", nil) s.publishToOwner(r, "label.changed", nil)
+5 -5
View File
@@ -37,13 +37,13 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
pkce, err := gmail.NewPKCE() pkce, err := gmail.NewPKCE()
if err != nil { if err != nil {
s.internal(w, "gmail pkce", err) s.internal(w, r, "gmail pkce", err)
return return
} }
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail") settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
if err != nil { if err != nil {
s.internal(w, "gmail settings", err) s.internal(w, r, "gmail settings", err)
return return
} }
settings["pkce_verifier"] = pkce.Verifier 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() settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil { 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 return
} }
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)}) 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") settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
if err != nil { if err != nil {
s.internal(w, "gmail settings", err) s.internal(w, r, "gmail settings", err)
return return
} }
verifier, _ := settings["pkce_verifier"].(string) 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 { 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 return
} }
s.log.Info("gmail connected", "user", user.ID, "mailbox", email) s.log.Info("gmail connected", "user", user.ID, "mailbox", email)
+8 -8
View File
@@ -69,7 +69,7 @@ func (s *Server) handleImportTodoist(w http.ResponseWriter, r *http.Request) {
project := todoist.FromRows(rows, name) project := todoist.FromRows(rows, name)
result, err := s.importProject(r, user, project) result, err := s.importProject(r, user, project)
if err != nil { if err != nil {
s.internal(w, "import project", err) s.internal(w, r, "import project", err)
return return
} }
writeJSON(w, http.StatusCreated, result) 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) result, err := s.importProject(r, user, project)
if err != nil { if err != nil {
s.internal(w, "import csv", err) s.internal(w, r, "import csv", err)
return return
} }
writeJSON(w, http.StatusCreated, result) 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) project, err := s.db.GetProject(r.Context(), projectID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get project", err) s.storeError(w, r, "get project", err)
return return
} }
exported, err := s.buildExport(r, user, project) exported, err := s.buildExport(r, user, project)
if err != nil { if err != nil {
s.internal(w, "export project", err) s.internal(w, r, "export project", err)
return return
} }
@@ -346,7 +346,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
projects, err := s.db.ListProjects(ctx, user.ID, true) projects, err := s.db.ListProjects(ctx, user.ID, true)
if err != nil { if err != nil {
s.internal(w, "export projects", err) s.internal(w, r, "export projects", err)
return return
} }
@@ -390,7 +390,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000, ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000,
}) })
if err != nil { if err != nil {
s.internal(w, "export tasks", err) s.internal(w, r, "export tasks", err)
return return
} }
for _, t := range tasks { 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) project, err := s.db.GetProject(r.Context(), projectID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get project", err) s.storeError(w, r, "get project", err)
return return
} }
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{ tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000, ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000,
}) })
if err != nil { if err != nil {
s.internal(w, "export ics", err) s.internal(w, r, "export ics", err)
return return
} }
+17 -17
View File
@@ -37,7 +37,7 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
token, err := s.db.EnsureMailToken(r.Context(), user.ID) token, err := s.db.EnsureMailToken(r.Context(), user.ID)
if err != nil { if err != nil {
s.internal(w, "mail token", err) s.internal(w, r, "mail token", err)
return return
} }
writeJSON(w, http.StatusOK, mailAddressResponse{ 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) { func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
token, err := auth.NewToken() token, err := auth.NewToken()
if err != nil { if err != nil {
s.internal(w, "generate mail token", err) s.internal(w, r, "generate mail token", err)
return return
} }
if err := s.db.SetMailToken(r.Context(), userFrom(r.Context()).ID, token); err != nil { 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 return
} }
writeJSON(w, http.StatusOK, mailAddressResponse{ writeJSON(w, http.StatusOK, mailAddressResponse{
@@ -134,7 +134,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
} }
if projectID == "" { if projectID == "" {
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil { if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, "inbox", err) s.internal(w, r, "inbox", err)
return return
} }
} }
@@ -145,7 +145,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID, RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
} }
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil { 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 return
} }
@@ -187,7 +187,7 @@ type aiSettings struct {
func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) {
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID) cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "ai settings", err) s.internal(w, r, "ai settings", err)
return return
} }
// The key is never sent back. A settings page that repopulates a password // 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) existing, err := s.aiConfig(r, user.ID)
if err != nil { if err != nil {
s.internal(w, "ai settings", err) s.internal(w, r, "ai settings", err)
return return
} }
// An empty key means "leave it alone", not "delete it" — otherwise saving a // 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, "model": req.Model, "api_key": key,
} }
if err := s.db.SetUserSettings(r.Context(), user.ID, "ai", settings); err != nil { 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 return
} }
w.WriteHeader(http.StatusNoContent) 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) cfg, err := s.aiConfig(r, user.ID)
if err != nil { if err != nil {
s.internal(w, "ai settings", err) s.internal(w, r, "ai settings", err)
return return
} }
if !cfg.Configured() { 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) task, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get task", err) s.storeError(w, r, "get task", err)
return return
} }
@@ -299,7 +299,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
Priority: task.Priority, CreatedBy: user.ID, Priority: task.Priority, CreatedBy: user.ID,
} }
if err := s.db.CreateTask(r.Context(), child, nil); err != nil { 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 return
} }
created = append(created, toTaskJSON(*child)) 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) cfg, err := s.aiConfig(r, user.ID)
if err != nil { if err != nil {
s.internal(w, "ai settings", err) s.internal(w, r, "ai settings", err)
return return
} }
if !cfg.Configured() { 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}) tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100})
if err != nil { if err != nil {
s.internal(w, "list tasks", err) s.internal(w, r, "list tasks", err)
return return
} }
if len(tasks) == 0 { if len(tasks) == 0 {
@@ -372,7 +372,7 @@ type gmailSettings struct {
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail") values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail")
if err != nil { if err != nil {
s.internal(w, "gmail settings", err) s.internal(w, r, "gmail settings", err)
return return
} }
str := func(key string) string { 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") values, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
if err != nil { if err != nil {
s.internal(w, "gmail settings", err) s.internal(w, r, "gmail settings", err)
return return
} }
values["trigger"] = req.Trigger values["trigger"] = req.Trigger
values["label"] = req.Label values["label"] = req.Label
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil { 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 return
} }
w.WriteHeader(http.StatusNoContent) 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. // handleDisconnectGmail forgets the connection, tokens and all.
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) { 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 { 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 return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
+1 -1
View File
@@ -154,7 +154,7 @@ func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
user, err := s.db.UserByAPIToken(r.Context(), key) user, err := s.db.UserByAPIToken(r.Context(), key)
if err != nil { if err != nil {
if !errors.Is(err, store.ErrNotFound) { if !errors.Is(err, store.ErrNotFound) {
s.internal(w, "mcp key", err) s.internal(w, r, "mcp key", err)
return return
} }
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid") writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
+1 -1
View File
@@ -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 // 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. // and shows the sign-in screen — and nothing in the logs says why.
if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) { if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) {
s.internal(w, "authenticate", err) s.internal(w, r, "authenticate", err)
return return
} }
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in") writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in")
+6 -6
View File
@@ -27,12 +27,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
list, err := s.db.ListNotifications(r.Context(), user.ID, 30) list, err := s.db.ListNotifications(r.Context(), user.ID, 30)
if err != nil { if err != nil {
s.internal(w, "list notifications", err) s.internal(w, r, "list notifications", err)
return return
} }
unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID) unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID)
if err != nil { if err != nil {
s.internal(w, "count notifications", err) s.internal(w, r, "count notifications", err)
return 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) { func (s *Server) handleMarkNotificationsRead(w http.ResponseWriter, r *http.Request) {
err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID")) err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID"))
if err != nil { if err != nil {
s.internal(w, "mark notifications read", err) s.internal(w, r, "mark notifications read", err)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -148,7 +148,7 @@ type pushSubscriptionRequest struct {
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
public, _, err := s.vapidKeys(r.Context()) public, _, err := s.vapidKeys(r.Context())
if err != nil { if err != nil {
s.internal(w, "vapid keys", err) s.internal(w, r, "vapid keys", err)
return return
} }
writeJSON(w, http.StatusOK, map[string]string{"public_key": public}) 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(), UserAgent: r.UserAgent(),
}) })
if err != nil { if err != nil {
s.internal(w, "save push subscription", err) s.internal(w, r, "save push subscription", err)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -184,7 +184,7 @@ func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil { 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 return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
+6 -6
View File
@@ -34,7 +34,7 @@ func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) {
groups, err := s.db.ListProjectGroups(r.Context(), userFrom(r.Context()).ID) groups, err := s.db.ListProjectGroups(r.Context(), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "list project groups", err) s.internal(w, r, "list project groups", err)
return return
} }
out := make([]projectGroupJSON, 0, len(groups)) out := make([]projectGroupJSON, 0, len(groups))
@@ -77,7 +77,7 @@ func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request
g.Collapsed = *req.Collapsed g.Collapsed = *req.Collapsed
} }
if err := s.db.CreateProjectGroup(r.Context(), g); err != nil { 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 return
} }
s.publishToOwner(r, "project_group.created", toProjectGroupJSON(*g)) 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{ if err := s.db.UpdateProjectGroup(r.Context(), groupID, userID, store.ProjectGroupUpdate{
Name: req.Name, Color: req.Color, Collapsed: req.Collapsed, Name: req.Name, Color: req.Color, Collapsed: req.Collapsed,
}); err != nil { }); err != nil {
s.storeError(w, "update project group", err) s.storeError(w, r, "update project group", err)
return return
} }
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID) g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
if err != nil { if err != nil {
s.storeError(w, "get project group", err) s.storeError(w, r, "get project group", err)
return return
} }
s.publishToOwner(r, "project_group.updated", toProjectGroupJSON(*g)) 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) { func (s *Server) handleDeleteProjectGroup(w http.ResponseWriter, r *http.Request) {
groupID := chi.URLParam(r, "groupID") groupID := chi.URLParam(r, "groupID")
if err := s.db.DeleteProjectGroup(r.Context(), groupID, userFrom(r.Context()).ID); err != nil { 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 return
} }
s.publishToOwner(r, "project_group.deleted", map[string]string{"id": groupID}) 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 return
} }
if err := s.db.ReorderProjectGroups(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil { 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 return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
+26 -26
View File
@@ -45,7 +45,7 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived) projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived)
if err != nil { if err != nil {
s.internal(w, "list projects", err) s.internal(w, r, "list projects", err)
return return
} }
out := make([]projectJSON, 0, len(projects)) out := make([]projectJSON, 0, len(projects))
@@ -59,7 +59,7 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context()) user := userFrom(r.Context())
p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID) p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID)
if err != nil { if err != nil {
s.storeError(w, "get project", err) s.storeError(w, r, "get project", err)
return return
} }
writeJSON(w, http.StatusOK, toProjectJSON(*p)) 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 { 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 return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -138,7 +138,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
p.ViewMode = *req.ViewMode p.ViewMode = *req.ViewMode
} }
if err := s.db.CreateProject(r.Context(), p); err != nil { if err := s.db.CreateProject(r.Context(), p); err != nil {
s.internal(w, "create project", err) s.internal(w, r, "create project", err)
return return
} }
s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name}) 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, ViewMode: req.ViewMode, Archived: req.Archived,
}) })
if err != nil { if err != nil {
s.storeError(w, "update project", err) s.storeError(w, r, "update project", err)
return return
} }
// Grouping is written separately because it is scoped differently: the fields // 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 req.GroupID != nil {
if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID, if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID,
userFrom(r.Context()).ID); err != nil { userFrom(r.Context()).ID); err != nil {
s.storeError(w, "set project group", err) s.storeError(w, r, "set project group", err)
return 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) p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.storeError(w, "get project", err) s.storeError(w, r, "get project", err)
return return
} }
s.publishToOwner(r, "project.updated", toProjectJSON(*p)) 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") writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted")
return return
} }
s.storeError(w, "delete project", err) s.storeError(w, r, "delete project", err)
return return
} }
s.activity(r, projectID, "", "project.deleted", nil) s.activity(r, projectID, "", "project.deleted", nil)
@@ -230,7 +230,7 @@ type trashedProjectJSON struct {
func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) {
trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID) trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "list trashed projects", err) s.internal(w, r, "list trashed projects", err)
return return
} }
@@ -252,7 +252,7 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
// deliberately invisible to it. Ownership is what governs restoring. // deliberately invisible to it. Ownership is what governs restoring.
owner, err := s.db.ProjectOwner(r.Context(), projectID) owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil { if err != nil {
s.storeError(w, "project owner", err) s.storeError(w, r, "project owner", err)
return return
} }
if owner != userFrom(r.Context()).ID { if owner != userFrom(r.Context()).ID {
@@ -260,7 +260,7 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.db.RestoreProject(r.Context(), projectID); err != nil { if err := s.db.RestoreProject(r.Context(), projectID); err != nil {
s.storeError(w, "restore project", err) s.storeError(w, r, "restore project", err)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -306,7 +306,7 @@ type sectionJSON struct {
func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) {
sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID")) sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID"))
if err != nil { if err != nil {
s.internal(w, "list sections", err) s.internal(w, r, "list sections", err)
return return
} }
out := make([]sectionJSON, 0, len(sections)) out := make([]sectionJSON, 0, len(sections))
@@ -336,7 +336,7 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
sec.SortOrder = *req.SortOrder sec.SortOrder = *req.SortOrder
} }
if err := s.db.CreateSection(r.Context(), sec); err != nil { if err := s.db.CreateSection(r.Context(), sec); err != nil {
s.internal(w, "create section", err) s.internal(w, r, "create section", err)
return return
} }
s.activity(r, sec.ProjectID, "", "section.created", map[string]any{"name": sec.Name}) 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") sectionID := chi.URLParam(r, "sectionID")
projectID, err := s.db.SectionProject(r.Context(), sectionID) projectID, err := s.db.SectionProject(r.Context(), sectionID)
if err != nil { if err != nil {
s.storeError(w, "section project", err) s.storeError(w, r, "section project", err)
return "", "", false return "", "", false
} }
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, userFrom(r.Context()).ID, min); err != nil { 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{ if err := s.db.UpdateSection(r.Context(), sectionID, store.SectionUpdate{
Name: req.Name, SortOrder: req.SortOrder, Name: req.Name, SortOrder: req.SortOrder,
}); err != nil { }); err != nil {
s.storeError(w, "update section", err) s.storeError(w, r, "update section", err)
return return
} }
s.activity(r, projectID, "", "section.updated", nil) s.activity(r, projectID, "", "section.updated", nil)
@@ -394,7 +394,7 @@ func (s *Server) handleDeleteSection(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.db.DeleteSection(r.Context(), sectionID); err != nil { if err := s.db.DeleteSection(r.Context(), sectionID); err != nil {
s.storeError(w, "delete section", err) s.storeError(w, r, "delete section", err)
return return
} }
s.activity(r, projectID, "", "section.deleted", nil) s.activity(r, projectID, "", "section.deleted", nil)
@@ -414,7 +414,7 @@ type memberJSON struct {
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID")) members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
if err != nil { if err != nil {
s.internal(w, "list members", err) s.internal(w, r, "list members", err)
return return
} }
out := make([]memberJSON, 0, len(members)) 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) project, err := s.db.GetProject(r.Context(), projectID, inviter.ID)
if err != nil { if err != nil {
s.storeError(w, "get project", err) s.storeError(w, r, "get project", err)
return return
} }
@@ -475,7 +475,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil { 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 return
} }
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)}) 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) token, _, err := s.db.CreateInvite(r.Context(), email, projectID, role, inviter.ID, s.cfg.InviteTTL)
if err != nil { if err != nil {
s.internal(w, "create invite", err) s.internal(w, r, "create invite", err)
return return
} }
link := s.cfg.BaseURL + "/invite?token=" + token 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. // unhelpful. Say the real reason instead.
owner, err := s.db.ProjectOwner(r.Context(), projectID) owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil { if err != nil {
s.storeError(w, "project owner", err) s.storeError(w, r, "project owner", err)
return return
} }
if userID == owner { 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 { 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 return
} }
s.activity(r, projectID, "", "member.role_changed", 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) owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil { if err != nil {
s.storeError(w, "project owner", err) s.storeError(w, r, "project owner", err)
return return
} }
if userID == owner { if userID == owner {
@@ -564,7 +564,7 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.db.RemoveMember(r.Context(), projectID, userID); err != nil { 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 return
} }
s.activity(r, projectID, "", "member.removed", map[string]any{"user_id": userID}) 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 // 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 // 403 confirms the thing exists, which is exactly what somebody probing ids wants
// to learn. // 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 { switch {
case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess): case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess):
writeError(w, http.StatusNotFound, CodeNotFound, "not found") writeError(w, http.StatusNotFound, CodeNotFound, "not found")
case errors.Is(err, store.ErrEmailInUse): case errors.Is(err, store.ErrEmailInUse):
writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account") writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account")
default: default:
s.internal(w, what, err) s.internal(w, r, what, err)
} }
} }
+1
View File
@@ -305,6 +305,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// delete somebody else's. // delete somebody else's.
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(s.requireSession, s.requireAdmin) r.Use(s.requireSession, s.requireAdmin)
r.Get("/errors", s.handleListErrors)
r.Get("/users", s.handleListUsers) r.Get("/users", s.handleListUsers)
r.Post("/users", s.handleCreateUser) r.Post("/users", s.handleCreateUser)
r.Patch("/users/{userID}", s.handleUpdateUser) r.Patch("/users/{userID}", s.handleUpdateUser)
+2 -2
View File
@@ -41,7 +41,7 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
sessions, err := s.db.ListSessions(r.Context(), user.ID) sessions, err := s.db.ListSessions(r.Context(), user.ID)
if err != nil { if err != nil {
s.internal(w, "list sessions", err) s.internal(w, r, "list sessions", err)
return return
} }
@@ -69,7 +69,7 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
sessionID := chi.URLParam(r, "sessionID") sessionID := chi.URLParam(r, "sessionID")
if err := s.db.DeleteUserSession(r.Context(), sessionID, user.ID); err != nil { 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 return
} }
if current := sessionFrom(r.Context()); current != nil && current.ID == sessionID { if current := sessionFrom(r.Context()); current != nil && current.ID == sessionID {
+17 -17
View File
@@ -99,7 +99,7 @@ func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
tasks, err := s.db.ListTasks(r.Context(), user.ID, f) tasks, err := s.db.ListTasks(r.Context(), user.ID, f)
if err != nil { if err != nil {
s.internal(w, "list tasks", err) s.internal(w, r, "list tasks", err)
return return
} }
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)}) 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) { 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) t, err := s.db.GetTask(r.Context(), chi.URLParam(r, "taskID"), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.storeError(w, "get task", err) s.storeError(w, r, "get task", err)
return return
} }
writeJSON(w, http.StatusOK, toTaskJSON(*t)) writeJSON(w, http.StatusOK, toTaskJSON(*t))
@@ -162,7 +162,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
if projectID == "" { if projectID == "" {
var err error var err error
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil { if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, "inbox", err) s.internal(w, r, "inbox", err)
return 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 { 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 return
} }
// The store wrote the labels; the struct it was handed does not know that, and // 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 { 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 return
} }
t, err := s.db.GetTask(r.Context(), taskID, user.ID) t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get task", err) s.storeError(w, r, "get task", err)
return return
} }
s.activity(r, t.ProjectID, t.ID, "task.updated", nil) 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) result, err := s.db.CompleteTask(r.Context(), taskID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "complete task", err) s.storeError(w, r, "complete task", err)
return return
} }
t, err := s.db.GetTask(r.Context(), taskID, user.ID) t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get task", err) s.storeError(w, r, "get task", err)
return return
} }
@@ -369,13 +369,13 @@ func (s *Server) handleReopenTask(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.db.ReopenTask(r.Context(), taskID); err != nil { if err := s.db.ReopenTask(r.Context(), taskID); err != nil {
s.storeError(w, "reopen task", err) s.storeError(w, r, "reopen task", err)
return return
} }
t, err := s.db.GetTask(r.Context(), taskID, user.ID) t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get task", err) s.storeError(w, r, "get task", err)
return return
} }
s.activity(r, t.ProjectID, t.ID, "task.reopened", nil) 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) t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get task", err) s.storeError(w, r, "get task", err)
return return
} }
if err := s.db.DeleteTask(r.Context(), taskID); err != nil { if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
s.storeError(w, "delete task", err) s.storeError(w, r, "delete task", err)
return return
} }
s.activity(r, t.ProjectID, "", "task.deleted", map[string]any{"content": t.Content}) 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) current, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get task", err) s.storeError(w, r, "get task", err)
return return
} }
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID) 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 { 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 return
} }
t, err := s.db.GetTask(r.Context(), taskID, user.ID) t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil { if err != nil {
s.storeError(w, "get task", err) s.storeError(w, r, "get task", err)
return return
} }
s.publish(t.ProjectID, "task.moved", toTaskJSON(*t)) s.publish(t.ProjectID, "task.moved", toTaskJSON(*t))
@@ -502,7 +502,7 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
if projectID == "" { if projectID == "" {
var err error var err error
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil { if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, "inbox", err) s.internal(w, r, "inbox", err)
return 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 { 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 return
} }
t.Labels = parsed.Labels t.Labels = parsed.Labels
+3 -3
View File
@@ -36,7 +36,7 @@ type createdTokenJSON struct {
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID) tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "list api tokens", err) s.internal(w, r, "list api tokens", err)
return 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) plaintext, token, err := s.db.CreateAPIToken(r.Context(), userFrom(r.Context()).ID, name, expires)
if err != nil { if err != nil {
s.internal(w, "create api token", err) s.internal(w, r, "create api token", err)
return 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) { func (s *Server) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID")) err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID"))
if err != nil { if err != nil {
s.storeError(w, "delete api token", err) s.storeError(w, r, "delete api token", err)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
+9 -9
View File
@@ -29,12 +29,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
secret, uri, err := auth.NewTOTPSecret(s.issuer(), user.Email) secret, uri, err := auth.NewTOTPSecret(s.issuer(), user.Email)
if err != nil { if err != nil {
s.internal(w, "generate totp secret", err) s.internal(w, r, "generate totp secret", err)
return return
} }
// Stored but not enabled: confirm decides that. // Stored but not enabled: confirm decides that.
if err := s.db.SetTOTPSecret(r.Context(), user.ID, secret, false); err != nil { 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 return
} }
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri}) 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() codes, hashes, err := auth.NewRecoveryCodes()
if err != nil { if err != nil {
s.internal(w, "generate recovery codes", err) s.internal(w, r, "generate recovery codes", err)
return return
} }
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil { 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 return
} }
if err := s.db.SetTOTPSecret(r.Context(), user.ID, user.TOTPSecret, true); err != nil { 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 return
} }
@@ -98,7 +98,7 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.db.SetTOTPSecret(r.Context(), user.ID, "", false); err != nil { 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 return
} }
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, nil); err != nil { 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() codes, hashes, err := auth.NewRecoveryCodes()
if err != nil { if err != nil {
s.internal(w, "generate recovery codes", err) s.internal(w, r, "generate recovery codes", err)
return return
} }
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil { 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 return
} }
writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes}) 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) { func (s *Server) handleRecoveryCodesCount(w http.ResponseWriter, r *http.Request) {
n, err := s.db.CountRecoveryCodes(r.Context(), userFrom(r.Context()).ID) n, err := s.db.CountRecoveryCodes(r.Context(), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "count recovery codes", err) s.internal(w, r, "count recovery codes", err)
return return
} }
writeJSON(w, http.StatusOK, map[string]int{"remaining": n}) writeJSON(w, http.StatusOK, map[string]int{"remaining": n})
+8 -8
View File
@@ -54,12 +54,12 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.db.ListUsers(r.Context()) users, err := s.db.ListUsers(r.Context())
if err != nil { if err != nil {
s.internal(w, "list users", err) s.internal(w, r, "list users", err)
return return
} }
invites, err := s.db.ListPendingInvites(r.Context()) invites, err := s.db.ListPendingInvites(r.Context())
if err != nil { if err != nil {
s.internal(w, "list pending invites", err) s.internal(w, r, "list pending invites", err)
return 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, token, _, err := s.db.CreateInvite(r.Context(), email, "", store.RoleEditor,
inviter.ID, s.cfg.InviteTTL) inviter.ID, s.cfg.InviteTTL)
if err != nil { if err != nil {
s.internal(w, "create instance invite", err) s.internal(w, r, "create instance invite", err)
return return
} }
link := s.cfg.BaseURL + "/invite?token=" + token 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 { 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 return
} }
s.log.Info("admin changed", "by", me.ID, "user", userID, "is_admin", *req.IsAdmin) 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 { if err := s.db.DeleteUser(r.Context(), userID); err != nil {
s.storeError(w, "delete user", err) s.storeError(w, r, "delete user", err)
return return
} }
s.log.Info("user deleted", "by", me.ID, "user", userID) 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) { func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
if err := s.db.DeleteInvite(r.Context(), chi.URLParam(r, "inviteID")); err != nil { 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 return
} }
w.WriteHeader(http.StatusNoContent) 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 { func (s *Server) refuseIfLastAdmin(w http.ResponseWriter, r *http.Request, userID, message string) error {
target, err := s.db.UserByID(r.Context(), userID) target, err := s.db.UserByID(r.Context(), userID)
if err != nil { if err != nil {
s.storeError(w, "get user", err) s.storeError(w, r, "get user", err)
return err return err
} }
if !target.IsAdmin { 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()) admins, err := s.db.CountAdmins(r.Context())
if err != nil { if err != nil {
s.internal(w, "count admins", err) s.internal(w, r, "count admins", err)
return err return err
} }
if admins <= 1 { if admins <= 1 {
+67
View File
@@ -277,3 +277,70 @@ func indexAfter(s, needle string) int {
} }
return 0 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)
}
}
+8 -8
View File
@@ -31,7 +31,7 @@ func (s *Server) handleToday(w http.ResponseWriter, r *http.Request) {
Limit: 500, Limit: 500,
}) })
if err != nil { if err != nil {
s.internal(w, "today", err) s.internal(w, r, "today", err)
return return
} }
@@ -71,7 +71,7 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
Limit: 1000, Limit: 1000,
}) })
if err != nil { if err != nil {
s.internal(w, "upcoming", err) s.internal(w, r, "upcoming", err)
return 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) { func (s *Server) handlePeople(w http.ResponseWriter, r *http.Request) {
people, err := s.db.ListPeople(r.Context(), userFrom(r.Context()).ID) people, err := s.db.ListPeople(r.Context(), userFrom(r.Context()).ID)
if err != nil { if err != nil {
s.internal(w, "list people", err) s.internal(w, r, "list people", err)
return return
} }
out := make([]personJSON, 0, len(people)) 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), Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
}) })
if err != nil { if err != nil {
s.internal(w, "delegated", err) s.internal(w, r, "delegated", err)
return return
} }
@@ -157,7 +157,7 @@ func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
people, err := s.db.PeopleByIDs(r.Context(), ids) people, err := s.db.PeopleByIDs(r.Context(), ids)
if err != nil { if err != nil {
s.internal(w, "delegated people", err) s.internal(w, r, "delegated people", err)
return return
} }
@@ -209,7 +209,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100), Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
}) })
if err != nil { if err != nil {
s.internal(w, "search tasks", err) s.internal(w, r, "search tasks", err)
return 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. // second FTS index for that would be more machinery than the problem needs.
projects, err := s.db.ListProjects(r.Context(), user.ID, false) projects, err := s.db.ListProjects(r.Context(), user.ID, false)
if err != nil { if err != nil {
s.internal(w, "search projects", err) s.internal(w, r, "search projects", err)
return return
} }
matched := []projectJSON{} 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"), entries, err := s.db.ListActivity(r.Context(), chi.URLParam(r, "projectID"),
parseLimit(r.URL.Query().Get("limit"), 50, 200)) parseLimit(r.URL.Query().Get("limit"), 50, 200))
if err != nil { if err != nil {
s.internal(w, "list activity", err) s.internal(w, r, "list activity", err)
return return
} }
+15
View File
@@ -55,6 +55,7 @@ func (r *Runner) Start(ctx context.Context) {
r.every(ctx, "backup", time.Hour, r.nightlyBackup) r.every(ctx, "backup", time.Hour, r.nightlyBackup)
r.every(ctx, "trash", time.Hour, r.emptyTrash) r.every(ctx, "trash", time.Hour, r.emptyTrash)
r.every(ctx, "sessions", 6*time.Hour, r.purgeSessions) 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 // 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 // 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 // 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 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 { func (r *Runner) purgeSessions(ctx context.Context) error {
n, err := r.db.PurgeExpiredSessions(ctx) n, err := r.db.PurgeExpiredSessions(ctx)
if err != nil { if err != nil {
+77
View File
@@ -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()
}
@@ -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);
+16 -1
View File
@@ -153,9 +153,10 @@ test('hver fane under indstillinger renderer', async ({ page }) => {
'Integrationer', 'Integrationer',
'AI', 'AI',
'API-tokens', '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. // is the first account, which is one.
'Brugere', 'Brugere',
'Fejl',
'Data og skabeloner' 'Data og skabeloner'
]) { ]) {
await tabs.getByRole('link', { name: label, exact: true }).click(); await tabs.getByRole('link', { name: label, exact: true }).click();
@@ -183,6 +184,12 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
expect(iconResponse.ok(), `${icon.src} findes ikke`).toBeTruthy(); expect(iconResponse.ok(), `${icon.src} findes ikke`).toBeTruthy();
} }
// Safari never reads the manifest's icons; it looks for this by name, and
// without it an installed PWA on iOS gets a screenshot of the page as its
// home-screen icon.
const appleIcon = await request.get('/apple-touch-icon.png');
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
// The service worker is what Web Push needs; without it a subscription cannot // The service worker is what Web Push needs; without it a subscription cannot
// be created at all. // be created at all.
const worker = await request.get('/sw.js'); const worker = await request.get('/sw.js');
@@ -438,6 +445,14 @@ test('en opgave kan trækkes til en anden dag og videre til et projekt', async (
await expect(day('I morgen').getByText('hent pakken')).toBeVisible(); await expect(day('I morgen').getByText('hent pakken')).toBeVisible();
await expect(day('I dag').getByText('hent pakken')).toBeHidden(); await expect(day('I dag').getByText('hent pakken')).toBeHidden();
// Onto "I dag" in the sidebar: the most-made rescheduling there is, and the
// sidebar is where the pointer already is. "Kommende" deliberately does not
// take one — it is a range, not a day, so a drop would have to invent a date
// its label does not name.
await row.dragTo(sidebar.getByRole('link', { name: 'I dag' }));
await expect(day('I dag').getByText('hent pakken')).toBeVisible();
await row.dragTo(day('I morgen'));
// And onto a project, which is a move rather than a field: the task has to // And onto a project, which is a move rather than a field: the task has to
// find a place among that project's tasks, and leave its old section behind. // find a place among that project's tasks, and leave its old section behind.
await row.dragTo(sidebar.getByRole('link', { name: 'Ærinder' })); await row.dragTo(sidebar.getByRole('link', { name: 'Ærinder' }));
+40
View File
@@ -0,0 +1,40 @@
/**
* Renders static/icon.svg to the PNGs the manifest and iOS need.
*
* node scripts/generate-icons.js
*
* A script rather than a test, so CI never runs it and never rewrites a tracked
* binary. Run it when the icon changes; the smoke test fetches every icon the
* manifest names, so a reference with no file behind it fails there.
*
* It drives Chromium because there is no SVG renderer on this machine — which is
* why these references were once removed rather than left pointing at files that
* did not exist. Playwright is a renderer, and it is already a dependency.
*/
import { chromium } from '@playwright/test';
import { readFileSync, writeFileSync } from 'node:fs';
const SIZES = [
// The two the manifest asks for: 192 for a launcher, 512 for a splash screen.
{ file: 'static/icon-192.png', size: 192 },
{ file: 'static/icon-512.png', size: 512 },
// And the one Safari wants by name. It ignores the manifest's icons entirely
// and looks for this, which is why an installed PWA on iOS otherwise gets a
// screenshot of the page as its home-screen icon.
{ file: 'static/apple-touch-icon.png', size: 180 }
];
const svg = readFileSync('static/icon.svg', 'utf8');
const browser = await chromium.launch();
const page = await browser.newPage();
for (const { file, size } of SIZES) {
await page.setViewportSize({ width: size, height: size });
await page.setContent(
`<style>html,body{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`
);
writeFileSync(file, await page.locator('svg').screenshot());
console.log(`${file} ${size}×${size}`);
}
await browser.close();
+4
View File
@@ -7,6 +7,10 @@
<meta name="description" content="verdande — opgaver og projekter, delt." /> <meta name="description" content="verdande — opgaver og projekter, delt." />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" /> <link rel="icon" href="/icon.svg" type="image/svg+xml" />
<!-- Safari ignores the manifest's icons entirely and looks for this by name.
Without it an installed PWA on iOS gets a screenshot of the page as its
home-screen icon. Generated from the same SVG by scripts/generate-icons.js. -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>verdande</title> <title>verdande</title>
<script> <script>
// Applied before first paint, so a dark-theme user never sees a white // Applied before first paint, so a dark-theme user never sees a white
+1
View File
@@ -178,6 +178,7 @@ export const api = {
setUserAdmin: (id, isAdmin) => patch(`/users/${id}`, { is_admin: isAdmin }), setUserAdmin: (id, isAdmin) => patch(`/users/${id}`, { is_admin: isAdmin }),
deleteUser: (id) => del(`/users/${id}`), deleteUser: (id) => del(`/users/${id}`),
revokeInvite: (id) => del(`/invites/${id}`), revokeInvite: (id) => del(`/invites/${id}`),
listErrors: () => get('/errors'),
recoveryCodes: () => get('/auth/recovery-codes'), recoveryCodes: () => get('/auth/recovery-codes'),
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }), regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
+42 -1
View File
@@ -224,6 +224,34 @@
await app.reorderProjects(ordered.map((p) => p.id)); await app.reorderProjects(ordered.map((p) => p.id));
} }
// --- a task dropped on "I dag" ---------------------------------------------------
/**
* "Gør det i dag" is the most-made rescheduling there is, and the sidebar is
* where the pointer already goes.
*
* Only this one of the two date views. "Kommende" is a range rather than a
* day — it starts today and runs a week — so a drop on it would have to invent
* a date the label does not name. The month grid inside it takes drops per
* cell, which is where a specific day is actually offered.
*/
let overToday = $state(false);
function todayISO() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(
now.getDate()
).padStart(2, '0')}`;
}
async function onDropOnToday(event) {
event.preventDefault();
const id = dragged(event, TASK);
overToday = false;
if (!id) return;
await app.reschedule(id, todayISO());
}
// --- a task dropped on a project ------------------------------------------------ // --- a task dropped on a project ------------------------------------------------
/** The project row lit up because a task is hovering over it. */ /** The project row lit up because a task is hovering over it. */
@@ -306,7 +334,20 @@
</div> </div>
<div class="views"> <div class="views">
<a href="/" class:active={current === '/'} onclick={onnavigate}> <!-- svelte-ignore a11y_no_static_element_interactions -->
<a
href="/"
class:active={current === '/'}
class:receiving={overToday}
onclick={onnavigate}
ondragover={(e) => {
if (!carries(e, TASK)) return;
accept(e);
overToday = true;
}}
ondragleave={() => (overToday = false)}
ondrop={onDropOnToday}
>
<span class="dot today" aria-hidden="true"></span> <span class="dot today" aria-hidden="true"></span>
I dag I dag
</a> </a>
+6 -1
View File
@@ -25,7 +25,12 @@
{ href: '/indstillinger/integrationer', label: 'Integrationer' }, { href: '/indstillinger/integrationer', label: 'Integrationer' },
{ href: '/indstillinger/ai', label: 'AI' }, { href: '/indstillinger/ai', label: 'AI' },
{ href: '/indstillinger/tokens', label: 'API-tokens' }, { 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' } { href: '/indstillinger/data', label: 'Data og skabeloner' }
]); ]);
@@ -0,0 +1,138 @@
<script>
/**
* Fejl: what has gone wrong lately, kept where a restart cannot take it.
*
* The panel's watcher already says "HTTP 5xx, twice, at 11:49". That is enough
* to know something broke and nothing at all to act on — the line explaining it
* is in the container's log, and a Rune replaces the container on every
* restart, so it is usually gone before anybody looks. These are the same
* failures written somewhere that survives.
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
let errors = $state([]);
let loaded = $state(false);
$effect(() => {
api
.listErrors()
.then((r) => {
errors = r.errors;
loaded = true;
})
.catch((e) => app.toast(humanMessage(e)));
});
function when(iso) {
const then = new Date(iso);
const seconds = Math.round((Date.now() - then) / 1000);
if (seconds < 60) return 'lige nu';
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
return then.toLocaleString('da-DK', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
</script>
<section class="panel">
<header>
<h2>Serverfejl</h2>
<p class="hint">
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.
</p>
</header>
{#if errors.length}
<ul class="rows">
{#each errors as error (error.id)}
<li>
<div class="what">
<span class="primary-line">
<span class="status">{error.status}</span>
{error.what}
</span>
<span class="secondary">{error.message}</span>
<span class="secondary mono">
{error.method}
{error.path}{#if error.user_name}{' · '}{error.user_name}{/if}{#if error.request_id}{' · ' +
error.request_id}{/if}
</span>
</div>
<span class="when">{when(error.at)}</span>
</li>
{/each}
</ul>
{:else if loaded}
<p class="hint">Ingen fejl registreret. Det er den rigtige tilstand.</p>
{/if}
</section>
<style>
.rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.rows li {
display: flex;
align-items: flex-start;
gap: var(--s3);
padding: var(--s3) 0;
border-bottom: 1px solid var(--line);
}
.rows li:last-child {
border-bottom: 0;
}
.what {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.primary-line {
display: flex;
align-items: center;
gap: var(--s2);
font-size: var(--text-sm);
}
.status {
font-size: var(--text-xs);
font-weight: 560;
color: var(--danger);
border: 1px solid var(--danger);
border-radius: var(--radius-sm);
padding: 0 var(--s1);
}
.secondary {
font-size: var(--text-xs);
color: var(--ink-faint);
overflow-wrap: anywhere;
}
.mono {
font-family: var(--font-mono);
}
.when {
font-size: var(--text-xs);
color: var(--ink-faint);
flex: none;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+19 -1
View File
@@ -12,7 +12,25 @@
"src": "/icon.svg", "src": "/icon.svg",
"sizes": "any", "sizes": "any",
"type": "image/svg+xml", "type": "image/svg+xml",
"purpose": "any maskable" "purpose": "any"
},
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
} }
] ]
} }