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>
148 lines
4.7 KiB
Go
148 lines
4.7 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// Where a person is signed in, and how to end one of them.
|
|
//
|
|
// `last_seen_at` has been written on every request since sessions existed —
|
|
// throttled to once a minute, precisely so this list could say "this device, 2
|
|
// minutes ago" — and nothing has ever read it. A session you cannot see is a
|
|
// session you cannot end.
|
|
|
|
type sessionJSON struct {
|
|
ID string `json:"id"`
|
|
// Current marks the one making the request, so the interface can say "denne
|
|
// enhed" rather than making somebody work it out from a user agent.
|
|
Current bool `json:"current"`
|
|
// Device is a summary; UserAgent is what it was summarised from. Both, because
|
|
// the summary is what you read and the original is what settles the question
|
|
// when the summary is not enough.
|
|
Device string `json:"device"`
|
|
UserAgent string `json:"user_agent"`
|
|
IP string `json:"ip"`
|
|
CreatedAt string `json:"created_at"`
|
|
LastSeenAt string `json:"last_seen_at"`
|
|
ExpiresAt string `json:"expires_at"`
|
|
}
|
|
|
|
// handleListSessions lists the caller's live sessions, most recently used first.
|
|
//
|
|
// The id here is the stored hash of the cookie, never the cookie itself: it cannot
|
|
// be pasted into a browser, and it is what the delete below needs.
|
|
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
current := sessionFrom(r.Context())
|
|
|
|
sessions, err := s.db.ListSessions(r.Context(), user.ID)
|
|
if err != nil {
|
|
s.internal(w, r, "list sessions", err)
|
|
return
|
|
}
|
|
|
|
out := make([]sessionJSON, 0, len(sessions))
|
|
for _, sess := range sessions {
|
|
out = append(out, sessionJSON{
|
|
ID: sess.ID,
|
|
Current: current != nil && sess.ID == current.ID,
|
|
Device: describeUserAgent(sess.UA),
|
|
UserAgent: sess.UA,
|
|
IP: sess.IP,
|
|
CreatedAt: sess.CreatedAt.Format(time.RFC3339),
|
|
LastSeenAt: sess.LastSeenAt.Format(time.RFC3339),
|
|
ExpiresAt: sess.ExpiresAt.Format(time.RFC3339),
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"sessions": out})
|
|
}
|
|
|
|
// handleDeleteSession signs one device out. Ending the current one is allowed and
|
|
// is simply logging out — refusing it would be a rule with no reason behind it,
|
|
// and somebody clearing every device wants this one gone too.
|
|
func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
sessionID := chi.URLParam(r, "sessionID")
|
|
|
|
if err := s.db.DeleteUserSession(r.Context(), sessionID, user.ID); err != nil {
|
|
s.storeError(w, r, "delete session", err)
|
|
return
|
|
}
|
|
if current := sessionFrom(r.Context()); current != nil && current.ID == sessionID {
|
|
s.clearSessionCookie(w)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// describeUserAgent turns a user-agent string into something a person recognises.
|
|
//
|
|
// Deliberately a handful of substring tests rather than a parsing library. The
|
|
// question this answers is "is one of these me?", and for that a wrong guess
|
|
// between two Chromium forks costs nothing while a dependency that has to be kept
|
|
// current costs something every year. The raw string is sent alongside, so
|
|
// anything this gets wrong is still visible.
|
|
//
|
|
// Order matters: every browser's user agent claims to be several other browsers,
|
|
// so the most specific name has to be tested first.
|
|
func describeUserAgent(ua string) string {
|
|
if strings.TrimSpace(ua) == "" {
|
|
return "Ukendt enhed"
|
|
}
|
|
|
|
browser := ""
|
|
switch {
|
|
case strings.Contains(ua, "Edg/"):
|
|
browser = "Edge"
|
|
case strings.Contains(ua, "OPR/"), strings.Contains(ua, "Opera"):
|
|
browser = "Opera"
|
|
case strings.Contains(ua, "Firefox/"):
|
|
browser = "Firefox"
|
|
case strings.Contains(ua, "Chrome/"):
|
|
browser = "Chrome"
|
|
case strings.Contains(ua, "Safari/"):
|
|
browser = "Safari"
|
|
}
|
|
|
|
system := ""
|
|
switch {
|
|
// Before "Mac OS X": an iPhone's user agent says both.
|
|
case strings.Contains(ua, "iPhone"):
|
|
system = "iPhone"
|
|
case strings.Contains(ua, "iPad"):
|
|
system = "iPad"
|
|
case strings.Contains(ua, "Android"):
|
|
system = "Android"
|
|
case strings.Contains(ua, "Mac OS X"), strings.Contains(ua, "Macintosh"):
|
|
system = "macOS"
|
|
case strings.Contains(ua, "Windows"):
|
|
system = "Windows"
|
|
case strings.Contains(ua, "Linux"):
|
|
system = "Linux"
|
|
}
|
|
|
|
switch {
|
|
case browser != "" && system != "":
|
|
return browser + " på " + system
|
|
case browser != "":
|
|
return browser
|
|
case system != "":
|
|
return system
|
|
}
|
|
// Nothing recognised: show the string itself rather than "Ukendt". A CalDAV
|
|
// client or a script is a real session, and its own name is more useful than a
|
|
// word that says the server gave up.
|
|
return truncateRunes(ua, 60)
|
|
}
|
|
|
|
func truncateRunes(s string, max int) string {
|
|
runes := []rune(s)
|
|
if len(runes) <= max {
|
|
return s
|
|
}
|
|
return string(runes[:max]) + "…"
|
|
}
|