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>
This commit is contained in:
@@ -105,6 +105,19 @@ CREATE TABLE IF NOT EXISTS birthday_calendars (
|
||||
color TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- User-added read-only calendars backed by a remote ICS/webcal URL (see
|
||||
-- internal/db/ics.go, internal/web/ics.go, internal/caldav/ics.go). The
|
||||
-- local calendar name (like real calendars) is unique per owner and lives
|
||||
-- in the same "/cal/home/<name>/" namespace, so name collisions with the
|
||||
-- calendars table and reservedCalendarNames are checked at creation time.
|
||||
CREATE TABLE IF NOT EXISTS ics_subscriptions (
|
||||
owner TEXT NOT NULL REFERENCES users (username) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (owner, name)
|
||||
);
|
||||
|
||||
-- Web UI login sessions. Sessions are opaque random tokens stored server
|
||||
-- side (not JWTs) so they can be revoked instantly by deleting the row.
|
||||
CREATE TABLE IF NOT EXISTS web_sessions (
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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()
|
||||
}
|
||||
@@ -193,6 +193,16 @@ func (d *DB) CreateCalendarWithColor(owner, name, color string) error {
|
||||
if reservedCalendarNames[strings.ToLower(name)] {
|
||||
return ErrReservedName
|
||||
}
|
||||
// The "/cal/home/<name>/" namespace is shared with ICS subscriptions
|
||||
// (see internal/db/ics.go), so a real calendar can't be created under
|
||||
// a name already taken by one of those either.
|
||||
var subExists int
|
||||
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM ics_subscriptions WHERE owner = ? AND name = ?`, owner, name).Scan(&subExists); err != nil {
|
||||
return fmt.Errorf("checking ics subscription name: %w", err)
|
||||
}
|
||||
if subExists > 0 {
|
||||
return ErrResourceExists
|
||||
}
|
||||
_, err := d.conn.Exec(`INSERT INTO calendars (owner, name, color) VALUES (?, ?, ?)`, owner, name, color)
|
||||
if err != nil {
|
||||
if isUniqueConstraintErr(err) {
|
||||
|
||||
Reference in New Issue
Block a user