Add ICS import/export and shared calendar support to calendar UI

- Combine own and shared calendars into a single /web/calendar month
  view, each event colored per its source calendar.
- New event creation now uses a calendar <select> (only writable
  calendars offered); editing keeps the event's original calendar fixed.
- Shared calendars use an "owner~name" reference in URLs, resolved via
  resolveCalRef which also enforces read/write permissions from
  calendar_shares (read-only shares can view/export but not edit/delete).
- Add per-calendar ICS export (single event and export-all) and import,
  splitting multi-VEVENT uploads into individual stored objects while
  preserving VTIMEZONE definitions and source VERSION/PRODID.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-20 18:58:04 +02:00
co-authored by Copilot
parent cd2cfa6c06
commit adabfd22ca
5 changed files with 1071 additions and 559 deletions
+432 -114
View File
@@ -1,10 +1,13 @@
package web
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"sort"
@@ -13,6 +16,7 @@ import (
"time"
ical "github.com/emersion/go-ical"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/web/templates"
)
@@ -25,6 +29,19 @@ var eventIDRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,128}\.ics$`)
const dateLayout = "2006-01-02"
const timeLayout = "15:04"
// calRefSep separates the owner and calendar name in a shared calendar's
// reference string (e.g. "alice~vacations"), mirroring (but independent
// from) internal/caldav's sharedNameSep convention for its own URL
// namespace.
const calRefSep = "~"
// errCalendarNotFound/errCalendarReadOnly are sentinel errors returned by
// resolveCalRef, translated by callers into 404/403 responses.
var (
errCalendarNotFound = errors.New("calendar not found")
errCalendarReadOnly = errors.New("calendar is read-only")
)
// newEventID generates a random filename for a new event object,
// mirroring the "<id>.ics" convention used by the CalDAV backend
// (internal/caldav).
@@ -36,9 +53,13 @@ func newEventID() (string, error) {
return hex.EncodeToString(buf) + ".ics", nil
}
// ownsCalendar reports whether cal is one of username's own calendars
// (never one shared with them by another user — the web calendar UI only
// manages a user's own calendars, consistent with contacts/files).
// sharedCalRef builds the reference string used in URLs for a calendar
// shared with the requesting user by owner.
func sharedCalRef(owner, name string) string {
return owner + calRefSep + name
}
// ownsCalendar reports whether cal is one of username's own calendars.
func (s *Server) ownsCalendar(username, cal string) (bool, error) {
if !resourceNameRe.MatchString(cal) {
return false, nil
@@ -55,28 +76,90 @@ func (s *Server) ownsCalendar(username, cal string) (bool, error) {
return false, nil
}
func (s *Server) handleCalendarsHome(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
// resolveCalRef resolves a calendar reference (either a bare name for
// username's own calendar, or "<owner>~<name>" for a calendar shared with
// username) into the concrete (owner, name) pair to use when addressing
// store.Store/db.DB. If requireWrite is true, a read-only share is
// rejected with errCalendarReadOnly.
func (s *Server) resolveCalRef(username, ref string, requireWrite bool) (owner, name string, err error) {
if owner, name, ok := strings.Cut(ref, calRefSep); ok {
if !resourceNameRe.MatchString(owner) || !resourceNameRe.MatchString(name) {
return "", "", errCalendarNotFound
}
share, err := s.dbase.CalendarShareFor(owner, name, username)
if err != nil {
if errors.Is(err, db.ErrShareNotFound) {
return "", "", errCalendarNotFound
}
return "", "", err
}
if requireWrite && share.Permission != db.PermWrite {
return "", "", errCalendarReadOnly
}
return owner, name, nil
}
username := userFromContext(r.Context())
cals, err := s.dbase.ListCalendars(username)
ok, err := s.ownsCalendar(username, ref)
if err != nil {
s.logger.Error("listing calendars", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
return "", "", err
}
sort.Slice(cals, func(i, j int) bool { return cals[i].Name < cals[j].Name })
if !ok {
return "", "", errCalendarNotFound
}
return username, ref, nil
}
var summaries []templates.CalendarSummary
for _, c := range cals {
summaries = append(summaries, templates.CalendarSummary{Name: c.Name, Color: c.Color})
// calendarEntry is one calendar (own or shared) visible to a user in the
// combined month view.
type calendarEntry struct {
Ref string // path-safe reference: "name" or "owner~name"
Owner string // owner's username
Name string // calendar's own name (unqualified)
Color string
Writable bool
}
// listCalendarEntries returns every calendar visible to username: their
// own calendars (always writable) plus any calendars shared with them
// (writable only if the share grants write permission).
func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) {
var entries []calendarEntry
own, err := s.dbase.ListCalendars(username)
if err != nil {
return nil, err
}
for _, c := range own {
entries = append(entries, calendarEntry{
Ref: c.Name, Owner: username, Name: c.Name, Color: c.Color, Writable: true,
})
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.CalendarsHome(username, summaries).Render(context.Background(), w)
shared, err := s.dbase.CalendarsSharedWith(username)
if err != nil {
return nil, err
}
for _, sh := range shared {
color, err := s.dbase.GetCalendarColor(sh.Owner, sh.CalendarName)
if err != nil {
color = ""
}
entries = append(entries, calendarEntry{
Ref: sharedCalRef(sh.Owner, sh.CalendarName),
Owner: sh.Owner,
Name: sh.CalendarName,
Color: color,
Writable: sh.Permission == db.PermWrite,
})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Owner != entries[j].Owner {
return entries[i].Owner < entries[j].Owner
}
return entries[i].Name < entries[j].Name
})
return entries, nil
}
func (s *Server) handleCalendarMonth(w http.ResponseWriter, r *http.Request) {
@@ -86,24 +169,12 @@ func (s *Server) handleCalendarMonth(w http.ResponseWriter, r *http.Request) {
return
}
username := userFromContext(r.Context())
cal := r.PathValue("cal")
ok, err := s.ownsCalendar(username, cal)
if err != nil {
s.logger.Error("checking calendar ownership", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !ok {
http.NotFound(w, r)
return
}
year, month := parseYearMonth(r)
color, _ := s.dbase.GetCalendarColor(username, cal)
data, err := s.buildMonthView(username, cal, color, year, month)
data, err := s.buildMonthView(username, year, month)
if err != nil {
s.logger.Error("building month view", "calendar", cal, "error", err)
s.logger.Error("building month view", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
@@ -126,12 +197,18 @@ func parseYearMonth(r *http.Request) (year int, month time.Month) {
return year, month
}
// buildMonthView loads every event in cal, then places each occurrence's
// days onto a 6-week grid covering the requested month (plus enough
// leading/trailing days of neighboring months to fill full weeks).
// Recurring events (RRULE) are not expanded — only an event's own
// DTSTART/DTEND span is considered.
func (s *Server) buildMonthView(username, cal, color string, year int, month time.Month) (templates.MonthViewData, error) {
// buildMonthView loads every event from every calendar visible to
// username (own + shared), then places each occurrence's days onto a
// 6-week grid covering the requested month (plus enough leading/trailing
// days of neighboring months to fill full weeks). Recurring events
// (RRULE) are not expanded — only an event's own DTSTART/DTEND span is
// considered.
func (s *Server) buildMonthView(username string, year int, month time.Month) (templates.MonthViewData, error) {
entries, err := s.listCalendarEntries(username)
if err != nil {
return templates.MonthViewData{}, err
}
loc := time.Local
first := time.Date(year, month, 1, 0, 0, 0, 0, loc)
// Monday-first offset: time.Weekday has Sunday=0..Saturday=6.
@@ -156,50 +233,64 @@ func (s *Server) buildMonthView(username, cal, color string, year int, month tim
}
gridEnd := gridStart.AddDate(0, 0, totalDays-1)
ids, err := s.store.ListObjects(username, "cal-"+cal)
if err != nil {
return templates.MonthViewData{}, err
}
for _, id := range ids {
data, err := s.store.GetObject(username, "cal-"+cal, id)
hasWritable := false
var calSummaries []templates.CalendarSummary
for _, entry := range entries {
if entry.Writable {
hasWritable = true
}
calSummaries = append(calSummaries, templates.CalendarSummary{
Ref: entry.Ref, Name: entry.Name, Owner: entry.Owner, Color: entry.Color,
Shared: entry.Owner != username, Writable: entry.Writable,
})
ids, err := s.store.ListObjects(entry.Owner, "cal-"+entry.Name)
if err != nil {
continue
}
form, err := eventFormFromICS(id, data)
if err != nil {
s.logger.Warn("decoding calendar object", "calendar", cal, "id", id, "error", err)
continue
}
startDay, endDay, err := eventDayRange(form, loc)
if err != nil {
continue
}
if endDay.Before(gridStart) || startDay.After(gridEnd) {
continue
}
if startDay.Before(gridStart) {
startDay = gridStart
}
if endDay.After(gridEnd) {
endDay = gridEnd
}
// Cap the number of days a single event can add to the grid, in
// case of malformed data with a wildly distant DTEND.
for d, n := startDay, 0; !d.After(endDay) && n < totalDays; d, n = d.AddDate(0, 0, 1), n+1 {
idx, ok := dayIndex[d.Format(dateLayout)]
if !ok {
for _, id := range ids {
data, err := s.store.GetObject(entry.Owner, "cal-"+entry.Name, id)
if err != nil {
continue
}
timeText := ""
if !form.AllDay {
timeText = form.StartTime
form, err := eventFormFromICS(id, data)
if err != nil {
s.logger.Warn("decoding calendar object", "calendar", entry.Name, "id", id, "error", err)
continue
}
startDay, endDay, err := eventDayRange(form, loc)
if err != nil {
continue
}
if endDay.Before(gridStart) || startDay.After(gridEnd) {
continue
}
if startDay.Before(gridStart) {
startDay = gridStart
}
if endDay.After(gridEnd) {
endDay = gridEnd
}
// Cap the number of days a single event can add to the grid,
// in case of malformed data with a wildly distant DTEND.
for d, n := startDay, 0; !d.After(endDay) && n < totalDays; d, n = d.AddDate(0, 0, 1), n+1 {
idx, ok := dayIndex[d.Format(dateLayout)]
if !ok {
continue
}
timeText := ""
if !form.AllDay {
timeText = form.StartTime
}
days[idx].Events = append(days[idx].Events, templates.EventSummary{
ID: id,
CalRef: entry.Ref,
Color: entry.Color,
Summary: form.Summary,
TimeText: timeText,
AllDay: form.AllDay,
})
}
days[idx].Events = append(days[idx].Events, templates.EventSummary{
ID: id,
Summary: form.Summary,
TimeText: timeText,
AllDay: form.AllDay,
})
}
}
@@ -223,17 +314,17 @@ func (s *Server) buildMonthView(username, cal, color string, year int, month tim
prevMonth := first.AddDate(0, -1, 0)
nextMonth := first.AddDate(0, 1, 0)
monthURL := func(y int, m time.Month) string {
return fmt.Sprintf("/web/calendar/%s?year=%d&month=%d", cal, y, int(m))
return fmt.Sprintf("/web/calendar?year=%d&month=%d", y, int(m))
}
return templates.MonthViewData{
Cal: cal,
Color: color,
Calendars: calSummaries,
HasWritable: hasWritable,
MonthLabel: first.Format("January 2006"),
Weeks: weeks,
PrevMonthURL: monthURL(prevMonth.Year(), prevMonth.Month()),
NextMonthURL: monthURL(nextMonth.Year(), nextMonth.Month()),
TodayURL: "/web/calendar/" + cal,
TodayURL: "/web/calendar",
}, nil
}
@@ -260,45 +351,85 @@ func eventDayRange(form templates.EventFormData, loc *time.Location) (start, end
func (s *Server) handleEventNew(w http.ResponseWriter, r *http.Request) {
username := userFromContext(r.Context())
cal := r.PathValue("cal")
ok, err := s.ownsCalendar(username, cal)
entries, err := s.listCalendarEntries(username)
if err != nil {
s.logger.Error("checking calendar ownership", "error", err)
s.logger.Error("listing calendars", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !ok {
http.NotFound(w, r)
var options []templates.CalendarOption
for _, e := range entries {
if !e.Writable {
continue
}
label := e.Name
if e.Owner != username {
label = e.Name + " (" + e.Owner + ")"
}
options = append(options, templates.CalendarOption{Ref: e.Ref, Label: label})
}
if len(options) == 0 {
http.Error(w, "you don't have any calendar you can add events to", http.StatusConflict)
return
}
switch r.Method {
case http.MethodGet:
form := newEventFormDefaults(cal, r.URL.Query().Get("date"))
selected := r.URL.Query().Get("calendar")
if selected == "" {
selected = options[0].Ref
}
form := newEventFormDefaults(selected, r.URL.Query().Get("date"))
form.Calendars = options
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EventForm(username, form, "").Render(context.Background(), w)
case http.MethodPost:
s.saveEventFromForm(w, r, username, cal, "")
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
ref := strings.TrimSpace(r.PostForm.Get("calendar"))
owner, name, err := s.resolveCalRef(username, ref, true)
if err != nil {
s.handleCalRefError(w, r, err)
return
}
s.saveEventFromForm(w, r, owner, name, ref, "", options)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// handleCalRefError translates a resolveCalRef error into the
// appropriate HTTP response.
func (s *Server) handleCalRefError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, errCalendarNotFound):
http.NotFound(w, r)
case errors.Is(err, errCalendarReadOnly):
http.Error(w, "this calendar is shared read-only", http.StatusForbidden)
default:
s.logger.Error("resolving calendar reference", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
// newEventFormDefaults builds the initial form values for a brand new
// event. If dateParam is a valid "YYYY-MM-DD" (e.g. from clicking a day
// cell in the month grid), the event defaults to an all-day event on that
// date; otherwise it defaults to a one-hour timed event starting at the
// next full hour today.
func newEventFormDefaults(cal, dateParam string) templates.EventFormData {
func newEventFormDefaults(calRef, dateParam string) templates.EventFormData {
if _, err := time.Parse(dateLayout, dateParam); err == nil {
return templates.EventFormData{Cal: cal, AllDay: true, StartDate: dateParam, EndDate: dateParam}
return templates.EventFormData{CalRef: calRef, AllDay: true, StartDate: dateParam, EndDate: dateParam}
}
now := time.Now()
start := now.Truncate(time.Hour).Add(time.Hour)
end := start.Add(time.Hour)
return templates.EventFormData{
Cal: cal,
CalRef: calRef,
StartDate: start.Format(dateLayout),
StartTime: start.Format(timeLayout),
EndDate: end.Format(dateLayout),
@@ -308,22 +439,22 @@ func newEventFormDefaults(cal, dateParam string) templates.EventFormData {
func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) {
username := userFromContext(r.Context())
cal := r.PathValue("cal")
ref := r.PathValue("ref")
id := r.PathValue("id")
ok, err := s.ownsCalendar(username, cal)
owner, name, err := s.resolveCalRef(username, ref, false)
if err != nil {
s.logger.Error("checking calendar ownership", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
s.handleCalRefError(w, r, err)
return
}
if !ok || !eventIDRe.MatchString(id) {
if !eventIDRe.MatchString(id) {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
data, err := s.store.GetObject(username, "cal-"+cal, id)
data, err := s.store.GetObject(owner, "cal-"+name, id)
if err != nil {
http.NotFound(w, r)
return
@@ -334,11 +465,28 @@ func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
form.Cal = cal
form.CalRef = ref
label := name
if owner != username {
label = name + " (" + owner + ")"
}
form.CalendarLabel = label
form.Writable = owner == username
if !form.Writable {
if share, err := s.dbase.CalendarShareFor(owner, name, username); err == nil {
form.Writable = share.Permission == db.PermWrite
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EventForm(username, form, "").Render(context.Background(), w)
case http.MethodPost:
s.saveEventFromForm(w, r, username, cal, id)
if owner2, _, err := s.resolveCalRef(username, ref, true); err != nil {
s.handleCalRefError(w, r, err)
return
} else {
owner = owner2
}
s.saveEventFromForm(w, r, owner, name, ref, id, nil)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -352,23 +500,23 @@ func (s *Server) handleEventDelete(w http.ResponseWriter, r *http.Request) {
return
}
username := userFromContext(r.Context())
cal := r.PathValue("cal")
ref := r.PathValue("ref")
id := r.PathValue("id")
ok, err := s.ownsCalendar(username, cal)
owner, name, err := s.resolveCalRef(username, ref, true)
if err != nil {
s.logger.Error("checking calendar ownership", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
s.handleCalRefError(w, r, err)
return
}
if !ok || !eventIDRe.MatchString(id) {
if !eventIDRe.MatchString(id) {
http.NotFound(w, r)
return
}
if err := s.store.DeleteObject(username, "cal-"+cal, id); err != nil {
if err := s.store.DeleteObject(owner, "cal-"+name, id); err != nil {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/web/calendar/"+cal, http.StatusSeeOther)
http.Redirect(w, r, "/web/calendar", http.StatusSeeOther)
}
// eventFormInput holds the parsed, not-yet-validated values submitted by
@@ -398,8 +546,13 @@ func parseEventForm(r *http.Request) eventFormInput {
}
// saveEventFromForm handles both event creation (id == "") and editing
// (id preserves the existing event's UID).
func (s *Server) saveEventFromForm(w http.ResponseWriter, r *http.Request, username, cal, id string) {
// (id preserves the existing event's UID). owner/name identify the
// resolved (and already permission-checked) storage location; ref is the
// calendar reference used for redirect/re-render URLs; options is only
// non-nil when creating a new event (to re-render the calendar <select>
// on a validation error).
func (s *Server) saveEventFromForm(w http.ResponseWriter, r *http.Request, owner, name, ref, id string, options []templates.CalendarOption) {
username := userFromContext(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
@@ -408,8 +561,16 @@ func (s *Server) saveEventFromForm(w http.ResponseWriter, r *http.Request, usern
reRender := func(errMsg string) {
form := templates.EventFormData{
Cal: cal, ID: id, Summary: in.Summary, Description: in.Description, Location: in.Location,
CalRef: ref, ID: id, Summary: in.Summary, Description: in.Description, Location: in.Location,
AllDay: in.AllDay, StartDate: in.StartDate, StartTime: in.StartTime, EndDate: in.EndDate, EndTime: in.EndTime,
Calendars: options,
}
if id != "" {
label := name
if owner != username {
label = name + " (" + owner + ")"
}
form.CalendarLabel = label
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EventForm(username, form, errMsg).Render(context.Background(), w)
@@ -437,7 +598,7 @@ func (s *Server) saveEventFromForm(w http.ResponseWriter, r *http.Request, usern
id = newID
uid = strings.TrimSuffix(id, ".ics")
} else {
data, err := s.store.GetObject(username, "cal-"+cal, id)
data, err := s.store.GetObject(owner, "cal-"+name, id)
if err != nil {
http.NotFound(w, r)
return
@@ -459,13 +620,13 @@ func (s *Server) saveEventFromForm(w http.ResponseWriter, r *http.Request, usern
}
newCal := buildEventCalendar(uid, in, start, end)
if err := s.saveEvent(username, cal, id, newCal); err != nil {
if err := s.saveEvent(owner, name, id, newCal); err != nil {
s.logger.Error("saving event", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/web/calendar/"+cal, http.StatusSeeOther)
http.Redirect(w, r, "/web/calendar", http.StatusSeeOther)
}
// computeEventTimes validates and converts the submitted form fields into
@@ -545,13 +706,13 @@ func buildEventCalendar(uid string, in eventFormInput, start, end time.Time) *ic
return cal
}
func (s *Server) saveEvent(username, cal, id string, calendar *ical.Calendar) error {
func (s *Server) saveEvent(owner, cal, id string, calendar *ical.Calendar) error {
var buf strings.Builder
enc := ical.NewEncoder(&buf)
if err := enc.Encode(calendar); err != nil {
return fmt.Errorf("encoding ical: %w", err)
}
return s.store.PutObject(username, "cal-"+cal, id, []byte(buf.String()))
return s.store.PutObject(owner, "cal-"+cal, id, []byte(buf.String()))
}
// eventFormFromICS decodes a single-VEVENT .ics object into the shared
@@ -626,3 +787,160 @@ func eventFormFromICS(id string, data []byte) (templates.EventFormData, error) {
return form, nil
}
// handleCalendarExportOne exports a single event as a standalone .ics
// file.
func (s *Server) handleEventExportOne(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
ref := r.PathValue("ref")
id := r.PathValue("id")
owner, name, err := s.resolveCalRef(username, ref, false)
if err != nil {
s.handleCalRefError(w, r, err)
return
}
if !eventIDRe.MatchString(id) {
http.NotFound(w, r)
return
}
data, err := s.store.GetObject(owner, "cal-"+name, id)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+id+`"`)
_, _ = w.Write(data)
}
// handleCalendarExportAll exports every event in a calendar as one
// concatenated .ics file.
func (s *Server) handleCalendarExportAll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
ref := r.PathValue("ref")
owner, name, err := s.resolveCalRef(username, ref, false)
if err != nil {
s.handleCalRefError(w, r, err)
return
}
ids, err := s.store.ListObjects(owner, "cal-"+name)
if err != nil {
s.logger.Error("listing calendar objects", "calendar", name, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`.ics"`)
for _, id := range ids {
data, err := s.store.GetObject(owner, "cal-"+name, id)
if err != nil {
continue
}
// Each stored object is already a complete, self-contained
// VCALENDAR document, so concatenating their raw bytes produces a
// file most clients will happily re-import (though it is not a
// single strictly-valid multi-event VCALENDAR document).
_, _ = w.Write(data)
}
}
// handleCalendarImport accepts an uploaded .ics file that may contain one
// or more VCALENDAR blocks, each possibly holding multiple VEVENTs, and
// stores each VEVENT as its own object (mirroring how the CalDAV backend
// stores one event per object).
func (s *Server) handleCalendarImport(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())
ref := r.PathValue("ref")
owner, name, err := s.resolveCalRef(username, ref, true)
if err != nil {
s.handleCalRefError(w, r, err)
return
}
if err := r.ParseMultipartForm(maxUploadMemory); err != nil {
http.Error(w, "invalid upload", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "no file provided", http.StatusBadRequest)
return
}
defer file.Close()
body, err := io.ReadAll(file)
if err != nil {
s.logger.Error("reading import file", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
imported := 0
dec := ical.NewDecoder(bytes.NewReader(body))
for {
srcCal, err := dec.Decode()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
s.logger.Warn("stopping ics import on parse error", "calendar", name, "error", err)
break
}
version := "2.0"
if p := srcCal.Props.Get(ical.PropVersion); p != nil && p.Value != "" {
version = p.Value
}
prodID := "-//nidus//web calendar//EN"
if p := srcCal.Props.Get(ical.PropProductID); p != nil && p.Value != "" {
prodID = p.Value
}
var timezones []*ical.Component
for _, child := range srcCal.Children {
if child.Name == ical.CompTimezone {
timezones = append(timezones, child)
}
}
for _, ev := range srcCal.Events() {
id, err := newEventID()
if err != nil {
s.logger.Error("generating event id", "error", err)
continue
}
out := ical.NewCalendar()
out.Props.SetText(ical.PropVersion, version)
out.Props.SetText(ical.PropProductID, prodID)
out.Children = append(out.Children, timezones...)
out.Children = append(out.Children, ev.Component)
if err := s.saveEvent(owner, name, id, out); err != nil {
s.logger.Warn("saving imported event", "error", err)
continue
}
imported++
}
}
http.Redirect(w, r, "/web/calendar?imported="+strconv.Itoa(imported), http.StatusSeeOther)
}