188d9fc02f
fetchEvents and listPublished both bounded their HTTP body reads at 4<<20 with no name for what the number was for. One constant, maxResponseBody, documents the reason (cap every CalDAV response this daemon reads) once instead of twice. No functional change.
223 lines
6.7 KiB
Go
223 lines
6.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/calendar"
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// renderer is the write half of maven's own local calendar (Vikunja #127).
|
|
//
|
|
// It is a RENDER TARGET, not a store. sqlite stays canonical: every tick the
|
|
// renderer reads the pending reminders out of core and publishes each one as a
|
|
// single-event iCal resource in a CalDAV collection maven owns. Nothing is ever
|
|
// read back from that collection, and losing it costs nothing — the next tick
|
|
// rebuilds it.
|
|
//
|
|
// It structurally cannot write to a calendar maven only reads. The URL comes
|
|
// from its own flag, checked at startup against every read URL (see
|
|
// run in main.go), and the only paths it ever addresses carry
|
|
// calendar.ReminderUIDPrefix — so even pointed at the wrong collection it can
|
|
// only touch resources it created.
|
|
type renderer struct {
|
|
core ipc.CoreAPI
|
|
http *http.Client
|
|
url string
|
|
user string
|
|
pass string
|
|
dur time.Duration
|
|
|
|
// published maps reminder id → the body last successfully PUT, so an
|
|
// unchanged reminder costs nothing. Purely an optimisation: a restart
|
|
// re-publishes every reminder once, which is idempotent.
|
|
published map[int64]string
|
|
|
|
// reconciled — whether the collection has been read once since start. It
|
|
// has to be, because published is in-memory: withdrawal used to cover only
|
|
// the reminders THIS process published, so a reminder that fired while the
|
|
// daemon was down kept its event in the calendar forever, and nothing ever
|
|
// revisited it.
|
|
reconciled bool
|
|
}
|
|
|
|
func newRenderer(core ipc.CoreAPI, hc *http.Client, url, user, pass string, dur time.Duration) *renderer {
|
|
return &renderer{
|
|
core: core,
|
|
http: hc,
|
|
url: strings.TrimRight(url, "/"),
|
|
user: user,
|
|
pass: pass,
|
|
dur: dur,
|
|
published: make(map[int64]string),
|
|
}
|
|
}
|
|
|
|
// renderOnce publishes every pending reminder and withdraws the ones that are
|
|
// no longer pending. Errors are logged and skipped: a calendar maven cannot
|
|
// reach must never break the reminder itself, which lives in sqlite.
|
|
func (r *renderer) renderOnce(ctx context.Context) {
|
|
reminders, err := r.core.ListReminders(ctx, renderMaxReminders)
|
|
if err != nil {
|
|
log.Printf("mavcaldav: list reminders: %v", err)
|
|
return
|
|
}
|
|
|
|
live := make(map[int64]bool, len(reminders))
|
|
for _, rem := range reminders {
|
|
if rem.Status != store.ReminderPending {
|
|
continue
|
|
}
|
|
live[rem.ID] = true
|
|
e := calendar.ReminderEvent(rem.ID, fireTime(rem), rem.Payload, r.dur)
|
|
body := calendar.RenderICal([]calendar.Event{e})
|
|
if r.published[rem.ID] == body {
|
|
continue
|
|
}
|
|
if err := r.put(ctx, calendar.ReminderPath(rem.ID), body); err != nil {
|
|
log.Printf("mavcaldav: render reminder %d: %v", rem.ID, err)
|
|
continue
|
|
}
|
|
r.published[rem.ID] = body
|
|
log.Printf("mavcaldav: rendered reminder %d (%s)", rem.ID, e.Summary)
|
|
}
|
|
|
|
stale := make(map[int64]bool)
|
|
for id := range r.published {
|
|
if !live[id] {
|
|
stale[id] = true
|
|
}
|
|
}
|
|
if !r.reconciled {
|
|
remote, err := r.listPublished(ctx)
|
|
if err != nil {
|
|
// Try again next tick. A collection maven cannot read is not a
|
|
// reason to stop publishing to it.
|
|
log.Printf("mavcaldav: reconcile: %v", err)
|
|
} else {
|
|
r.reconciled = true
|
|
for _, id := range remote {
|
|
if !live[id] {
|
|
stale[id] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for id := range stale {
|
|
if err := r.delete(ctx, calendar.ReminderPath(id)); err != nil {
|
|
log.Printf("mavcaldav: withdraw reminder %d: %v", id, err)
|
|
continue
|
|
}
|
|
delete(r.published, id)
|
|
log.Printf("mavcaldav: withdrew reminder %d", id)
|
|
}
|
|
}
|
|
|
|
// listPublished PROPFINDs the collection and returns the reminder ids maven has
|
|
// events for in it. Only resources carrying calendar.ReminderUIDPrefix are
|
|
// reported, so a reconciliation pass can never propose deleting a file maven
|
|
// did not create — the same bound every other path in this file has.
|
|
func (r *renderer) listPublished(ctx context.Context) ([]int64, error) {
|
|
const body = `<?xml version="1.0" encoding="utf-8"?>` +
|
|
`<D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/></D:prop></D:propfind>`
|
|
req, err := http.NewRequestWithContext(ctx, "PROPFIND", r.url+"/", strings.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.SetBasicAuth(r.user, r.pass)
|
|
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
|
req.Header.Set("Depth", "1")
|
|
|
|
resp, err := r.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusMultiStatus && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
|
|
return nil, fmt.Errorf("PROPFIND %s: %s", r.url, resp.Status)
|
|
}
|
|
|
|
var ms struct {
|
|
Responses []struct {
|
|
Href string `xml:"href"`
|
|
} `xml:"response"`
|
|
}
|
|
if err := xml.Unmarshal(raw, &ms); err != nil {
|
|
return nil, fmt.Errorf("PROPFIND %s: %w", r.url, err)
|
|
}
|
|
var ids []int64
|
|
for _, resp := range ms.Responses {
|
|
href, err := url.PathUnescape(strings.TrimSpace(resp.Href))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if id, ok := calendar.ReminderIDFromPath(href); ok {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// renderMaxReminders bounds the read. Reminders past this count are older than
|
|
// anything a calendar view is useful for.
|
|
const renderMaxReminders = 200
|
|
|
|
// fireTime prefers NextFireTs — for a recurring reminder that is the occurrence
|
|
// worth showing; FireTs is the original statement.
|
|
func fireTime(rem ipc.Reminder) time.Time {
|
|
if !rem.NextFireTs.IsZero() {
|
|
return rem.NextFireTs
|
|
}
|
|
return rem.FireTs
|
|
}
|
|
|
|
func (r *renderer) put(ctx context.Context, name, body string) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, r.url+"/"+name, strings.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.SetBasicAuth(r.user, r.pass)
|
|
req.Header.Set("Content-Type", "text/calendar; charset=utf-8")
|
|
return r.do(req, name)
|
|
}
|
|
|
|
func (r *renderer) delete(ctx context.Context, name string) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, r.url+"/"+name, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.SetBasicAuth(r.user, r.pass)
|
|
return r.do(req, name)
|
|
}
|
|
|
|
// do runs the request and treats any 2xx, plus 404 on a DELETE, as success —
|
|
// a resource that is already gone is the state the caller wanted.
|
|
func (r *renderer) do(req *http.Request, name string) error {
|
|
resp, err := r.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
|
|
switch {
|
|
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
|
return nil
|
|
case req.Method == http.MethodDelete && resp.StatusCode == http.StatusNotFound:
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%s %s: %s", req.Method, name, resp.Status)
|
|
}
|