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