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>
331 lines
10 KiB
Go
331 lines
10 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/kristianwind/verdande/internal/auth"
|
|
"github.com/kristianwind/verdande/internal/ics"
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
)
|
|
|
|
// handleICSFeed serves a user's calendar.
|
|
//
|
|
// Unauthenticated by necessity: Apple Calendar and Google fetch a URL with no
|
|
// credentials and no way to prompt for any, so the token in the path *is* the
|
|
// credential. That is why it is a token of its own — it can be rotated when a feed
|
|
// URL ends up somewhere it should not, without signing anybody out.
|
|
func (s *Server) handleICSFeed(w http.ResponseWriter, r *http.Request) {
|
|
token := strings.TrimSuffix(chi.URLParam(r, "token"), ".ics")
|
|
|
|
user, err := s.db.UserByICSToken(r.Context(), token)
|
|
if err != nil {
|
|
// Not 401: a calendar client would show a password prompt nobody can
|
|
// answer. A missing feed is a missing feed.
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
|
// Everything with a date, plus recently completed ones so a calendar does
|
|
// not silently drop what was done this week.
|
|
IncludeCompleted: true,
|
|
Limit: 2000,
|
|
})
|
|
if err != nil {
|
|
s.internal(w, r, "ics feed", err)
|
|
return
|
|
}
|
|
|
|
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
|
|
if err != nil {
|
|
s.internal(w, r, "ics feed projects", err)
|
|
return
|
|
}
|
|
names := map[string]string{}
|
|
for _, p := range projects {
|
|
names[p.ID] = p.Name
|
|
}
|
|
|
|
cutoff := time.Now().AddDate(0, 0, -30)
|
|
cal := ics.Calendar{Name: "verdande — " + user.Name, Domain: feedDomain(s.cfg.BaseURL)}
|
|
|
|
for _, t := range tasks {
|
|
// A task with no date has no place in a calendar, and a completion from
|
|
// months ago is noise in every client that shows it.
|
|
if t.DueDate == "" {
|
|
continue
|
|
}
|
|
if t.CompletedAt != nil && t.CompletedAt.Before(cutoff) {
|
|
continue
|
|
}
|
|
cal.Tasks = append(cal.Tasks, ics.Task{
|
|
ID: t.ID, Content: t.Content, Description: t.Description,
|
|
ProjectName: names[t.ProjectID], DueDate: t.DueDate, DueDatetime: t.DueDatetime,
|
|
DurationMin: t.DurationMin, Priority: t.Priority, Recurrence: t.RecurrenceRule,
|
|
Completed: t.Completed(), CompletedAt: t.CompletedAt,
|
|
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", `inline; filename="`+ics.Filename("verdande")+`"`)
|
|
// A feed is per-person and changes constantly; a cached copy in a shared proxy
|
|
// would be both stale and somebody else's.
|
|
w.Header().Set("Cache-Control", "private, no-cache")
|
|
w.Write([]byte(ics.Render(cal)))
|
|
}
|
|
|
|
type feedResponse struct {
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// handleGetFeed returns the subscription URL, creating the token on first ask.
|
|
func (s *Server) handleGetFeed(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
|
|
token, err := s.db.EnsureICSToken(r.Context(), user.ID)
|
|
if err != nil {
|
|
s.internal(w, r, "ics token", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
|
|
}
|
|
|
|
// handleRotateFeed issues a new token, which immediately breaks every existing
|
|
// subscription. That is the point: it is what somebody does when the old URL has
|
|
// leaked.
|
|
func (s *Server) handleRotateFeed(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
|
|
token, err := auth.NewToken()
|
|
if err != nil {
|
|
s.internal(w, r, "generate feed token", err)
|
|
return
|
|
}
|
|
if err := s.db.SetICSToken(r.Context(), user.ID, token); err != nil {
|
|
s.internal(w, r, "set feed token", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
|
|
}
|
|
|
|
func feedDomain(baseURL string) string {
|
|
host := stripScheme(baseURL)
|
|
if i := strings.IndexAny(host, "/:"); i > 0 {
|
|
host = host[:i]
|
|
}
|
|
if host == "" {
|
|
return "verdande.local"
|
|
}
|
|
return host
|
|
}
|
|
|
|
// --- reminders --------------------------------------------------------------------
|
|
|
|
type reminderJSON struct {
|
|
ID string `json:"id"`
|
|
TaskID string `json:"task_id"`
|
|
RemindAt string `json:"remind_at,omitempty"`
|
|
OffsetMin *int `json:"offset_min,omitempty"`
|
|
Sent bool `json:"sent"`
|
|
}
|
|
|
|
type reminderRequest struct {
|
|
// Exactly one of the two. An absolute moment, or minutes relative to the
|
|
// task's due time — negative for "before", which is the only kind anybody
|
|
// actually wants.
|
|
RemindAt *string `json:"remind_at"`
|
|
OffsetMin *int `json:"offset_min"`
|
|
}
|
|
|
|
func (s *Server) handleListReminders(w http.ResponseWriter, r *http.Request) {
|
|
taskID := chi.URLParam(r, "taskID")
|
|
if _, err := store.TaskRole(r.Context(), s.db, taskID, userFrom(r.Context()).ID); err != nil {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
reminders, err := s.db.ListReminders(r.Context(), taskID)
|
|
if err != nil {
|
|
s.internal(w, r, "list reminders", err)
|
|
return
|
|
}
|
|
out := make([]reminderJSON, 0, len(reminders))
|
|
for _, rem := range reminders {
|
|
j := reminderJSON{ID: rem.ID, TaskID: rem.TaskID, OffsetMin: rem.OffsetMin, Sent: rem.SentAt != nil}
|
|
if !rem.RemindAt.IsZero() {
|
|
j.RemindAt = rem.RemindAt.Format(time.RFC3339)
|
|
}
|
|
out = append(out, j)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"reminders": out})
|
|
}
|
|
|
|
func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
|
|
var req reminderRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
taskID := chi.URLParam(r, "taskID")
|
|
user := userFrom(r.Context())
|
|
|
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
|
if err != nil || !role.CanEdit() {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
if (req.RemindAt == nil) == (req.OffsetMin == nil) {
|
|
writeFieldErrors(w, map[string]string{
|
|
"remind_at": "give either a time or an offset, not both",
|
|
})
|
|
return
|
|
}
|
|
|
|
var at *time.Time
|
|
if req.RemindAt != nil {
|
|
parsed, err := time.Parse(time.RFC3339, *req.RemindAt)
|
|
if err != nil {
|
|
writeFieldErrors(w, map[string]string{"remind_at": "must be an RFC 3339 timestamp"})
|
|
return
|
|
}
|
|
at = &parsed
|
|
}
|
|
|
|
rem, err := s.db.CreateReminder(r.Context(), taskID, user.ID, at, req.OffsetMin)
|
|
if err != nil {
|
|
s.internal(w, r, "create reminder", err)
|
|
return
|
|
}
|
|
|
|
j := reminderJSON{ID: rem.ID, TaskID: rem.TaskID, OffsetMin: rem.OffsetMin}
|
|
if at != nil {
|
|
j.RemindAt = at.Format(time.RFC3339)
|
|
}
|
|
writeJSON(w, http.StatusCreated, j)
|
|
}
|
|
|
|
func (s *Server) handleDeleteReminder(w http.ResponseWriter, r *http.Request) {
|
|
err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID)
|
|
if err != nil {
|
|
s.storeError(w, r, "delete reminder", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// --- templates ---------------------------------------------------------------------
|
|
|
|
type templateJSON struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Color string `json:"color"`
|
|
TaskCount int `json:"task_count"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
func toTemplateJSON(t store.Template) templateJSON {
|
|
return templateJSON{
|
|
ID: t.ID, Name: t.Name, Description: t.Description, Color: t.Color,
|
|
TaskCount: len(t.Body.Tasks), CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
|
templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID)
|
|
if err != nil {
|
|
s.internal(w, r, "list templates", err)
|
|
return
|
|
}
|
|
out := make([]templateJSON, 0, len(templates))
|
|
for _, t := range templates {
|
|
out = append(out, toTemplateJSON(t))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"templates": out})
|
|
}
|
|
|
|
type saveTemplateRequest struct {
|
|
ProjectID string `json:"project_id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request) {
|
|
var req saveTemplateRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
user := userFrom(r.Context())
|
|
|
|
if req.ProjectID == "" {
|
|
writeFieldErrors(w, map[string]string{"project_id": "required"})
|
|
return
|
|
}
|
|
if _, err := store.RequireProjectRole(r.Context(), s.db, req.ProjectID, user.ID, store.RoleViewer); err != nil {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
tpl, err := s.db.SaveProjectAsTemplate(r.Context(), req.ProjectID, user.ID,
|
|
strings.TrimSpace(req.Name), strings.TrimSpace(req.Description))
|
|
if err != nil {
|
|
s.storeError(w, r, "save template", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, toTemplateJSON(*tpl))
|
|
}
|
|
|
|
type useTemplateRequest struct {
|
|
Name string `json:"name"`
|
|
// StartDate is day zero for every relative due date in the template. Defaults
|
|
// to today, which is what "start this checklist now" means.
|
|
StartDate string `json:"start_date"`
|
|
}
|
|
|
|
func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
|
|
var req useTemplateRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
user := userFrom(r.Context())
|
|
|
|
tpl, err := s.db.GetTemplate(r.Context(), user.ID, chi.URLParam(r, "templateID"))
|
|
if err != nil {
|
|
s.storeError(w, r, "get template", err)
|
|
return
|
|
}
|
|
|
|
start := time.Now().In(userLocation(user.Timezone))
|
|
if req.StartDate != "" {
|
|
parsed, err := time.ParseInLocation("2006-01-02", req.StartDate, userLocation(user.Timezone))
|
|
if err != nil {
|
|
writeFieldErrors(w, map[string]string{"start_date": "must be a date like 2026-03-15"})
|
|
return
|
|
}
|
|
start = parsed
|
|
}
|
|
|
|
project, err := s.db.CreateProjectFromTemplate(r.Context(), tpl, user.ID, strings.TrimSpace(req.Name), start)
|
|
if err != nil {
|
|
s.internal(w, r, "create from template", err)
|
|
return
|
|
}
|
|
s.activity(r, project.ID, "", "project.created", map[string]any{
|
|
"name": project.Name, "from_template": tpl.Name,
|
|
})
|
|
writeJSON(w, http.StatusCreated, toProjectJSON(*project))
|
|
}
|
|
|
|
func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
|
err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID"))
|
|
if err != nil {
|
|
s.storeError(w, r, "delete template", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|