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:
@@ -0,0 +1,130 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/yourusername/caldav-server/internal/db"
|
||||
"github.com/yourusername/caldav-server/internal/web/templates"
|
||||
)
|
||||
|
||||
// icsURLValid does a light sanity check on a subscription URL: it must be
|
||||
// http(s):// (fetched directly) or webcal:// (rewritten to https:// by
|
||||
// internal/icssub before fetching), and within a reasonable length.
|
||||
func icsURLValid(u string) bool {
|
||||
if len(u) == 0 || len(u) > 2048 {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "webcal://")
|
||||
}
|
||||
|
||||
// handleICSResource handles POST (create) and DELETE (remove) for the
|
||||
// current user's ICS/webcal calendar subscriptions, mounted at
|
||||
// /resources/ics.
|
||||
func (s *Server) handleICSResource(w http.ResponseWriter, r *http.Request) {
|
||||
username := userFromContext(r.Context())
|
||||
|
||||
// htmx v2 sends DELETE request parameters as URL query parameters, not
|
||||
// a request body (see internal/web/resources.go's handleResource).
|
||||
if r.Method == http.MethodDelete {
|
||||
r.PostForm = r.URL.Query()
|
||||
} else if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.PostForm.Get("name"))
|
||||
if !resourceNameRe.MatchString(name) {
|
||||
http.Error(w, "name must be 1-64 letters, digits, '-' or '_'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
url := strings.TrimSpace(r.PostForm.Get("url"))
|
||||
if !icsURLValid(url) {
|
||||
http.Error(w, "url must be a http://, https:// or webcal:// address", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
color := strings.TrimSpace(r.PostForm.Get("color"))
|
||||
if color != "" && !hexColorRe.MatchString(color) {
|
||||
http.Error(w, "color must be a hex value like #3b82f6", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.dbase.CreateICSSubscription(username, name, url, color); err != nil {
|
||||
if err == db.ErrResourceExists {
|
||||
http.Error(w, "already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err == db.ErrReservedName {
|
||||
http.Error(w, "this name is reserved for a computed calendar", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
s.logger.Error("creating ics subscription", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
case http.MethodDelete:
|
||||
if err := s.dbase.DeleteICSSubscription(username, name); err != nil && err != db.ErrResourceNotFound {
|
||||
s.logger.Error("deleting ics subscription", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
default:
|
||||
w.Header().Set("Allow", "POST, DELETE")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// The set of cards changed (one added/removed), so re-render the
|
||||
// whole #resources list rather than a single card.
|
||||
resources, err := s.resourceCards(username)
|
||||
if err != nil {
|
||||
s.logger.Error("listing resources", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = templates.ResourceList(resources).Render(context.Background(), w)
|
||||
}
|
||||
|
||||
// handleICSColor updates the color of one of the current user's ICS
|
||||
// subscriptions, mounted at /resources/ics/color. It re-renders just that
|
||||
// card (not the whole list), since the set of cards doesn't change.
|
||||
func (s *Server) handleICSColor(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
username := userFromContext(r.Context())
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.PostForm.Get("name"))
|
||||
color := strings.TrimSpace(r.PostForm.Get("color"))
|
||||
if !resourceNameRe.MatchString(name) {
|
||||
http.Error(w, "name must be 1-64 letters, digits, '-' or '_'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if color != "" && !hexColorRe.MatchString(color) {
|
||||
http.Error(w, "color must be a hex value like #3b82f6", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.dbase.SetICSSubscriptionColor(username, name, color); err != nil && err != db.ErrResourceNotFound {
|
||||
s.logger.Error("setting ics subscription color", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sub, err := s.dbase.GetICSSubscription(username, name)
|
||||
if err != nil {
|
||||
s.logger.Error("loading ics subscription", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
card := templates.ResourceCard{Kind: "ics", Name: sub.Name, Color: sub.Color, URL: sub.URL}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = templates.ResourceCardView(card).Render(r.Context(), w)
|
||||
}
|
||||
Reference in New Issue
Block a user