731 lines
21 KiB
Go
731 lines
21 KiB
Go
// Package caldav implements a CalDAV server (RFC 4791 over WebDAV RFC 4918).
|
|
// Authentication: HTTP Basic Auth against the user DB.
|
|
// Events are stored as AES-256-GCM encrypted iCalendar blobs.
|
|
package caldav
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
|
"ghb.freebede.com/nahakubuilder/mailgosend/internal/db"
|
|
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
|
)
|
|
|
|
const (
|
|
nsDAV = "DAV:"
|
|
nsCalDAV = "urn:ietf:params:xml:ns:caldav"
|
|
nsCS = "http://calendarserver.org/ns/"
|
|
)
|
|
|
|
// Deps holds CalDAV server dependencies.
|
|
type Deps struct {
|
|
DB *db.DB
|
|
Crypt *appCrypto.Crypto
|
|
}
|
|
|
|
// Server is the CalDAV HTTP handler.
|
|
type Server struct {
|
|
deps *Deps
|
|
mux *http.ServeMux
|
|
}
|
|
|
|
// New creates a CalDAV server and registers handlers on the given mux prefix.
|
|
func New(deps *Deps) *Server {
|
|
s := &Server{deps: deps, mux: http.NewServeMux()}
|
|
s.setup()
|
|
return s
|
|
}
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.mux.ServeHTTP(w, r)
|
|
}
|
|
|
|
func (s *Server) setup() {
|
|
// Well-known discovery redirects.
|
|
s.mux.HandleFunc("/.well-known/caldav", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/caldav/", http.StatusMovedPermanently)
|
|
})
|
|
|
|
// All CalDAV routes use a catch-all; we route internally by path/method.
|
|
s.mux.HandleFunc("/caldav/", s.withAuth(s.route))
|
|
}
|
|
|
|
// withAuth wraps a handler requiring HTTP Basic Auth.
|
|
func (s *Server) withAuth(next func(http.ResponseWriter, *http.Request, *models.User)) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
user, err := s.authenticate(r)
|
|
if err != nil || user == nil {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="mailgosend CalDAV", charset="UTF-8"`)
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if !user.Enabled {
|
|
http.Error(w, "Account disabled", http.StatusForbidden)
|
|
return
|
|
}
|
|
next(w, r, user)
|
|
}
|
|
}
|
|
|
|
func (s *Server) authenticate(r *http.Request) (*models.User, error) {
|
|
authHeader := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(authHeader, "Basic ") {
|
|
return nil, nil
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(authHeader[6:])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("basic auth decode: %w", err)
|
|
}
|
|
parts := strings.SplitN(string(decoded), ":", 2)
|
|
if len(parts) != 2 {
|
|
return nil, fmt.Errorf("basic auth format")
|
|
}
|
|
email := strings.TrimSpace(parts[0])
|
|
password := parts[1]
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
user, err := s.deps.DB.GetUserByEmail(ctx, email)
|
|
if err != nil || user == nil {
|
|
return nil, err
|
|
}
|
|
if err := appCrypto.CheckPassword(user.PasswordHash, password); err != nil {
|
|
return nil, nil
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
// route dispatches CalDAV requests by path structure and HTTP method.
|
|
// Path patterns under /caldav/:
|
|
// /caldav/ → root (discovery)
|
|
// /caldav/p/{userID} → principal
|
|
// /caldav/{userID}/ → calendar home
|
|
// /caldav/{userID}/{calID}/ → calendar collection
|
|
// /caldav/{userID}/{calID}/{uid}.ics → event resource
|
|
func (s *Server) route(w http.ResponseWriter, r *http.Request, user *models.User) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/caldav")
|
|
path = strings.TrimSuffix(path, "/")
|
|
segments := splitPath(path)
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
r = r.WithContext(ctx)
|
|
|
|
switch len(segments) {
|
|
case 0:
|
|
// /caldav/ — root
|
|
s.handleOptions(w, r, "caldav")
|
|
if r.Method == "PROPFIND" {
|
|
s.propfindRoot(w, r, user)
|
|
}
|
|
|
|
case 1:
|
|
if segments[0] == "p" || strings.HasPrefix(segments[0], "p") {
|
|
// /caldav/p — principal without user ID (redirect to user principal)
|
|
http.Redirect(w, r, fmt.Sprintf("/caldav/p/%d", user.ID), http.StatusMovedPermanently)
|
|
return
|
|
}
|
|
// /caldav/{userID}/ — calendar home
|
|
ownerID, err := strconv.ParseInt(segments[0], 10, 64)
|
|
if err != nil || ownerID != user.ID {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case "OPTIONS":
|
|
s.handleOptions(w, r, "collection")
|
|
case "PROPFIND":
|
|
s.propfindHome(w, r, user)
|
|
case "MKCOL":
|
|
s.mkcalendarHome(w, r, user)
|
|
default:
|
|
w.Header().Set("Allow", davAllow)
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
|
|
case 2:
|
|
// /caldav/p/{userID} — principal
|
|
if segments[0] == "p" {
|
|
switch r.Method {
|
|
case "OPTIONS":
|
|
s.handleOptions(w, r, "principal")
|
|
case "PROPFIND":
|
|
s.propfindPrincipal(w, r, user)
|
|
default:
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
return
|
|
}
|
|
// /caldav/{userID}/{calID}/ — calendar collection
|
|
ownerID, _ := strconv.ParseInt(segments[0], 10, 64)
|
|
if ownerID != user.ID {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
calID, err := strconv.ParseInt(segments[1], 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
cal, err := s.deps.DB.GetCalendarByID(r.Context(), calID)
|
|
if err != nil || cal == nil || cal.UserID != user.ID {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case "OPTIONS":
|
|
s.handleOptions(w, r, "calendar")
|
|
case "PROPFIND":
|
|
s.propfindCalendar(w, r, user, cal)
|
|
case "REPORT":
|
|
s.reportCalendar(w, r, user, cal)
|
|
case "DELETE":
|
|
s.deps.DB.DeleteCalendar(r.Context(), calID) //nolint:errcheck
|
|
w.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
w.Header().Set("Allow", davAllow)
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
|
|
case 3:
|
|
// /caldav/{userID}/{calID}/{uid}.ics — event resource
|
|
ownerID, _ := strconv.ParseInt(segments[0], 10, 64)
|
|
if ownerID != user.ID {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
calID, err := strconv.ParseInt(segments[1], 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
cal, err := s.deps.DB.GetCalendarByID(r.Context(), calID)
|
|
if err != nil || cal == nil || cal.UserID != user.ID {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
uid := strings.TrimSuffix(segments[2], ".ics")
|
|
switch r.Method {
|
|
case "GET", "HEAD":
|
|
s.getEvent(w, r, user, cal, uid)
|
|
case "PUT":
|
|
s.putEvent(w, r, user, cal, uid)
|
|
case "DELETE":
|
|
s.deleteEvent(w, r, user, cal, uid)
|
|
case "OPTIONS":
|
|
s.handleOptions(w, r, "event")
|
|
default:
|
|
w.Header().Set("Allow", davAllow)
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
const davAllow = "OPTIONS, GET, PUT, DELETE, PROPFIND, MKCOL, REPORT"
|
|
|
|
func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request, resType string) {
|
|
w.Header().Set("DAV", "1, 2, 3, calendar-access")
|
|
w.Header().Set("Allow", davAllow)
|
|
w.Header().Set("Ms-Author-Via", "DAV")
|
|
if r.Method == "OPTIONS" {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// ---- PROPFIND handlers ----
|
|
|
|
func (s *Server) propfindRoot(w http.ResponseWriter, r *http.Request, user *models.User) {
|
|
depth := r.Header.Get("Depth")
|
|
if depth == "" {
|
|
depth = "0"
|
|
}
|
|
|
|
responses := []davResponse{
|
|
{
|
|
Href: "/caldav/",
|
|
Props: []davProp{
|
|
{Name: "resourcetype", NS: nsDAV, Value: "<D:collection/>"},
|
|
{Name: "displayname", NS: nsDAV, Value: "CalDAV"},
|
|
{Name: "current-user-principal", NS: nsDAV,
|
|
Value: fmt.Sprintf("<D:href>/caldav/p/%d</D:href>", user.ID)},
|
|
},
|
|
},
|
|
}
|
|
writeMultiStatus(w, responses)
|
|
}
|
|
|
|
func (s *Server) propfindPrincipal(w http.ResponseWriter, r *http.Request, user *models.User) {
|
|
responses := []davResponse{
|
|
{
|
|
Href: fmt.Sprintf("/caldav/p/%d", user.ID),
|
|
Props: []davProp{
|
|
{Name: "resourcetype", NS: nsDAV, Value: "<D:principal/>"},
|
|
{Name: "displayname", NS: nsDAV, Value: xmlEscape(user.DisplayName)},
|
|
{Name: "current-user-principal", NS: nsDAV,
|
|
Value: fmt.Sprintf("<D:href>/caldav/p/%d</D:href>", user.ID)},
|
|
{Name: "calendar-home-set", NS: nsCalDAV,
|
|
Value: fmt.Sprintf("<D:href>/caldav/%d/</D:href>", user.ID)},
|
|
},
|
|
},
|
|
}
|
|
writeMultiStatus(w, responses)
|
|
}
|
|
|
|
func (s *Server) propfindHome(w http.ResponseWriter, r *http.Request, user *models.User) {
|
|
depth := r.Header.Get("Depth")
|
|
|
|
cals, err := s.deps.DB.ListCalendars(r.Context(), user.ID)
|
|
if err != nil {
|
|
log.Printf("[caldav] list calendars: %v", err)
|
|
}
|
|
|
|
responses := []davResponse{
|
|
{
|
|
Href: fmt.Sprintf("/caldav/%d/", user.ID),
|
|
Props: []davProp{
|
|
{Name: "resourcetype", NS: nsDAV, Value: "<D:collection/>"},
|
|
{Name: "displayname", NS: nsDAV, Value: "Calendars"},
|
|
{Name: "current-user-principal", NS: nsDAV,
|
|
Value: fmt.Sprintf("<D:href>/caldav/p/%d</D:href>", user.ID)},
|
|
{Name: "calendar-home-set", NS: nsCalDAV,
|
|
Value: fmt.Sprintf("<D:href>/caldav/%d/</D:href>", user.ID)},
|
|
},
|
|
},
|
|
}
|
|
|
|
if depth != "0" {
|
|
for _, cal := range cals {
|
|
responses = append(responses, calendarResponse(user.ID, cal))
|
|
}
|
|
}
|
|
writeMultiStatus(w, responses)
|
|
}
|
|
|
|
func (s *Server) propfindCalendar(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar) {
|
|
depth := r.Header.Get("Depth")
|
|
|
|
responses := []davResponse{calendarResponse(user.ID, cal)}
|
|
|
|
if depth != "0" {
|
|
events, err := s.deps.DB.ListCalendarEvents(r.Context(), cal.ID)
|
|
if err != nil {
|
|
log.Printf("[caldav] list events: %v", err)
|
|
}
|
|
for _, ev := range events {
|
|
responses = append(responses, eventResponse(user.ID, cal.ID, ev))
|
|
}
|
|
}
|
|
writeMultiStatus(w, responses)
|
|
}
|
|
|
|
func calendarResponse(userID int64, cal *models.Calendar) davResponse {
|
|
ctag := strconv.FormatInt(cal.SyncToken, 10)
|
|
syncToken := fmt.Sprintf("https://example.com/ns/sync/%d", cal.SyncToken)
|
|
return davResponse{
|
|
Href: fmt.Sprintf("/caldav/%d/%d/", userID, cal.ID),
|
|
Props: []davProp{
|
|
{Name: "resourcetype", NS: nsDAV,
|
|
Value: `<D:collection/><C:calendar xmlns:C="` + nsCalDAV + `"/>`},
|
|
{Name: "displayname", NS: nsDAV, Value: xmlEscape(cal.Name)},
|
|
{Name: "calendar-description", NS: nsCalDAV, Value: xmlEscape(cal.Description)},
|
|
{Name: "calendar-color", NS: "http://apple.com/ns/ical/", Value: xmlEscape(cal.Color)},
|
|
{Name: "supported-calendar-component-set", NS: nsCalDAV,
|
|
Value: `<C:comp xmlns:C="` + nsCalDAV + `" name="VEVENT"/><C:comp xmlns:C="` + nsCalDAV + `" name="VTODO"/>`},
|
|
{Name: "getctag", NS: nsCS, Value: ctag},
|
|
{Name: "sync-token", NS: nsDAV, Value: xmlEscape(syncToken)},
|
|
},
|
|
}
|
|
}
|
|
|
|
func eventResponse(userID, calID int64, ev *models.CalendarEvent) davResponse {
|
|
return davResponse{
|
|
Href: fmt.Sprintf("/caldav/%d/%d/%s.ics", userID, calID, ev.UID),
|
|
Props: []davProp{
|
|
{Name: "resourcetype", NS: nsDAV, Value: ""},
|
|
{Name: "getetag", NS: nsDAV, Value: `"` + ev.ETag + `"`},
|
|
{Name: "getcontenttype", NS: nsDAV, Value: "text/calendar; charset=utf-8"},
|
|
{Name: "getlastmodified", NS: nsDAV, Value: ev.UpdatedAt.UTC().Format(http.TimeFormat)},
|
|
},
|
|
}
|
|
}
|
|
|
|
// ---- REPORT ----
|
|
|
|
func (s *Server) reportCalendar(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar) {
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 256*1024))
|
|
if err != nil {
|
|
http.Error(w, "read error", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
XMLName xml.Name `xml:""`
|
|
SyncToken string `xml:"sync-token"`
|
|
Hrefs []string `xml:"href"`
|
|
}
|
|
_ = xml.Unmarshal(body, &req)
|
|
|
|
localName := ""
|
|
if req.XMLName.Local != "" {
|
|
localName = req.XMLName.Local
|
|
}
|
|
|
|
switch localName {
|
|
case "sync-collection":
|
|
s.syncCollection(w, r, user, cal, req.SyncToken)
|
|
case "calendar-multiget":
|
|
s.calendarMultiget(w, r, user, cal, req.Hrefs)
|
|
default:
|
|
// calendar-query or unknown: return all events.
|
|
s.propfindCalendar(w, r, user, cal)
|
|
}
|
|
}
|
|
|
|
func (s *Server) syncCollection(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, clientToken string) {
|
|
// Minimal sync: if token matches current, return 0 changes; else return all.
|
|
curToken := fmt.Sprintf("https://example.com/ns/sync/%d", cal.SyncToken)
|
|
if clientToken == curToken {
|
|
// No changes since last sync.
|
|
writeMultiStatus(w, []davResponse{})
|
|
return
|
|
}
|
|
// Full sync: return all events.
|
|
events, err := s.deps.DB.ListCalendarEvents(r.Context(), cal.ID)
|
|
if err != nil {
|
|
log.Printf("[caldav] sync events: %v", err)
|
|
}
|
|
var responses []davResponse
|
|
for _, ev := range events {
|
|
responses = append(responses, eventResponse(user.ID, cal.ID, ev))
|
|
}
|
|
writeMultiStatus(w, responses)
|
|
}
|
|
|
|
func (s *Server) calendarMultiget(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, hrefs []string) {
|
|
var responses []davResponse
|
|
for _, href := range hrefs {
|
|
// Extract UID from href like /caldav/{uid}/{calid}/{uid}.ics
|
|
parts := splitPath(strings.TrimPrefix(href, "/caldav"))
|
|
if len(parts) < 3 {
|
|
continue
|
|
}
|
|
uid := strings.TrimSuffix(parts[len(parts)-1], ".ics")
|
|
ev, err := s.deps.DB.GetCalendarEvent(r.Context(), cal.ID, uid)
|
|
if err != nil || ev == nil {
|
|
responses = append(responses, davResponse{
|
|
Href: href,
|
|
Status: "HTTP/1.1 404 Not Found",
|
|
})
|
|
continue
|
|
}
|
|
// Return event with ical data.
|
|
raw, err := s.decryptICal(user.ID, ev.ICalEnc)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
res := eventResponse(user.ID, cal.ID, ev)
|
|
res.Props = append(res.Props, davProp{
|
|
Name: "calendar-data", NS: nsCalDAV, Value: xmlEscape(string(raw)), CData: true,
|
|
})
|
|
responses = append(responses, res)
|
|
}
|
|
writeMultiStatus(w, responses)
|
|
}
|
|
|
|
// ---- Event GET/PUT/DELETE ----
|
|
|
|
func (s *Server) getEvent(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, uid string) {
|
|
ev, err := s.deps.DB.GetCalendarEvent(r.Context(), cal.ID, uid)
|
|
if err != nil || ev == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Conditional GET.
|
|
if match := r.Header.Get("If-None-Match"); match != "" {
|
|
if match == `"`+ev.ETag+`"` {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
}
|
|
|
|
raw, err := s.decryptICal(user.ID, ev.ICalEnc)
|
|
if err != nil {
|
|
http.Error(w, "decrypt error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
|
w.Header().Set("ETag", `"`+ev.ETag+`"`)
|
|
w.Header().Set("Last-Modified", ev.UpdatedAt.UTC().Format(http.TimeFormat))
|
|
if r.Method == "HEAD" {
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(raw)))
|
|
return
|
|
}
|
|
w.Write(raw) //nolint:errcheck
|
|
}
|
|
|
|
func (s *Server) putEvent(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, uid string) {
|
|
if r.ContentLength > 1024*1024 {
|
|
http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
|
|
raw, err := io.ReadAll(io.LimitReader(r.Body, 1024*1024))
|
|
if err != nil || len(raw) == 0 {
|
|
http.Error(w, "read error", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate it's iCalendar data.
|
|
rawStr := string(raw)
|
|
if !strings.Contains(rawStr, "BEGIN:VCALENDAR") {
|
|
http.Error(w, "Invalid iCalendar data", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Conditional check: If-Match must match existing ETag.
|
|
existing, err := s.deps.DB.GetCalendarEvent(r.Context(), cal.ID, uid)
|
|
if err != nil {
|
|
http.Error(w, "db error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if ifMatch := r.Header.Get("If-Match"); ifMatch != "" && ifMatch != "*" {
|
|
if existing == nil || `"`+existing.ETag+`"` != ifMatch {
|
|
http.Error(w, "Precondition Failed", http.StatusPreconditionFailed)
|
|
return
|
|
}
|
|
}
|
|
if r.Header.Get("If-None-Match") == "*" && existing != nil {
|
|
http.Error(w, "Precondition Failed", http.StatusPreconditionFailed)
|
|
return
|
|
}
|
|
|
|
// Parse minimal fields from iCalendar for DB index.
|
|
dtStart, dtEnd, summary, recurring := parseICal(rawStr)
|
|
|
|
// Encrypt and store.
|
|
icalEnc, err := s.encryptICal(user.ID, raw)
|
|
if err != nil {
|
|
http.Error(w, "encrypt error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
etag := sha256Hex(raw)
|
|
if err := s.deps.DB.UpsertCalendarEvent(r.Context(), cal.ID, uid, etag, icalEnc, dtStart, dtEnd, summary, recurring); err != nil {
|
|
log.Printf("[caldav] upsert event: %v", err)
|
|
http.Error(w, "db error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if _, err := s.deps.DB.BumpCalendarSyncToken(r.Context(), cal.ID); err != nil {
|
|
log.Printf("[caldav] bump token: %v", err)
|
|
}
|
|
|
|
w.Header().Set("ETag", `"`+etag+`"`)
|
|
if existing == nil {
|
|
w.WriteHeader(http.StatusCreated)
|
|
} else {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
func (s *Server) deleteEvent(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, uid string) {
|
|
ev, err := s.deps.DB.GetCalendarEvent(r.Context(), cal.ID, uid)
|
|
if err != nil || ev == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if ifMatch := r.Header.Get("If-Match"); ifMatch != "" && ifMatch != "*" {
|
|
if `"`+ev.ETag+`"` != ifMatch {
|
|
http.Error(w, "Precondition Failed", http.StatusPreconditionFailed)
|
|
return
|
|
}
|
|
}
|
|
if err := s.deps.DB.DeleteCalendarEvent(r.Context(), cal.ID, uid); err != nil {
|
|
http.Error(w, "db error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if _, err := s.deps.DB.BumpCalendarSyncToken(r.Context(), cal.ID); err != nil {
|
|
log.Printf("[caldav] bump token delete: %v", err)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) mkcalendarHome(w http.ResponseWriter, r *http.Request, user *models.User) {
|
|
// MKCOL on calendar home: ensure default calendar exists.
|
|
if _, err := s.deps.DB.EnsureDefaultCalendar(r.Context(), user.ID); err != nil {
|
|
http.Error(w, "error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
}
|
|
|
|
// ---- Encryption helpers ----
|
|
|
|
func (s *Server) encryptICal(userID int64, plain []byte) ([]byte, error) {
|
|
key, err := s.deps.Crypt.DeriveKey("ical", userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return appCrypto.Encrypt(key, plain)
|
|
}
|
|
|
|
func (s *Server) decryptICal(userID int64, enc []byte) ([]byte, error) {
|
|
key, err := s.deps.Crypt.DeriveKey("ical", userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return appCrypto.Decrypt(key, enc)
|
|
}
|
|
|
|
// ---- iCalendar parsing (minimal, no third-party lib) ----
|
|
|
|
// parseICal extracts DTSTART, DTEND, SUMMARY, RRULE presence from raw iCal text.
|
|
func parseICal(raw string) (dtStart, dtEnd time.Time, summary string, recurring bool) {
|
|
for _, line := range strings.Split(raw, "\n") {
|
|
line = strings.TrimRight(line, "\r")
|
|
k, v, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
// Strip parameters from key (e.g., DTSTART;TZID=UTC → DTSTART)
|
|
k = strings.SplitN(k, ";", 2)[0]
|
|
switch strings.ToUpper(k) {
|
|
case "DTSTART":
|
|
dtStart = parseICalTime(v)
|
|
case "DTEND":
|
|
dtEnd = parseICalTime(v)
|
|
case "SUMMARY":
|
|
summary = icalUnfold(v)
|
|
case "RRULE":
|
|
recurring = true
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func parseICalTime(s string) time.Time {
|
|
s = strings.TrimSuffix(s, "Z")
|
|
formats := []string{"20060102T150405", "20060102"}
|
|
for _, f := range formats {
|
|
if t, err := time.Parse(f, s); err == nil {
|
|
return t.UTC()
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func icalUnfold(s string) string {
|
|
return strings.ReplaceAll(s, `\n`, "\n")
|
|
}
|
|
|
|
// ---- XML multi-status response ----
|
|
|
|
type davProp struct {
|
|
Name string
|
|
NS string
|
|
Value string
|
|
CData bool // wrap Value in CDATA
|
|
}
|
|
|
|
type davResponse struct {
|
|
Href string
|
|
Status string // if empty, use 200 OK
|
|
Props []davProp
|
|
}
|
|
|
|
func writeMultiStatus(w http.ResponseWriter, responses []davResponse) {
|
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
|
w.WriteHeader(http.StatusMultiStatus)
|
|
|
|
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>`+"\n")
|
|
fmt.Fprintf(w, `<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">`+"\n")
|
|
|
|
for _, resp := range responses {
|
|
fmt.Fprintf(w, " <D:response>\n")
|
|
fmt.Fprintf(w, " <D:href>%s</D:href>\n", xmlEscape(resp.Href))
|
|
|
|
if resp.Status != "" {
|
|
fmt.Fprintf(w, " <D:status>%s</D:status>\n", xmlEscape(resp.Status))
|
|
} else if len(resp.Props) > 0 {
|
|
fmt.Fprintf(w, " <D:propstat>\n <D:prop>\n")
|
|
for _, p := range resp.Props {
|
|
writeDAVProp(w, p)
|
|
}
|
|
fmt.Fprintf(w, " </D:prop>\n <D:status>HTTP/1.1 200 OK</D:status>\n </D:propstat>\n")
|
|
} else {
|
|
fmt.Fprintf(w, " <D:status>HTTP/1.1 200 OK</D:status>\n")
|
|
}
|
|
|
|
fmt.Fprintf(w, " </D:response>\n")
|
|
}
|
|
|
|
fmt.Fprintf(w, "</D:multistatus>\n")
|
|
}
|
|
|
|
func writeDAVProp(w http.ResponseWriter, p davProp) {
|
|
ns := ""
|
|
switch p.NS {
|
|
case nsDAV:
|
|
ns = "D"
|
|
case nsCalDAV:
|
|
ns = "C"
|
|
case nsCS:
|
|
ns = "CS"
|
|
case "http://apple.com/ns/ical/":
|
|
ns = "ICAL"
|
|
default:
|
|
ns = "D"
|
|
}
|
|
|
|
if p.Value == "" {
|
|
fmt.Fprintf(w, " <%s:%s/>\n", ns, p.Name)
|
|
return
|
|
}
|
|
if p.CData {
|
|
fmt.Fprintf(w, " <%s:%s><![CDATA[%s]]></%s:%s>\n", ns, p.Name, p.Value, ns, p.Name)
|
|
} else {
|
|
fmt.Fprintf(w, " <%s:%s>%s</%s:%s>\n", ns, p.Name, p.Value, ns, p.Name)
|
|
}
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
func splitPath(path string) []string {
|
|
var out []string
|
|
for _, s := range strings.Split(strings.Trim(path, "/"), "/") {
|
|
if s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func xmlEscape(s string) string {
|
|
s = strings.ReplaceAll(s, "&", "&")
|
|
s = strings.ReplaceAll(s, "<", "<")
|
|
s = strings.ReplaceAll(s, ">", ">")
|
|
s = strings.ReplaceAll(s, `"`, """)
|
|
return s
|
|
}
|
|
|
|
func sha256Hex(data []byte) string {
|
|
h := sha256.Sum256(data)
|
|
return hex.EncodeToString(h[:])
|
|
}
|