Files
verdande/internal/httpapi/caldav_handlers.go
T
Kristian WindandClaude Opus 5 cab4e703f5
Release / Build and publish image (push) Failing after 10m0s
fix(sikkerhed): CalDAV lod en viewer skrive, og Web Push kunne ringe indad
To huller fundet ved en gennemgang efter dagens arbejde. Begge har en prøve, der
er set fejle før rettelsen.

CalDAV PUT afgjorde adgang på det projekt, klienten *nævnte i adressen*, og tog
derefter opgavens id fra den samme adresse. Intet bandt de to sammen. En viewer
på et delt projekt kunne pege på et projekt, de selv ejer — indbakken, som alle
har og alle må redigere — og lægge den delte opgaves id i filnavnet. Projekt-
tjekket gik godt på indbakken, og skrivningen landede på en opgave, de kun må
læse. GetTask lukkede det ikke: den svarer "må denne person se det", og det må en
viewer. `CanEdit` er spørgsmålet, og det er det samme, handleUpdateTask altid har
stillet — API-døren har spurgt hele tiden, CalDAV-døren spurgte ikke.

Opgaven skal nu også ligge, hvor adressen siger. Uden det kan en redaktør på to
projekter nå det ene gennem det andets adresse; intet eskaleres af det i sig
selv, men en adresse, der har lov at lyve om hvor noget bor, er sådan sagen
ovenfor kom ind.

Web Push blev leveret med en almindelig http.Client. Adressen kommer fra
browserens abonnement — altså fra den, der er logget ind — og serveren POSTer til
den på et skema, de selv vælger: sæt en påmindelse et minut ude, og jobbet går
igen hvert minut. Tilmeldingen afviser en privat adresse, og den kontrol er ikke
nok alene: den kører på URL'en som skrevet, og et navn, den godkender, kan svare
127.0.0.1 næste gang det slås op. Det er præcis den sætning, `internal/safedial`
selv står og advarer om. Leveringen går gennem safedial nu, som tjekker den
opslåede adresse ved forbindelsen og hvert viderestillingshop på samme måde.
Kontrollen ved tilmelding bliver, fordi den siger nej, mens nogen kigger på
skærmen.

Og en hærdning: /inbound/mail har fået et loft. Det er den svagere af de to
offentlige skriveveje — beaconet skriver en række for en fremmed, denne skriver
ingenting uden en 256-bit adresse, man skal kende i forvejen — men adressen deles
med mailservere og står på en indstillingsside, og når den først er kendt, var der
intet loft på, hvad der kunne laves med den. Tallet er sat generøst: modtageren er
mailserveren, én adresse der leverer det hele.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:15:28 +02:00

420 lines
14 KiB
Go

