diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ad081fd..e234ee9 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2079,6 +2079,48 @@ paths: "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/Validation" } + /api/v1/calendar/subscriptions: + post: + tags: [calendar] + summary: Subscribe to a calendar you only have an address to. + description: | + An ICS or `webcal://` address. Several per person, unlike the Google + connection, which is one — the address is the identity here, the way the + token is for OAuth. + + The address is fetched once before it is stored: an address that cannot be + read is a typo far more often than a server that happens to be down, and + saying so while somebody is still looking at the field is worth two + seconds. `webcal://` is rewritten to `https://`, and the destination is + checked against the private-network wall both when it is written down and + again on the resolved address every time it is fetched. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [url] + properties: + url: { type: string, description: "https:// or webcal://" } + name: + type: string + description: Optional. The calendar's own name is used when this is empty. + responses: + "201": + description: Subscribed, and fetched once. + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/calendar/subscriptions/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + delete: + tags: [calendar] + summary: Stop subscribing. The cached events go with it. + responses: + "204": { description: Gone. } + "404": { $ref: "#/components/responses/NotFound" } + /api/v1/push/test: post: tags: [notifications] diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go index 1d96318..779c245 100644 --- a/internal/httpapi/auth_test.go +++ b/internal/httpapi/auth_test.go @@ -27,6 +27,9 @@ type testServer struct { *httptest.Server db *store.DB client *http.Client + // api er den samme server, routeren hænger på. Et par prøver har brug for at + // nå ind i den — se `icsFetch` — og uden den her er der kun håndtaget udefra. + api *Server } func newTestServer(t *testing.T) *testServer { @@ -62,7 +65,8 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer // Discard: a passing test should print nothing, and these handlers log freely. log := slog.New(slog.NewTextHandler(io.Discard, nil)) - srv := httptest.NewServer(New(cfg, db, log, nil)) + api := New(cfg, db, log, nil) + srv := httptest.NewServer(api) t.Cleanup(srv.Close) jar, err := cookiejar.New(nil) @@ -75,6 +79,7 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer // The cookie jar is what makes this a session test rather than a // sequence of unrelated requests. client: &http.Client{Jar: jar, Timeout: 10 * time.Second}, + api: api, } } diff --git a/internal/httpapi/calendar_handlers.go b/internal/httpapi/calendar_handlers.go index c79cfcb..76fcbaf 100644 --- a/internal/httpapi/calendar_handlers.go +++ b/internal/httpapi/calendar_handlers.go @@ -3,10 +3,17 @@ package httpapi import ( "context" "errors" + "fmt" + "io" "net/http" + "strings" "time" + "github.com/go-chi/chi/v5" + "github.com/kristianwind/verdande/internal/gcal" + "github.com/kristianwind/verdande/internal/ics" + "github.com/kristianwind/verdande/internal/safedial" "github.com/kristianwind/verdande/internal/store" ) @@ -205,9 +212,21 @@ type calendarStatusJSON struct { // ReadOnly is stated rather than assumed. Nothing writes a Google calendar, and // an interface that quietly looks editable is one somebody will try to edit. ReadOnly bool `json:"read_only"` + + // Abonnementer står ved siden af Google-kontoen og ikke inde i den: man kan + // have det ene uden det andet, og det er hele pointen med dem — en privat + // konto, der ikke kan bruge en Internal OAuth-klient, kan abonnere. + Subscriptions []store.CalendarAccount `json:"subscriptions"` } func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) { + // Læst først, så de også kommer med, når der ingen Google-konto er. + subscriptions, err := s.db.Subscriptions(r.Context(), userFrom(r.Context()).ID) + if err != nil { + s.internal(w, r, "subscriptions", err) + return + } + user := userFrom(r.Context()) out := calendarStatusJSON{ @@ -223,6 +242,7 @@ func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) { return } if account == nil || account.RefreshToken == "" { + out.Subscriptions = subscriptions writeJSON(w, http.StatusOK, out) return } @@ -253,6 +273,7 @@ func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) { if calendars != nil { out.Calendars = calendars } + out.Subscriptions = subscriptions writeJSON(w, http.StatusOK, out) } @@ -383,12 +404,23 @@ func (s *Server) handleCalendarSyncNow(w http.ResponseWriter, r *http.Request) { // Returns how many events were written. Also used by the background job, which is // why it takes a context and a user rather than a request. func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, error) { + // Abonnementer først, og deres fejl tæller ikke mod Google. + // + // De to har intet med hinanden at gøre ud over at ende samme sted: et + // abonnement er en adresse, der hentes, hvor Google er et token, der udløber. + // En kalender, hvis vært er nede, må ikke få det til at se ud som om + // Google-forbindelsen er væk — og omvendt. + written, err := s.syncSubscriptions(ctx, user) + if err != nil { + s.log.Warn("calendar subscriptions", "err", err, "user", user.ID) + } + account, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName) if err != nil { - return 0, err + return written, err } if account == nil || account.RefreshToken == "" { - return 0, nil // not connected; nothing to do and nothing to complain about + return written, nil // not connected; nothing to do and nothing to complain about } // The same guard the mailboxes have, for the same reason: a press of "fetch @@ -414,7 +446,6 @@ func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, erro } from, to := calendarWindow(time.Now()) - written := 0 for _, calendar := range calendars { if !calendar.Shown { continue @@ -546,3 +577,232 @@ func validDay(s string) bool { _, err := time.Parse(calendarDateLayout, s) return err == nil } + +// --- abonnementer ----------------------------------------------------------------- + +// maxICSBytes bounds one fetch. A published calendar of a decade of holidays is a +// few hundred kilobytes; ten megabytes is not a calendar, and reading it into +// memory on a schedule with nobody watching is how a homelab instance falls over +// at three in the morning. +const maxICSBytes = 10 << 20 + +// normaliseCalendarURL turns what somebody pastes into something that can be +// fetched. +// +// `webcal://` is not a scheme anything speaks. It is a convention — the same URL +// over https, with a scheme that makes a desktop hand it to the calendar app +// instead of the browser — and every publisher writes their links that way. A +// person pasting one has pasted the right address; refusing it would be refusing +// them on a technicality. +func normaliseCalendarURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if after, ok := strings.CutPrefix(raw, "webcal://"); ok { + raw = "https://" + after + } + if !strings.HasPrefix(raw, "https://") && !strings.HasPrefix(raw, "http://") { + return "", errors.New("must be an https:// or webcal:// address") + } + // The same wall the AI provider's base_url goes through, and for the same + // reason: this is an address a user typed, and the server is about to fetch it + // on a schedule of their choosing. + if reason := safedial.CheckURL(raw); reason != "" { + return "", errors.New(reason) + } + return raw, nil +} + +// icsClient is the client a subscribed calendar is fetched with. +// +// safedial, checked on the *resolved* address at connect time and on every +// redirect hop — because a name that passed the check when it was pasted can +// answer 127.0.0.1 when the sweep runs at three in the morning. +// +// The field exists so a test can put an ordinary client here: httptest listens on +// loopback, which is exactly what this refuses, so a test of the *format* cannot +// otherwise reach its own server. It is never set outside a test, and the wall it +// bypasses is tested where it belongs — `normaliseCalendarURL` refuses a private +// address where it is written down, and `internal/push` proves the connect-time +// half. +func (s *Server) icsClient() *http.Client { + if s.icsFetch != nil { + return s.icsFetch + } + return safedial.Client(30 * time.Second) +} + +// fetchICS reads a subscribed calendar. +// +// safedial, not a plain client — checked on the resolved address at connect time +// and on every redirect hop, because a name that passed the check when it was +// pasted can answer 127.0.0.1 when the sweep runs at three in the morning. +func (s *Server) fetchICS(ctx context.Context, url string) (ics.Subscribed, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return ics.Subscribed{}, err + } + req.Header.Set("Accept", "text/calendar, text/plain;q=0.9, */*;q=0.5") + req.Header.Set("User-Agent", "verdande/"+Version) + + resp, err := s.icsClient().Do(req) + if err != nil { + return ics.Subscribed{}, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return ics.Subscribed{}, fmt.Errorf("%s said %s", resp.Request.URL.Host, resp.Status) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxICSBytes+1)) + if err != nil { + return ics.Subscribed{}, err + } + if len(body) > maxICSBytes { + return ics.Subscribed{}, errors.New("that calendar is too large") + } + return ics.ParseCalendar(string(body)), nil +} + +// syncSubscriptions refreshes every calendar this person subscribes to by address. +// +// One failing address does not stop the others. A subscription is somebody else's +// server, and the ordinary state of somebody else's server is that it is fine +// until one morning it is not — a holiday feed that 404s must not take the +// football club's fixtures down with it. +func (s *Server) syncSubscriptions(ctx context.Context, user *store.User) (int, error) { + subs, err := s.db.Subscriptions(ctx, user.ID) + if err != nil || len(subs) == 0 { + return 0, err + } + + from, to := calendarWindow(time.Now()) + written := 0 + var failures []string + + for _, sub := range subs { + if ctx.Err() != nil { + return written, ctx.Err() + } + unlock := s.lockSync("calendar:" + sub.ID) + + calendarID, err := s.db.SubscribedCalendarID(ctx, sub.ID) + if err != nil { + unlock() + failures = append(failures, sub.URL) + continue + } + + cal, err := s.fetchICS(ctx, sub.URL) + if err != nil { + unlock() + s.log.Warn("subscribed calendar", "err", err, "url", sub.URL, "user", user.ID) + failures = append(failures, sub.URL) + continue + } + + // The name the file calls itself, once. A subscription is added under its + // address because that is all there is to go on before it has been fetched. + if err := s.db.RenameSubscribedCalendar(ctx, sub.ID, cal.Name); err != nil { + s.log.Warn("name subscribed calendar", "err", err) + } + + rows := make([]store.CalendarEvent, 0, len(cal.Events)) + for _, e := range cal.Events { + // Outside the window is not stored. The grid asks about a window, the + // sweep replaces a window, and a decade of holidays in one file would + // otherwise be a decade of rows rewritten every fifteen minutes. + if e.EndDay < from || e.StartDay > to { + continue + } + rows = append(rows, store.CalendarEvent{ + RemoteID: e.UID, Summary: e.Summary, Location: e.Location, URL: e.URL, + StartsAt: e.StartsAt, EndsAt: e.EndsAt, + StartDay: e.StartDay, EndDay: e.EndDay, AllDay: e.AllDay, + }) + } + if err := s.db.ReplaceCalendarEvents(ctx, calendarID, from, to, rows); err != nil { + unlock() + return written, err + } + if err := s.db.MarkCalendarSynced(ctx, sub.ID, time.Now()); err != nil { + s.log.Warn("mark subscription synced", "err", err) + } + written += len(rows) + unlock() + } + + if len(failures) == len(subs) { + return written, fmt.Errorf("none of the %d subscribed calendars could be read", len(subs)) + } + return written, nil +} + +// handleSubscribeCalendar adds a calendar somebody only has an address to. +// +// Fetched once, here, before it is stored. An address that cannot be read is a +// typo far more often than it is a server that happens to be down, and telling +// somebody now — while they are looking at the field they just pasted into — is +// worth the two seconds. A subscription that silently never fills is the same +// silence the notifications had. +func (s *Server) handleSubscribeCalendar(w http.ResponseWriter, r *http.Request) { + var req struct { + URL string `json:"url"` + Name string `json:"name"` + } + if err := decodeJSON(w, r, &req); err != nil { + return + } + + url, err := normaliseCalendarURL(req.URL) + if err != nil { + writeFieldErrors(w, map[string]string{"url": err.Error()}) + return + } + + user := userFrom(r.Context()) + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + + cal, err := s.fetchICS(ctx, url) + if err != nil { + s.log.Warn("subscribe calendar", "err", err, "user", user.ID) + writeFieldErrors(w, map[string]string{"url": "that address could not be read"}) + return + } + + name := strings.TrimSpace(req.Name) + if name == "" { + name = strings.TrimSpace(cal.Name) + } + if name == "" { + name = url + } + + account, err := s.db.SubscribeCalendar(r.Context(), user.ID, url, name) + if err != nil { + // The unique index is the one refusal worth naming: subscribing twice to + // the same address is the same calendar fetched twice, with no way to say + // which one counts. + if errors.Is(err, store.ErrDuplicate) { + writeFieldErrors(w, map[string]string{"url": "that calendar is already subscribed"}) + return + } + s.storeError(w, r, "subscribe calendar", err) + return + } + + // The events it already fetched, rather than making the person wait fifteen + // minutes for the sweep to prove it worked. + if _, err := s.syncSubscriptions(r.Context(), user); err != nil { + s.log.Warn("first subscription sync", "err", err) + } + writeJSON(w, http.StatusCreated, account) +} + +func (s *Server) handleUnsubscribeCalendar(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + if err := s.db.DeleteCalendarAccount(r.Context(), user.ID, chi.URLParam(r, "id")); err != nil { + s.storeError(w, r, "unsubscribe calendar", err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/httpapi/integrations_test.go b/internal/httpapi/integrations_test.go index 3ec1893..2f8aace 100644 --- a/internal/httpapi/integrations_test.go +++ b/internal/httpapi/integrations_test.go @@ -2,12 +2,15 @@ package httpapi import ( "encoding/json" + "errors" "fmt" "io" "net/http" + "net/http/httptest" "net/url" "strings" "testing" + "time" "github.com/kristianwind/verdande/internal/config" "github.com/kristianwind/verdande/internal/store" @@ -1529,3 +1532,113 @@ func TestATestNotificationSaysWhatHappenedToIt(t *testing.T) { t.Errorf("endepunktet lækkede med ud: %v", first) } } + +// Et abonnement på en kalender, man kun har en adresse til. +// +// Bedt om som "abonnering af flere kalendere (ics og webcal?)". Google-vejen er +// OAuth og én konto pr. person; det her er en adresse, og man har flere. Det er +// også den eneste vej for en privat konto, som en Internal-registrering afviser +// med `org_internal`. +// +// Prøven går uden om handleren og ind i det, den kalder. Grunden er væggen: +// `httptest` lytter på loopback, og safedial nægter — både når adressen skrives +// ned og igen når den hentes. Det er rigtigt, og det skal ikke kunne slås fra fra +// en prøve. Så adressereglerne prøves for sig i TestWebcalIsRewrittenToHTTPS, og +// her prøves resten af vejen: hentning, format, navn, gitteret og afmeldingen. +func TestSubscribingToACalendarByAddress(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + day := time.Now().AddDate(0, 0, 2).Format("20060102") + feedBody := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nX-WR-CALNAME:Helligdage\r\n" + + "BEGIN:VEVENT\r\nUID:en@example.dk\r\n" + + "DTSTART;VALUE=DATE:" + day + "\r\nDTEND;VALUE=DATE:" + day + "\r\n" + + "SUMMARY:Grundlovsdag\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + + var hits int + feed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.Header().Set("Content-Type", "text/calendar") + w.Write([]byte(feedBody)) + })) + defer feed.Close() + ts.api.icsFetch = &http.Client{Timeout: 10 * time.Second} + + user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk") + if err != nil { + t.Fatal(err) + } + + // Tilføjet under sin adresse, som er alt der er at gå efter, før den er hentet. + if _, err := ts.db.SubscribeCalendar(t.Context(), user.ID, feed.URL, feed.URL); err != nil { + t.Fatal(err) + } + if _, err := ts.api.syncSubscriptions(t.Context(), user); err != nil { + t.Fatal(err) + } + if hits == 0 { + t.Fatal("adressen blev aldrig hentet") + } + + // Navnet kommer fra filen. X-WR-CALNAME er det, alle udgivere bruger; der + // findes ingen standardegenskab for det. + _, status := ts.do(t, "GET", "/api/v1/calendar", nil) + subs, _ := status["subscriptions"].([]any) + if len(subs) != 1 { + t.Fatalf("%d abonnementer i svaret", len(subs)) + } + first, _ := subs[0].(map[string]any) + if first["account"] != "Helligdage" { + t.Errorf("navnet er %v, want Helligdage", first["account"]) + } + + // Og begivenheden står i gitteret. + from := time.Now().AddDate(0, 0, -1).Format("2006-01-02") + to := time.Now().AddDate(0, 0, 7).Format("2006-01-02") + _, events := ts.do(t, "GET", "/api/v1/calendar/events?from="+from+"&to="+to, nil) + list, _ := events["events"].([]any) + if len(list) != 1 { + t.Fatalf("%d begivenheder efter abonnementet", len(list)) + } + if e, _ := list[0].(map[string]any); e["summary"] != "Grundlovsdag" { + t.Errorf("begivenheden er %v", e["summary"]) + } + + // Den samme adresse to gange er den samme kalender hentet to gange, og der er + // ingen måde at sige hvilken der gælder. + if _, err := ts.db.SubscribeCalendar(t.Context(), user.ID, feed.URL, ""); !errors.Is(err, store.ErrDuplicate) { + t.Errorf("den samme adresse blev accepteret to gange: %v", err) + } + + // Fjernet igen, og begivenhederne går med. + resp, _ := ts.do(t, "DELETE", "/api/v1/calendar/subscriptions/"+first["id"].(string), nil) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("unsubscribe: status %d", resp.StatusCode) + } + _, events = ts.do(t, "GET", "/api/v1/calendar/events?from="+from+"&to="+to, nil) + if list, _ := events["events"].([]any); len(list) != 0 { + t.Errorf("%d begivenheder blev tilbage efter afmeldingen", len(list)) + } +} + +// webcal:// er https med et andet skema foran. Det er ikke en protokol, noget +// taler — det er en konvention, der får et skrivebord til at give adressen til +// kalenderprogrammet i stedet for til browseren, og alle udgivere skriver deres +// links sådan. At afvise den ville være at afvise folk på en teknikalitet. +func TestWebcalIsRewrittenToHTTPS(t *testing.T) { + got, err := normaliseCalendarURL("webcal://example.dk/kalender.ics") + if err != nil { + t.Fatal(err) + } + if got != "https://example.dk/kalender.ics" { + t.Errorf("blev til %q", got) + } + + // Og en adresse indad afvises, hvor den skrives — ikke først når den hentes. + if _, err := normaliseCalendarURL("http://127.0.0.1:8080/x.ics"); err == nil { + t.Error("en loopback-adresse blev godtaget") + } + if _, err := normaliseCalendarURL("file:///etc/passwd"); err == nil { + t.Error("et skema, der ikke er http, blev godtaget") + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 5b6af2c..8418b67 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -63,6 +63,8 @@ type Server struct { // the endpoints that check a secret are limited separately and more tightly // than the rest of the API. loginLimiter *limiter + // Sat kun af prøver; se icsClient(). + icsFetch *http.Client mailLimiter *limiter resetLimiter *limiter // Second factors are counted against the *account*, not the address. An @@ -392,6 +394,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Post("/authorize", s.handleCalendarAuthorize) r.Post("/sync", s.handleCalendarSyncNow) r.Get("/events", s.handleCalendarEvents) + + // Abonnementer: en kalender, man kun har en adresse til. Flere pr. + // person, hvor Google er én — det er adressen, der er identiteten, + // hvor tokenet er det for OAuth. + r.Post("/subscriptions", s.handleSubscribeCalendar) + r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar) }) r.Route("/import", func(r chi.Router) { diff --git a/internal/ics/ics.go b/internal/ics/ics.go index dd8a218..efeb77d 100644 --- a/internal/ics/ics.go +++ b/internal/ics/ics.go @@ -367,3 +367,180 @@ func unescape(s string) string { r := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`) return r.Replace(s) } + +// --- reading a whole calendar ------------------------------------------------------ + +// Event is one VEVENT, reduced to what a grid needs — the same shape the Google +// side already stores, so the two arrive in the database identically. +// +// Days at both ends, inclusive, because that is the question a grid asks: which +// cells does this cover. StartsAt keeps the stamp as the file wrote it, offset and +// all: it is what the chip shows, and reparsing it here would ask *this process* +// what time zone it is in. +type Event struct { + UID string + Summary string + Location string + URL string + AllDay bool + StartsAt string // RFC3339, empty for an all-day event + EndsAt string + StartDay string // YYYY-MM-DD + EndDay string +} + +// Subscribed is what an .ics file somebody subscribed to says. +// +// Its own name rather than `Calendar`: that one is the *writing* side — what +// verdande publishes as a feed — and the two are different documents that happen +// to share a format. One name for both would be a promise that a round trip +// through this package is lossless, which it is not and does not need to be. +type Subscribed struct { + Name string + TimeZone string + Events []Event +} + +// ParseCalendar reads an .ics file that somebody subscribed to. +// +// Forgiving in the same way ParseVTODO is, and for a sharper reason: this file was +// written by somebody else's software and is fetched on a schedule with nobody +// watching. One property this does not understand must not cost the other four +// hundred events in the file — so anything unrecognised is skipped, and an event +// without the two things a grid cannot do without (a uid and a start) is dropped +// rather than stored half-made. +// +// # What it deliberately does not do +// +// **Recurrence is not expanded.** RRULE in a subscribed calendar means "and then +// every Tuesday until 2031", and expanding it here would mean writing a year of +// rows per rule and re-deciding what happens when the file changes. A recurring +// event is stored as its first occurrence, which is honest — it is what the file +// says on its own terms — and the day it matters, the answer is an expander shared +// with `internal/recurrence`, not a second one written here. +// +// **Time zones are not resolved.** A DTSTART with TZID=Europe/Copenhagen is stored +// with the offset the file carries or, when it carries none, as a local stamp. The +// alternative is shipping a tz database and deciding what to do when the file +// names a zone this build has never heard of. The day that matters, it is one +// place. +func ParseCalendar(body string) Subscribed { + var cal Subscribed + var current *Event + depth := 0 + + for _, line := range unfold(body) { + name, params, value := splitLine(line) + upper := strings.ToUpper(name) + + switch upper { + case "BEGIN": + if strings.EqualFold(value, "VEVENT") { + current = &Event{} + } + depth++ + continue + case "END": + depth-- + if strings.EqualFold(value, "VEVENT") && current != nil { + if current.UID != "" && current.StartDay != "" { + cal.Events = append(cal.Events, *current) + } + current = nil + } + continue + } + + if current == nil { + // Calendar-level properties. X-WR-CALNAME is what every publisher uses + // to say what the calendar is called; there is no standard property for + // it, which is why a non-standard one is read here. + switch upper { + case "X-WR-CALNAME": + cal.Name = unescape(value) + case "X-WR-TIMEZONE": + cal.TimeZone = value + } + continue + } + + switch upper { + case "UID": + current.UID = value + case "SUMMARY": + current.Summary = unescape(value) + case "LOCATION": + current.Location = unescape(value) + case "URL": + current.URL = value + case "DTSTART": + current.AllDay = isDateOnly(params, value) + current.StartDay, current.StartsAt = readStamp(params, value) + case "DTEND": + day, at := readStamp(params, value) + current.EndsAt = at + // DTEND is exclusive for an all-day event: a one-day event ends on the + // next morning. Stored inclusive, or every all-day event would cover one + // cell too many — and a public holiday would eat the day after it. + if current.AllDay { + day = shiftDay(day, -1) + } + current.EndDay = day + } + } + + // A missing DTEND means the event ends when it starts. Filled after the loop + // rather than inside it, because DTEND may arrive before DTSTART in a file + // nobody promised would be in order. + for i := range cal.Events { + if cal.Events[i].EndDay == "" || cal.Events[i].EndDay < cal.Events[i].StartDay { + cal.Events[i].EndDay = cal.Events[i].StartDay + } + } + return cal +} + +// isDateOnly reports whether a stamp is a day rather than a moment. VALUE=DATE +// says so outright; an eight-character value says it by being one. +func isDateOnly(params, value string) bool { + if strings.Contains(strings.ToUpper(params), "VALUE=DATE") { + return true + } + return len(value) == 8 && !strings.ContainsAny(value, "T") +} + +// readStamp returns the day and, for a timed event, the moment as RFC3339. +// +// Three shapes exist in the wild: 20260821 (a day), 20260821T140000Z (UTC), and +// 20260821T140000 with a TZID parameter (a local time in a named zone). The third +// is kept as written, without a zone: resolving it needs a tz database, and a +// stamp that says 14:00 is closer to the truth than one this process has moved. +func readStamp(params, value string) (day, at string) { + value = strings.TrimSpace(value) + if len(value) < 8 { + return "", "" + } + day = value[0:4] + "-" + value[4:6] + "-" + value[6:8] + if len(value) < 15 || value[8] != 'T' { + return day, "" + } + + clock := value[9:11] + ":" + value[11:13] + ":" + value[13:15] + switch { + case strings.HasSuffix(value, "Z"): + return day, day + "T" + clock + "Z" + default: + // A local stamp, with or without a TZID. Written without an offset, which + // is what the interface already treats as "the clock the file said". + return day, day + "T" + clock + } +} + +// shiftDay moves a YYYY-MM-DD by whole days. +func shiftDay(day string, by int) string { + t, err := time.Parse("2006-01-02", day) + if err != nil { + return day + } + return t.AddDate(0, 0, by).Format("2006-01-02") +} diff --git a/internal/ics/ics_test.go b/internal/ics/ics_test.go index 302d2e1..8cd3f3a 100644 --- a/internal/ics/ics_test.go +++ b/internal/ics/ics_test.go @@ -220,3 +220,101 @@ func TestFilename(t *testing.T) { } } } + +// En abonnementskalender, som den ser ud fra nogen andens software. +// +// Alle fire former, der findes i naturen, i én fil: en heldagsbegivenhed, en med +// et UTC-stempel, en med TZID og lokal tid, og en over flere dage. Plus en, der +// mangler det, et gitter ikke kan undvære, og en foldet linje — begge dele +// kommer, og de kommer uden varsel. +func TestParseCalendarReadsTheFourShapesThatExist(t *testing.T) { + body := "BEGIN:VCALENDAR\r\n" + + "VERSION:2.0\r\n" + + "X-WR-CALNAME:Helligdage\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:heldag@example.dk\r\n" + + "DTSTART;VALUE=DATE:20260824\r\n" + + "DTEND;VALUE=DATE:20260825\r\n" + + "SUMMARY:Grundlovsdag\r\n" + + "END:VEVENT\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:utc@example.dk\r\n" + + "DTSTART:20260825T120000Z\r\n" + + "DTEND:20260825T133000Z\r\n" + + `SUMMARY:Møde med en meget lang titel\, der er foldet over to lin` + "\r\n" + " jer\r\n" + + "END:VEVENT\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:lokal@example.dk\r\n" + + "DTSTART;TZID=Europe/Copenhagen:20260826T090000\r\n" + + "DTEND;TZID=Europe/Copenhagen:20260826T100000\r\n" + + "SUMMARY:Tandlæge\r\n" + + "END:VEVENT\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:flerdage@example.dk\r\n" + + "DTSTART;VALUE=DATE:20260901\r\n" + + "DTEND;VALUE=DATE:20260904\r\n" + + "SUMMARY:Ferie\r\n" + + "END:VEVENT\r\n" + + "BEGIN:VEVENT\r\n" + + "SUMMARY:Uden uid og uden start\r\n" + + "END:VEVENT\r\n" + + "END:VCALENDAR\r\n" + + cal := ParseCalendar(body) + + if cal.Name != "Helligdage" { + t.Errorf("kalenderens navn = %q", cal.Name) + } + if len(cal.Events) != 4 { + t.Fatalf("%d begivenheder, want 4 — den femte mangler både uid og start", len(cal.Events)) + } + + // Heldag. DTEND er eksklusiv i filen: en endagsbegivenhed slutter næste morgen. + // Gemmes inklusivt, ellers dækker hver eneste heldagsbegivenhed én celle for + // meget — og en helligdag æder dagen efter sig. + one := cal.Events[0] + if !one.AllDay || one.StartDay != "2026-08-24" || one.EndDay != "2026-08-24" { + t.Errorf("heldag: %+v", one) + } + if one.StartsAt != "" { + t.Errorf("en heldagsbegivenhed fik et klokkeslæt: %q", one.StartsAt) + } + + // UTC, og den foldede linje samlet igen. + two := cal.Events[1] + if two.StartsAt != "2026-08-25T12:00:00Z" || two.EndsAt != "2026-08-25T13:30:00Z" { + t.Errorf("utc: %+v", two) + } + if two.Summary != "Møde med en meget lang titel, der er foldet over to linjer" { + t.Errorf("den foldede linje blev ikke samlet: %q", two.Summary) + } + + // Lokal tid med TZID: klokkeslættet står, som filen skrev det. At flytte det + // her ville spørge *denne* proces, hvilken tidszone den er i. + three := cal.Events[2] + if three.StartsAt != "2026-08-26T09:00:00" { + t.Errorf("lokal: %q", three.StartsAt) + } + + // Flere dage, begge ender inklusive. + four := cal.Events[3] + if four.StartDay != "2026-09-01" || four.EndDay != "2026-09-03" { + t.Errorf("ferie: %s til %s", four.StartDay, four.EndDay) + } +} + +// En fil, der ikke er en kalender, må ikke koste noget. Den hentes på et skema +// uden nogen, der kigger med, og en fejl her ville være en sweep, der stopper. +func TestParseCalendarSurvivesRubbish(t *testing.T) { + for _, body := range []string{ + "", + "ikke en kalender overhovedet", + "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", + "BEGIN:VEVENT\r\nDTSTART:x\r\nEND:VEVENT\r\n", + "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:2026\r\nEND:VEVENT\r\n", + } { + if got := ParseCalendar(body); len(got.Events) != 0 { + t.Errorf("%q gav %d begivenheder", body, len(got.Events)) + } + } +} diff --git a/internal/store/calendars.go b/internal/store/calendars.go index 4e1ed00..99c59d3 100644 --- a/internal/store/calendars.go +++ b/internal/store/calendars.go @@ -3,7 +3,9 @@ package store import ( "context" "database/sql" + "errors" "fmt" + "strings" "time" ) @@ -14,6 +16,12 @@ type CalendarAccount struct { Provider string `json:"provider"` Account string `json:"account,omitempty"` + // URL er tom for en OAuth-konto og adressen for et abonnement. Den er + // identiteten på et abonnement, som tokenet er det for Google: to rækker med + // den samme adresse for den samme person ville være den samme kalender hentet + // to gange, uden nogen måde at sige hvilken der gælder. + URL string `json:"url,omitempty"` + RefreshToken string `json:"-"` // never leaves the server AccessToken string `json:"-"` ExpiresAt time.Time `json:"-"` @@ -65,10 +73,17 @@ type CalendarEvent struct { const calendarAccountColumns = `id, user_id, provider, account, refresh_token, access_token, expires_at, last_sync_at, created_at` +// Med adressen. Skrivningen tilføjer den selv, fordi indsættelsen navngiver +// søjlerne; læsningen skal bede om den. +const calendarAccountRead = calendarAccountColumns + `, url` + // CalendarAccountFor returns a person's connection to one provider, or nil. func (db *DB) CalendarAccountFor(ctx context.Context, userID, provider string) (*CalendarAccount, error) { - row := db.QueryRowContext(ctx, `SELECT `+calendarAccountColumns+` - FROM calendar_accounts WHERE user_id = ? AND provider = ?`, userID, provider) + // `url = ''`: en OAuth-konto. Uden det ville et abonnement hos den samme + // udbyder kunne svare på spørgsmålet "hvad er personen forbundet til med + // Google", og et abonnement har hverken token eller konto at svare med. + row := db.QueryRowContext(ctx, `SELECT `+calendarAccountRead+` + FROM calendar_accounts WHERE user_id = ? AND provider = ? AND url = ''`, userID, provider) a, err := db.scanCalendarAccount(row) if err == sql.ErrNoRows { return nil, nil @@ -106,27 +121,37 @@ func (db *DB) SaveCalendarAccount(ctx context.Context, a *CalendarAccount) error // struct with no id in it, and matching on the id would insert a second row // that the unique index then refuses — which reads to the person as "connecting // failed" when what happened is that it already had. + // `WHERE url = ''` fordi indekset er delvist siden 0026. + // + // En OAuth-konto er én pr. person og udbyder — det er tokenet, der er + // identiteten — mens et abonnement er én pr. adresse, og man har flere. De to + // bor i samme tabel, fordi et abonnement *er* en kalender med begivenheder i, + // og den halvdel er den samme. Målet for konflikten skal sige, hvilken af de to + // slags rækker der tales om, ellers ved SQLite det ikke. _, err = db.ExecContext(ctx, ` - INSERT INTO calendar_accounts (`+calendarAccountColumns+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (user_id, provider) DO UPDATE SET + INSERT INTO calendar_accounts (`+calendarAccountColumns+`, url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, provider) WHERE url = '' DO UPDATE SET account = excluded.account, refresh_token = excluded.refresh_token, access_token = excluded.access_token, expires_at = excluded.expires_at, last_sync_at = excluded.last_sync_at`, a.ID, a.UserID, a.Provider, a.Account, refresh, access, - unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix()) + unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix(), a.URL) if err != nil { return err } + if a.URL != "" { + return nil + } // The row that is actually there wins. On a reconnect the insert above updated // an existing row, so the id in hand is one nothing points at — and the // calendars are hung off the id, so writing them against it would attach them // to nothing. return db.QueryRowContext(ctx, - `SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ?`, + `SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ? AND url = ''`, a.UserID, a.Provider).Scan(&a.ID) } @@ -362,7 +387,7 @@ func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) { var a CalendarAccount var expires, lastSync, created int64 err := row.Scan(&a.ID, &a.UserID, &a.Provider, &a.Account, &a.RefreshToken, - &a.AccessToken, &expires, &lastSync, &created) + &a.AccessToken, &expires, &lastSync, &created, &a.URL) if err != nil { return a, err } @@ -378,3 +403,129 @@ func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) { a.CreatedAt = time.Unix(created, 0) return a, nil } + +// --- abonnementer --------------------------------------------------------------- + +// SubscribeCalendar adds a calendar somebody only has an address to. +// +// One row in `calendar_accounts` with a url and no tokens, and one row in +// `calendars` hung off it — because a subscription *is* one calendar, where an +// OAuth account is a list of them somebody picks from. Written together so a +// subscription is never a row with nothing under it: the sweep looks for accounts, +// and one without a calendar would be fetched for ever and stored nowhere. +// +// Shown from the start. Ticking a box after adding an address is a second step for +// a decision the person already made by adding it. +func (db *DB) SubscribeCalendar(ctx context.Context, userID, url, name string) (*CalendarAccount, error) { + account := &CalendarAccount{ + ID: NewID(), + UserID: userID, + Provider: "ics", + Account: name, + URL: url, + CreatedAt: time.Now(), + } + + err := db.Tx(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO calendar_accounts (id, user_id, provider, account, url, created_at) + VALUES (?, ?, 'ics', ?, ?, ?)`, + account.ID, userID, name, url, account.CreatedAt.Unix()); err != nil { + return err + } + _, err := tx.ExecContext(ctx, ` + INSERT INTO calendars (id, account_id, remote_id, name, shown) + VALUES (?, ?, ?, ?, 1)`, + NewID(), account.ID, url, name) + return err + }) + if isUniqueViolation(err) { + return nil, ErrDuplicate + } + if err != nil { + return nil, err + } + return account, nil +} + +// Subscriptions lists the calendars a person subscribes to by address. +func (db *DB) Subscriptions(ctx context.Context, userID string) ([]CalendarAccount, error) { + rows, err := db.QueryContext(ctx, `SELECT `+calendarAccountRead+` + FROM calendar_accounts WHERE user_id = ? AND url <> '' + ORDER BY created_at`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []CalendarAccount{} + for rows.Next() { + a, err := db.scanCalendarAccount(rows) + if err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// AllSubscriptions is every subscription on the instance, for the sweep. +// +// The sweep runs for the server rather than for a session, so it cannot ask "whose +// is this" the way a handler can — it is handed the user id along with the row and +// writes against that. +func (db *DB) AllSubscriptions(ctx context.Context) ([]CalendarAccount, error) { + rows, err := db.QueryContext(ctx, `SELECT `+calendarAccountRead+` + FROM calendar_accounts WHERE url <> '' ORDER BY last_sync_at`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []CalendarAccount{} + for rows.Next() { + a, err := db.scanCalendarAccount(rows) + if err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// RenameSubscribedCalendar writes the name the file calls itself. +// +// A subscription is added with the address as its name, because that is all there +// is to go on before it has been fetched once. The file usually says better — +// X-WR-CALNAME is what every publisher uses — and taking it means the list says +// "Danske helligdage" instead of a URL nobody reads. +// +// Only when the person has not renamed it themselves: a name they typed is an +// instruction, and a name from the file is a guess. +func (db *DB) RenameSubscribedCalendar(ctx context.Context, accountID, name string) error { + if strings.TrimSpace(name) == "" { + return nil + } + return db.Tx(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, ` + UPDATE calendar_accounts SET account = ? + WHERE id = ? AND (account = '' OR account = url)`, name, accountID); err != nil { + return err + } + _, err := tx.ExecContext(ctx, ` + UPDATE calendars SET name = ? + WHERE account_id = ? AND (name = '' OR name = remote_id)`, name, accountID) + return err + }) +} + +// SubscribedCalendarID is the single calendar row under a subscription. +func (db *DB) SubscribedCalendarID(ctx context.Context, accountID string) (string, error) { + var id string + err := db.QueryRowContext(ctx, + `SELECT id FROM calendars WHERE account_id = ? LIMIT 1`, accountID).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return id, err +} diff --git a/internal/store/migrations/0026_calendar_subscriptions.sql b/internal/store/migrations/0026_calendar_subscriptions.sql new file mode 100644 index 0000000..5f18c1a --- /dev/null +++ b/internal/store/migrations/0026_calendar_subscriptions.sql @@ -0,0 +1,30 @@ +-- Abonnement på en kalender, man kun har en adresse til. +-- +-- Google-vejen er OAuth: én konto pr. person, hvor tokenet er identiteten, og +-- `calendar_accounts_once` sørgede for netop det — én række pr. (person, +-- udbyder). Et abonnement er en anden slags ting. Der er intet token og ingen +-- konto, kun en adresse, og man har flere: helligdage, en fodboldklub, en +-- fælleskalender fra arbejdet. Det er derfor det blev bedt om som "flere +-- kalendere". +-- +-- Samme tabel frem for en ny. Et abonnement er én kalender med begivenheder i, og +-- det er præcis det, `calendars` og `calendar_events` allerede beskriver — med +-- vinduet, `shown`, farven og hele fejemaskinen omkring sig. En tabel til ville +-- være en anden vej til det samme sted, og to veje til ét sted er to steder at +-- rette den dag noget om begivenheder ændrer sig. +-- +-- Adressen bliver identiteten, som tokenet er det for Google. To rækker med den +-- samme adresse for den samme person ville være den samme kalender hentet to +-- gange, og der er ingen måde at sige hvilken der gælder. +ALTER TABLE calendar_accounts ADD COLUMN url TEXT NOT NULL DEFAULT ''; + +-- Det gamle indeks holder stadig for OAuth-rækkerne — dem uden adresse — og +-- slipper abonnementerne fri. Delvise indekser er den ene ting SQLite kan her, +-- som en tabelombygning ellers skulle til for. +DROP INDEX calendar_accounts_once; + +CREATE UNIQUE INDEX calendar_accounts_once + ON calendar_accounts (user_id, provider) WHERE url = ''; + +CREATE UNIQUE INDEX calendar_subscription_once + ON calendar_accounts (user_id, url) WHERE url <> ''; diff --git a/internal/store/users.go b/internal/store/users.go index 6af2745..6558cb4 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -16,6 +16,11 @@ var ( ErrNotFound = errors.New("store: not found") ErrEmailInUse = errors.New("store: that email address already has an account") ErrInviteInvalid = errors.New("store: invite is not valid") + // ErrDuplicate er en unik nøgle, der blev brudt, oversat til noget en handler + // kan spørge om. Drivereren udstiller ikke sin egen fejltype brugbart, og en + // handler, der matcher på tekst, er en handler, der går i stykker den dag + // driveren omformulerer sig. + ErrDuplicate = errors.New("store: already exists") ) type User struct { diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 695f3c7..ca892e8 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -383,6 +383,10 @@ export const api = { // an event, only which calendars to look at. getCalendar: () => get('/calendar'), setCalendars: (shown) => put('/calendar/calendars', { shown }), + // En kalender, man kun har en adresse til. Flere pr. person, hvor Google er + // én: adressen er identiteten, som tokenet er det for OAuth. + subscribeCalendar: (url, name) => post('/calendar/subscriptions', { url, name }), + unsubscribeCalendar: (id) => del(`/calendar/subscriptions/${id}`), disconnectCalendar: () => del('/calendar'), authorizeCalendar: () => post('/calendar/authorize'), syncCalendar: () => post('/calendar/sync'), diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 0edd7e7..79788c1 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -671,6 +671,13 @@ export const da = { 'Bruger den samme OAuth-klient som Gmail ovenfor. Google Calendar API skal være slået til, og denne adresse skal stå som godkendt redirect-URI.', 'int.whichCalendars': 'Hvilke kalendere vises', 'int.noCalendars': 'Kontoen har ingen kalendere at vise.', + 'int.subscriptions': 'Abonnementer', + 'int.subscriptionsHint': 'En kalender, du kun har en adresse til — helligdage, en klub, en fælleskalender fra arbejdet. Den hentes hvert kvarter og er skrivebeskyttet. webcal://-adresser virker; de er https med et andet skema foran.', + 'int.subscriptionURL': 'Kalenderens adresse', + 'int.subscribe': 'Abonnér', + 'int.subscribing': 'Henter …', + 'int.unsubscribe': 'Fjern', + 'int.unsubscribeQuestion': 'Fjern abonnementet på "{name}"? Begivenhederne forsvinder fra kalenderen.', 'int.calendarLastSync': 'Sidst hentet {when}.', 'int.calendarNeverSynced': 'Ikke hentet endnu.', 'int.calendarFetchedOne': '{n} begivenhed hentet.', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 4797581..35bb2ac 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -667,6 +667,13 @@ export const en = { 'Uses the same OAuth client as Gmail above. The Google Calendar API has to be enabled, and this address has to be listed as an authorised redirect URI.', 'int.whichCalendars': 'Which calendars are shown', 'int.noCalendars': 'The account has no calendars to show.', + 'int.subscriptions': 'Subscriptions', + 'int.subscriptionsHint': 'A calendar you only have an address to — holidays, a club, a shared calendar from work. Fetched every fifteen minutes, and read-only. webcal:// addresses work; they are https with a different scheme in front.', + 'int.subscriptionURL': "The calendar's address", + 'int.subscribe': 'Subscribe', + 'int.subscribing': 'Fetching …', + 'int.unsubscribe': 'Remove', + 'int.unsubscribeQuestion': 'Stop subscribing to "{name}"? Its events go from the calendar.', 'int.calendarLastSync': 'Last fetched {when}.', 'int.calendarNeverSynced': 'Not fetched yet.', 'int.calendarFetchedOne': '{n} event fetched.', diff --git a/web/src/routes/indstillinger/integrationer/+page.svelte b/web/src/routes/indstillinger/integrationer/+page.svelte index c1c2954..d82f2f9 100644 --- a/web/src/routes/indstillinger/integrationer/+page.svelte +++ b/web/src/routes/indstillinger/integrationer/+page.svelte @@ -191,6 +191,41 @@ } } + // --- abonnementer --------------------------------------------------------- + let subscriptionURL = $state(''); + let subscribing = $state(false); + let subscriptionError = $state(''); + + async function subscribe(event) { + event.preventDefault(); + const url = subscriptionURL.trim(); + if (!url) return; + subscribing = true; + subscriptionError = ''; + try { + await api.subscribeCalendar(url, ''); + subscriptionURL = ''; + calendar = await api.getCalendar(); + } catch (e) { + // Feltets egen fejl frem for en toast: den hører til det felt, der lige + // blev skrevet i, og en toast, der forsvinder, er en fejlmeddelelse, man + // skal huske. + subscriptionError = e.fields?.url ?? humanMessage(e); + } finally { + subscribing = false; + } + } + + async function unsubscribe(sub) { + if (!confirm(t('int.unsubscribeQuestion', { name: sub.account || sub.url }))) return; + try { + await api.unsubscribeCalendar(sub.id); + calendar = await api.getCalendar(); + } catch (e) { + app.toast(humanMessage(e)); + } + } + async function saveCalendars(event) { event.preventDefault(); savingCalendars = true; @@ -548,6 +583,48 @@ {/if} + + + {#if calendar !== null} +
+

{t('int.subscriptions')}

+

{t('int.subscriptionsHint')}

+ + {#if calendar.subscriptions?.length} + + {/if} + +
+
+ + + {#if subscriptionError}

{subscriptionError}

{/if} +
+
+ +
+
+
+ {/if}