Files
nidus/internal/db/ics.go
T
arnefandCopilot 1707ab4060 Add ICS/webcal HTTP subscription calendars
Users can now subscribe to a remote ICS/webcal feed from the dashboard
(name + color), the same way they set up a real calendar or the virtual
Birthdays calendar. Subscriptions are read-only, per-user, and share the
"/cal/home/<name>/" namespace with real calendars and "birthdays" (name
collisions are rejected in both directions).

- internal/db: new ics_subscriptions table + CRUD (internal/db/ics.go);
  CreateCalendarWithColor checks for a colliding subscription name.
- internal/icssub: shared HTTP-fetch + TTL cache (15 min) for remote ICS
  calendars, with webcal:// -> https:// rewriting and stale-on-error
  fallback, used by both the web UI and the CalDAV backend.
- internal/web: dashboard "Subscribe to an ICS/webcal calendar" form,
  color picker, delete button (internal/web/ics.go,
  templates/dashboard.templ); month view renders subscription events in
  their chosen color, read-only (internal/web/calendar.go).
- internal/caldav: subscriptions are exposed as read-only calendars
  (internal/caldav/ics.go) - listed in PROPFIND, events served via
  GET/REPORT, PUT/DELETE on individual events rejected with 403, but
  DELETE on the calendar itself unsubscribes; calendar-color is injected
  the same way as for real calendars and Birthdays.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-20 22:10:49 +02:00

99 lines
3.4 KiB
Go

package db
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// ICSSubscription is a user-added read-only calendar backed by a remote
// ICS/webcal URL, exposed alongside real calendars under
// "/cal/home/<name>/" (see internal/caldav/ics.go).
type ICSSubscription struct {
Name string
URL string
Color string
}
// CreateICSSubscription registers a new ICS subscription owned by owner.
// Returns ErrReservedName if name is reserved for a computed calendar (see
// reservedCalendarNames), or ErrResourceExists if name is already used by
// one of owner's calendars or ICS subscriptions.
func (d *DB) CreateICSSubscription(owner, name, url, color string) error {
if reservedCalendarNames[strings.ToLower(name)] {
return ErrReservedName
}
// Shares the "/cal/home/<name>/" namespace with real calendars, so
// reject a name already taken by one of those.
var calExists int
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM calendars WHERE owner = ? AND name = ?`, owner, name).Scan(&calExists); err != nil {
return fmt.Errorf("checking calendar name: %w", err)
}
if calExists > 0 {
return ErrResourceExists
}
_, err := d.conn.Exec(`INSERT INTO ics_subscriptions (owner, name, url, color) VALUES (?, ?, ?, ?)`, owner, name, url, color)
if err != nil {
if isUniqueConstraintErr(err) {
return ErrResourceExists
}
return fmt.Errorf("creating ics subscription: %w", err)
}
return nil
}
// DeleteICSSubscription removes an ICS subscription. Returns
// ErrResourceNotFound if it doesn't exist.
func (d *DB) DeleteICSSubscription(owner, name string) error {
res, err := d.conn.Exec(`DELETE FROM ics_subscriptions WHERE owner = ? AND name = ?`, owner, name)
if err != nil {
return fmt.Errorf("deleting ics subscription: %w", err)
}
return requireRowsAffected(res, ErrResourceNotFound)
}
// SetICSSubscriptionColor updates an ICS subscription's display color.
// Returns ErrResourceNotFound if it doesn't exist.
func (d *DB) SetICSSubscriptionColor(owner, name, color string) error {
res, err := d.conn.Exec(`UPDATE ics_subscriptions SET color = ? WHERE owner = ? AND name = ?`, color, owner, name)
if err != nil {
return fmt.Errorf("setting ics subscription color: %w", err)
}
return requireRowsAffected(res, ErrResourceNotFound)
}
// GetICSSubscription returns owner's ICS subscription registered under
// name. Returns ErrResourceNotFound if it doesn't exist.
func (d *DB) GetICSSubscription(owner, name string) (ICSSubscription, error) {
sub := ICSSubscription{Name: name}
err := d.conn.QueryRow(`SELECT url, color FROM ics_subscriptions WHERE owner = ? AND name = ?`, owner, name).Scan(&sub.URL, &sub.Color)
if errors.Is(err, sql.ErrNoRows) {
return ICSSubscription{}, ErrResourceNotFound
}
if err != nil {
return ICSSubscription{}, fmt.Errorf("getting ics subscription: %w", err)
}
return sub, nil
}
// ListICSSubscriptions returns all of owner's ICS subscriptions, sorted by
// name.
func (d *DB) ListICSSubscriptions(owner string) ([]ICSSubscription, error) {
rows, err := d.conn.Query(`SELECT name, url, color FROM ics_subscriptions WHERE owner = ? ORDER BY name`, owner)
if err != nil {
return nil, fmt.Errorf("listing ics subscriptions: %w", err)
}
defer rows.Close()
var subs []ICSSubscription
for rows.Next() {
var s ICSSubscription
if err := rows.Scan(&s.Name, &s.URL, &s.Color); err != nil {
return nil, fmt.Errorf("scanning ics subscription: %w", err)
}
subs = append(subs, s)
}
return subs, rows.Err()
}