package httpapi
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/caldav"
"github.com/kristianwind/verdande/internal/ics"
"github.com/kristianwind/verdande/internal/store"
)
// chi only routes the standard HTTP verbs. WebDAV's are registered here, once,
// before any router is built — PROPFIND and REPORT are the two a task client needs,
// and without this the router panics at construction rather than at request time.
func init() {
for _, method := range []string{"PROPFIND", "REPORT", "PROPPATCH", "MKCALENDAR"} {
chi.RegisterMethod(method)
}
}
// CalDAV lives at /caldav and speaks HTTP Basic with an API token as the password.
//
// Basic rather than the session cookie because that is what CalDAV clients offer:
// Apple Reminders and Thunderbird both ask for a username and password and send
// them on every request. The username is the email address and the password is a
// personal API token — never the account password, so a client's stored credential
// cannot be used to sign in to the app or change the password.
func (s *Server) caldavAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
email, token, ok := r.BasicAuth()
if !ok {
s.caldavChallenge(w)
return
}
user, err := s.db.UserByAPIToken(r.Context(), token)
if err != nil || !strings.EqualFold(user.Email, strings.TrimSpace(email)) {
s.caldavChallenge(w)
return
}
next.ServeHTTP(w, r.WithContext(withUser(r.Context(), user)))
})
}
func (s *Server) caldavChallenge(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="verdande"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
}
// handleCalDAVOptions advertises what this server can do. Clients read the DAV
// header before they will try anything else; without "calendar-access" in it,
// Apple Reminders decides this is a plain WebDAV share and never looks for tasks.
func (s *Server) handleCalDAVOptions(w http.ResponseWriter, r *http.Request) {
w.Header().Set("DAV", "1, 2, 3, calendar-access")
w.Header().Set("Allow", "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT")
w.WriteHeader(http.StatusOK)
}
// handleCalDAVPrincipal answers the discovery request: who am I, and where are my
// calendars.
func (s *Server) handleCalDAVPrincipal(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
req := caldav.ParsePropfind(body)
m := &caldav.Multistatus{}
m.AddPrincipal("/caldav/"+user.ID+"/", "/caldav/"+user.ID+"/", user.Name, req)
writeMultistatus(w, m)
}
// handleCalDAVHome lists the calendars: one per project.
//
// A project per calendar rather than one calendar for everything, because that is
// the unit a person shares and the unit a client lets you turn on and off.
func (s *Server) handleCalDAVHome(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
req := caldav.ParsePropfind(body)
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
if err != nil {
s.internal(w, r, "caldav home", err)
return
}
m := &caldav.Multistatus{}
m.AddCollection("/caldav/"+user.ID+"/", "verdande", "home", req)
// Depth: 0 means the client wants the home itself and not its children.
if r.Header.Get("Depth") != "0" {
for _, p := range projects {
ctag, err := s.projectCTag(r, p.ID)
if err != nil {
continue
}
m.AddCollection("/caldav/"+user.ID+"/"+p.ID+"/", p.Name, ctag, req)
}
}
writeMultistatus(w, m)
}
// handleCalDAVCalendar lists the tasks in one project.
func (s *Server) handleCalDAVCalendar(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
projectID := chi.URLParam(r, "projectID")
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleViewer); err != nil {
http.NotFound(w, r)
return
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
req := caldav.ParsePropfind(body)
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
if err != nil {
http.NotFound(w, r)
return
}
ctag, _ := s.projectCTag(r, projectID)
m := &caldav.Multistatus{}
m.AddCollection("/caldav/"+user.ID+"/"+projectID+"/", project.Name, ctag, req)
if r.Header.Get("Depth") != "0" {
tasks, err := s.caldavTasks(r, user, projectID)
if err != nil {
s.internal(w, r, "caldav tasks", err)
return
}
for _, t := range tasks {
m.AddItem(taskHref(user.ID, projectID, t.ID), taskETag(t), "", req)
}
}
writeMultistatus(w, m)
}
// handleCalDAVReport answers calendar-query and calendar-multiget: the client
// asking for the actual VTODO bodies.
func (s *Server) handleCalDAVReport(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
projectID := chi.URLParam(r, "projectID")
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleViewer); err != nil {
http.NotFound(w, r)
return
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 4<<20))
req := caldav.ParseReport(body)
tasks, err := s.caldavTasks(r, user, projectID)
if err != nil {
s.internal(w, r, "caldav report", err)
return
}
byID := map[string]store.Task{}
for _, t := range tasks {
byID[t.ID] = t
}
m := &caldav.Multistatus{}
if req.Multiget {
// The client named specific resources. Anything it asks for that is not
// here has to be reported as 404 — that is how it learns a task was
// deleted somewhere else.
for _, href := range req.Hrefs {
id := hrefTaskID(href)
task, ok := byID[id]
if !ok {
m.AddNotFound(href)
continue
}
m.AddItem(href, taskETag(task), s.renderVTODO(r, user, task), req.Props)
}
} else {
for _, t := range tasks {
m.AddItem(taskHref(user.ID, projectID, t.ID), taskETag(t), s.renderVTODO(r, user, t), req.Props)
}
}
writeMultistatus(w, m)
}
// handleCalDAVGet returns one task as a calendar object.
func (s *Server) handleCalDAVGet(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
taskID := strings.TrimSuffix(chi.URLParam(r, "taskFile"), ".ics")
task, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8; component=vtodo")
w.Header().Set("ETag", `"`+taskETag(*task)+`"`)
w.Write([]byte(s.renderVTODO(r, user, *task)))
}
// handleCalDAVPut creates or updates a task from a VTODO the client sends.
//
// This is the half that makes CalDAV two-way: ticking something off in Apple
// Reminders arrives here as a PUT with STATUS:COMPLETED.
func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
projectID := chi.URLParam(r, "projectID")
taskID := strings.TrimSuffix(chi.URLParam(r, "taskFile"), ".ics")
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
parsed, err := ics.ParseVTODO(string(body))
if err != nil {
http.Error(w, "could not read the calendar object", http.StatusBadRequest)
return
}
existing, getErr := s.db.GetTask(r.Context(), taskID, user.ID)
if getErr == nil {
// The task decides, not the URL.
//
// The check above is about the project the *client named*, and a client
// names both halves of this address. Asking the project for permission and
// then taking the task id from the same string ties nothing together: a
// viewer on a shared project could name a project of their own — the Inbox,
// which everybody owns and may edit — and put the shared task's id in the
// filename. The project check passed on the Inbox and the write landed on a
// task they may only read.
//
// GetTask does not close it either: it answers "may this person see it",
// and a viewer may. `CanEdit` is the question, and it is the same one
// handleUpdateTask asks — the API door has always asked it.
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil || !role.CanEdit() {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// And it has to live where the address says it does. Without this an editor
// on two projects can reach either through the other's URL — nothing is
// escalated by that on its own, but a URL allowed to lie about where a
// resource lives is how the case above got in.
if existing.ProjectID != projectID {
http.NotFound(w, r)
return
}
// An update. Only the fields a task client can express are touched;
// everything else the task carries stays as it is.
content := parsed.Summary
update := store.TaskUpdate{Content: &content, Description: &parsed.Description}
update.SetDue = true
update.DueDate = parsed.DueDate
update.DueDatetime = parsed.DueDatetime
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, update); err != nil {
s.internal(w, r, "caldav update", err)
return
}
if parsed.Completed && !existing.Completed() {
if _, err := s.db.CompleteTask(r.Context(), taskID, user.ID); err != nil {
s.log.Warn("caldav complete", "err", err)
}
}
if !parsed.Completed && existing.Completed() {
if err := s.db.ReopenTask(r.Context(), taskID); err != nil {
s.log.Warn("caldav reopen", "err", err)
}
}
updated, _ := s.db.GetTask(r.Context(), taskID, user.ID)
if updated != nil {
s.publish(projectID, "task.updated", toTaskJSON(*updated))
w.Header().Set("ETag", `"`+taskETag(*updated)+`"`)
}
w.WriteHeader(http.StatusNoContent)
return
}
// A new task. The client chose the resource name, so that becomes the id —
// which is what lets it address the task it just created without re-syncing.
task := &store.Task{
ID: taskID, ProjectID: projectID, Content: parsed.Summary,
Description: parsed.Description, Priority: parsed.Priority,
DueDate: parsed.DueDate, DueDatetime: parsed.DueDatetime,
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
}
if strings.TrimSpace(task.Content) == "" {
task.Content = "(uden titel)"
}
if err := s.db.CreateTask(r.Context(), task, nil); err != nil {
s.internal(w, r, "caldav create", err)
return
}
s.publish(projectID, "task.created", toTaskJSON(*task))
w.Header().Set("ETag", `"`+taskETag(*task)+`"`)
w.WriteHeader(http.StatusCreated)
}
func (s *Server) handleCalDAVDelete(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
taskID := strings.TrimSuffix(chi.URLParam(r, "taskFile"), ".ics")
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil || !role.CanEdit() {
http.NotFound(w, r)
return
}
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
s.internal(w, r, "caldav delete", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- helpers ---------------------------------------------------------------------------
func (s *Server) caldavTasks(r *http.Request, user *store.User, projectID string) ([]store.Task, error) {
return s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
ProjectIDs: []string{projectID},
// Completed tasks stay in the collection: a client that stops seeing them
// treats them as deleted and removes them from its own store, which loses
// the record of having done them.
IncludeCompleted: true,
Limit: 2000,
})
}
func (s *Server) renderVTODO(r *http.Request, user *store.User, t store.Task) string {
return ics.Render(ics.Calendar{
Name: "verdande",
Domain: feedDomain(s.cfg.BaseURL),
Tasks: []ics.Task{{
ID: t.ID, Content: t.Content, Description: t.Description,
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,
}},
})
}
// projectCTag changes whenever anything in the project does, which is what tells a
// polling client to re-sync. The newest update time plus the row count catches both
// an edit and a deletion — an edit alone would miss a task being removed.
func (s *Server) projectCTag(r *http.Request, projectID string) (string, error) {
var newest, count int64
err := s.db.QueryRowContext(r.Context(),
`SELECT COALESCE(max(updated_at), 0), count(*) FROM tasks WHERE project_id = ?`,
projectID).Scan(&newest, &count)
if err != nil {
return "", err
}
return fmt.Sprintf("%d-%d", newest, count), nil
}
// taskETag identifies one version of one task. A client compares it to decide
// whether to fetch the body again.
func taskETag(t store.Task) string {
return fmt.Sprintf("%d", t.UpdatedAt.Unix())
}
func taskHref(userID, projectID, taskID string) string {
return "/caldav/" + userID + "/" + projectID + "/" + taskID + ".ics"
}
func hrefTaskID(href string) string {
parts := strings.Split(strings.TrimSuffix(href, ".ics"), "/")
if len(parts) == 0 {
return ""
}
return parts[len(parts)-1]
}
func writeMultistatus(w http.ResponseWriter, m *caldav.Multistatus) {
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.Header().Set("DAV", "1, 2, 3, calendar-access")
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(m.XML()))
}
// caldavRoutes mounts the whole thing. Registered outside /api/v1 because CalDAV
// clients do well-known-path discovery against the root.
func (s *Server) caldavRoutes(r chi.Router) {
r.Use(s.caldavAuth)
r.Method(http.MethodOptions, "/*", http.HandlerFunc(s.handleCalDAVOptions))
r.Method("PROPFIND", "/", http.HandlerFunc(s.handleCalDAVPrincipal))
r.Method("PROPFIND", "/{userID}/", http.HandlerFunc(s.handleCalDAVHome))
r.Method("PROPFIND", "/{userID}/{projectID}/", http.HandlerFunc(s.handleCalDAVCalendar))
r.Method("REPORT", "/{userID}/{projectID}/", http.HandlerFunc(s.handleCalDAVReport))
r.Get("/{userID}/{projectID}/{taskFile}", s.handleCalDAVGet)
r.Put("/{userID}/{projectID}/{taskFile}", s.handleCalDAVPut)
r.Delete("/{userID}/{projectID}/{taskFile}", s.handleCalDAVDelete)
}
// wellKnownCalDAV is where clients look first. Apple Reminders asks for
// /.well-known/caldav on the bare hostname before it will try anything else.
func (s *Server) wellKnownCalDAV(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/caldav/", http.StatusMovedPermanently)
}
var _ = time.Now