1361 lines
43 KiB
Go
1361 lines
43 KiB
Go
package web
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.arnef.de/arnef/nidus/internal/birthdays"
|
||
"git.arnef.de/arnef/nidus/internal/db"
|
||
"git.arnef.de/arnef/nidus/internal/icalfix"
|
||
"git.arnef.de/arnef/nidus/internal/icssub"
|
||
"git.arnef.de/arnef/nidus/internal/store"
|
||
"git.arnef.de/arnef/nidus/internal/web/templates"
|
||
ical "github.com/emersion/go-ical"
|
||
)
|
||
|
||
// eventIDRe validates an event's object ID as it appears in a URL path
|
||
// segment: a filename like "3f9a2b8c1d4e5f60ab12cd34ef56ab78.ics".
|
||
var eventIDRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,128}\.ics$`)
|
||
|
||
// dateLayout/timeLayout are the HTML date/time input formats used
|
||
// throughout the event form.
|
||
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).
|
||
func newEventID() (string, error) {
|
||
buf := make([]byte, 16)
|
||
if _, err := rand.Read(buf); err != nil {
|
||
return "", err
|
||
}
|
||
return hex.EncodeToString(buf) + ".ics", nil
|
||
}
|
||
|
||
// 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
|
||
}
|
||
cals, err := s.dbase.ListCalendars(username)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
for _, c := range cals {
|
||
if c.Name == cal {
|
||
return true, nil
|
||
}
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
// 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 ref == birthdaysCalRef || strings.HasPrefix(ref, icsRefPrefix) {
|
||
// Both the birthdays calendar and ICS subscriptions are
|
||
// virtual/computed, not backed by any stored calendar object —
|
||
// there's nothing to resolve to.
|
||
return "", "", errCalendarNotFound
|
||
}
|
||
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
|
||
}
|
||
|
||
ok, err := s.ownsCalendar(username, ref)
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
if !ok {
|
||
return "", "", errCalendarNotFound
|
||
}
|
||
return username, ref, nil
|
||
}
|
||
|
||
// 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
|
||
Virtual bool // true for computed calendars (e.g. birthdays) with no backing store objects
|
||
ICSURL string // set only for ICS-subscription entries (Ref has icsRefPrefix); the remote URL to fetch events from
|
||
}
|
||
|
||
// birthdaysCalRef is the fixed reference for the synthetic "Birthdays"
|
||
// calendar. It deliberately can't collide with a real calendar's ref: "@"
|
||
// is not in resourceNameRe's character class (so it can't be a bare own
|
||
// calendar name) and it contains no calRefSep ("~") (so it can't be
|
||
// mistaken for an "owner~name" shared-calendar ref either).
|
||
const birthdaysCalRef = "@birthdays"
|
||
|
||
// icsRefPrefix marks a calendarEntry's Ref as referring to one of
|
||
// username's own ICS/webcal subscriptions (see internal/db/ics.go). Like
|
||
// "@" for the birthdays calendar, "!" isn't in resourceNameRe's character
|
||
// class and can't appear in a calRefSep-joined shared-calendar ref either,
|
||
// so "!<name>" can't collide with any other kind of ref.
|
||
const icsRefPrefix = "!"
|
||
|
||
// icsCalRef builds the reference string for one of username's own ICS
|
||
// subscriptions named name.
|
||
func icsCalRef(name string) string {
|
||
return icsRefPrefix + name
|
||
}
|
||
|
||
// defaultBirthdayColor is the display color for the virtual birthdays
|
||
// calendar used until the user picks their own from the dashboard (a
|
||
// pink, distinct from typical user-picked calendar colors).
|
||
const defaultBirthdayColor = "#ec4899"
|
||
|
||
// birthdayCalendarColor returns username's chosen color for the virtual
|
||
// birthdays calendar, falling back to defaultBirthdayColor if unset.
|
||
func (s *Server) birthdayCalendarColor(username string) string {
|
||
color, err := s.dbase.GetBirthdayCalendarColor(username)
|
||
if err != nil || color == "" {
|
||
return defaultBirthdayColor
|
||
}
|
||
return color
|
||
}
|
||
|
||
// listCalendarEntries returns every calendar visible to username: the
|
||
// synthetic birthdays calendar, their own calendars (always writable),
|
||
// any calendars shared with them (writable only if the share grants write
|
||
// permission), and their own read-only ICS/webcal subscriptions.
|
||
func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) {
|
||
entries := []calendarEntry{
|
||
{Ref: birthdaysCalRef, Owner: username, Name: "Birthdays", Color: s.birthdayCalendarColor(username), Writable: false, Virtual: true},
|
||
}
|
||
|
||
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,
|
||
})
|
||
}
|
||
|
||
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,
|
||
})
|
||
}
|
||
|
||
subs, err := s.dbase.ListICSSubscriptions(username)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, sub := range subs {
|
||
entries = append(entries, calendarEntry{
|
||
Ref: icsCalRef(sub.Name), Owner: username, Name: sub.Name, Color: sub.Color,
|
||
Writable: false, Virtual: true, ICSURL: sub.URL,
|
||
})
|
||
}
|
||
|
||
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) {
|
||
if r.Method != http.MethodGet {
|
||
w.Header().Set("Allow", "GET")
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
username := userFromContext(r.Context())
|
||
|
||
year, month := parseYearMonth(r)
|
||
|
||
data, err := s.buildMonthView(username, year, month)
|
||
if err != nil {
|
||
s.logger.Error("building month view", "error", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
_ = templates.MonthView(username, data).Render(context.Background(), w)
|
||
}
|
||
|
||
// parseYearMonth reads ?year=&month= query params, defaulting to the
|
||
// current month if absent or invalid.
|
||
func parseYearMonth(r *http.Request) (year int, month time.Month) {
|
||
now := time.Now()
|
||
year, month = now.Year(), now.Month()
|
||
if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil && y >= 1 && y <= 9999 {
|
||
year = y
|
||
}
|
||
if m, err := strconv.Atoi(r.URL.Query().Get("month")); err == nil && m >= 1 && m <= 12 {
|
||
month = time.Month(m)
|
||
}
|
||
return year, month
|
||
}
|
||
|
||
// handleCalendarWeek renders the week view, mirroring
|
||
// handleCalendarMonth but for a single 7-day week.
|
||
func (s *Server) handleCalendarWeek(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())
|
||
|
||
weekStart := parseWeekStart(r)
|
||
|
||
data, err := s.buildWeekView(username, weekStart)
|
||
if err != nil {
|
||
s.logger.Error("building week view", "error", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
_ = templates.WeekView(username, data).Render(context.Background(), w)
|
||
}
|
||
|
||
// parseWeekStart reads ?date=YYYY-MM-DD and returns the Monday of that
|
||
// date's week, defaulting to the current week if the parameter is
|
||
// absent or invalid.
|
||
func parseWeekStart(r *http.Request) time.Time {
|
||
if v := r.URL.Query().Get("date"); v != "" {
|
||
if d, err := time.ParseInLocation(dateLayout, v, time.Local); err == nil {
|
||
return mondayOf(d)
|
||
}
|
||
}
|
||
return mondayOf(time.Now())
|
||
}
|
||
|
||
// 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) {
|
||
loc := time.Local
|
||
first := time.Date(year, month, 1, 0, 0, 0, 0, loc)
|
||
// Monday-first offset: time.Weekday has Sunday=0..Saturday=6.
|
||
offset := (int(first.Weekday()) + 6) % 7
|
||
gridStart := first.AddDate(0, 0, -offset)
|
||
const totalDays = 42 // 6 weeks
|
||
|
||
today := time.Now().In(loc).Format(dateLayout)
|
||
|
||
days := make([]templates.MonthDay, totalDays)
|
||
for i := 0; i < totalDays; i++ {
|
||
d := gridStart.AddDate(0, 0, i)
|
||
dateStr := d.Format(dateLayout)
|
||
days[i] = templates.MonthDay{
|
||
Date: dateStr,
|
||
Day: d.Day(),
|
||
InMonth: d.Month() == month,
|
||
IsToday: dateStr == today,
|
||
}
|
||
}
|
||
gridEnd := gridStart.AddDate(0, 0, totalDays-1)
|
||
|
||
eventsByDay, calSummaries, hasWritable, err := s.collectCalendarEvents(username, gridStart, gridEnd, loc)
|
||
if err != nil {
|
||
return templates.MonthViewData{}, err
|
||
}
|
||
for i := range days {
|
||
days[i].Events = sortedDayEvents(eventsByDay[days[i].Date])
|
||
}
|
||
|
||
weeks := make([][]templates.MonthDay, 0, 6)
|
||
for i := 0; i < totalDays; i += 7 {
|
||
weeks = append(weeks, days[i:i+7])
|
||
}
|
||
|
||
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?year=%d&month=%d", y, int(m))
|
||
}
|
||
|
||
return templates.MonthViewData{
|
||
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",
|
||
WeekURL: "/web/calendar/week",
|
||
}, nil
|
||
}
|
||
|
||
// buildWeekView loads every event from every calendar visible to
|
||
// username (own + shared) that falls within the 7-day week starting on
|
||
// weekStart (which must already be a Monday — see mondayOf), placing
|
||
// each occurrence's days onto a single 7-column row.
|
||
func (s *Server) buildWeekView(username string, weekStart time.Time) (templates.WeekViewData, error) {
|
||
loc := time.Local
|
||
gridEnd := weekStart.AddDate(0, 0, 6)
|
||
today := time.Now().In(loc).Format(dateLayout)
|
||
|
||
eventsByDay, calSummaries, hasWritable, err := s.collectCalendarEvents(username, weekStart, gridEnd, loc)
|
||
if err != nil {
|
||
return templates.WeekViewData{}, err
|
||
}
|
||
|
||
days := make([]templates.WeekDay, 7)
|
||
for i := 0; i < 7; i++ {
|
||
d := weekStart.AddDate(0, 0, i)
|
||
dateStr := d.Format(dateLayout)
|
||
days[i] = templates.WeekDay{
|
||
Date: dateStr,
|
||
Weekday: d.Format("Monday"),
|
||
Day: d.Day(),
|
||
Month: d.Format("Jan"),
|
||
IsToday: dateStr == today,
|
||
Events: sortedDayEvents(eventsByDay[dateStr]),
|
||
}
|
||
}
|
||
|
||
prevWeek := weekStart.AddDate(0, 0, -7)
|
||
nextWeek := weekStart.AddDate(0, 0, 7)
|
||
weekURL := func(t time.Time) string {
|
||
return "/web/calendar/week?date=" + t.Format(dateLayout)
|
||
}
|
||
|
||
rangeLabel := fmt.Sprintf("%s – %s", weekStart.Format("Jan 2"), gridEnd.Format("Jan 2, 2006"))
|
||
|
||
return templates.WeekViewData{
|
||
Calendars: calSummaries,
|
||
HasWritable: hasWritable,
|
||
RangeLabel: rangeLabel,
|
||
Days: days,
|
||
PrevWeekURL: weekURL(prevWeek),
|
||
NextWeekURL: weekURL(nextWeek),
|
||
TodayURL: "/web/calendar/week",
|
||
MonthURL: "/web/calendar",
|
||
}, nil
|
||
}
|
||
|
||
// sortedDayEvents returns evs (which may be nil) sorted with all-day
|
||
// events first, then timed events by start time — the stable, readable
|
||
// order used by both the month and week grids.
|
||
func sortedDayEvents(evs []templates.EventSummary) []templates.EventSummary {
|
||
sort.SliceStable(evs, func(a, b int) bool {
|
||
if evs[a].AllDay != evs[b].AllDay {
|
||
return evs[a].AllDay
|
||
}
|
||
return evs[a].TimeText < evs[b].TimeText
|
||
})
|
||
return evs
|
||
}
|
||
|
||
// collectCalendarEvents gathers every event, across every calendar
|
||
// visible to username (own, shared, birthdays, ICS subscriptions), whose
|
||
// day-span overlaps [gridStart, gridEnd] (inclusive, in loc), keyed by
|
||
// day (dateLayout) so callers can place them into either a month or a
|
||
// week grid. It also returns the calendar summary list (used for the
|
||
// legend/sidebar) and whether any calendar is writable (for "new event"
|
||
// links).
|
||
func (s *Server) collectCalendarEvents(username string, gridStart, gridEnd time.Time, loc *time.Location) (eventsByDay map[string][]templates.EventSummary, calSummaries []templates.CalendarSummary, hasWritable bool, err error) {
|
||
entries, err := s.listCalendarEntries(username)
|
||
if err != nil {
|
||
return nil, nil, false, err
|
||
}
|
||
|
||
// Cap how many days any single event can add, in case of malformed
|
||
// data with a wildly distant DTEND.
|
||
totalDays := int(gridEnd.Sub(gridStart).Hours()/24) + 1
|
||
|
||
dayIndex := make(map[string]int, totalDays)
|
||
days := make([]templates.MonthDay, totalDays)
|
||
for i := 0; i < totalDays; i++ {
|
||
d := gridStart.AddDate(0, 0, i)
|
||
dateStr := d.Format(dateLayout)
|
||
days[i] = templates.MonthDay{Date: dateStr}
|
||
dayIndex[dateStr] = i
|
||
}
|
||
|
||
eventsByDay = make(map[string][]templates.EventSummary)
|
||
for _, entry := range entries {
|
||
if entry.Writable {
|
||
hasWritable = true
|
||
}
|
||
calSummaries = append(calSummaries, templates.CalendarSummary{
|
||
Ref: entry.Ref, Name: entry.Name, Owner: s.dbase.DisplayName(entry.Owner), Color: entry.Color,
|
||
Shared: entry.Owner != username, Writable: entry.Writable, Virtual: entry.Virtual,
|
||
})
|
||
|
||
if entry.Ref == birthdaysCalRef {
|
||
if err := s.addBirthdayEvents(username, entry, gridStart, gridEnd, dayIndex, days); err != nil {
|
||
s.logger.Warn("computing birthday events", "error", err)
|
||
}
|
||
continue
|
||
}
|
||
|
||
if strings.HasPrefix(entry.Ref, icsRefPrefix) {
|
||
if err := s.addICSEvents(entry, gridStart, gridEnd, loc, dayIndex, days); err != nil {
|
||
s.logger.Warn("fetching ics subscription events", "calendar", entry.Name, "error", err)
|
||
}
|
||
continue
|
||
}
|
||
|
||
ids, err := s.store.ListObjects(entry.Owner, "cal-"+entry.Name)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
for _, id := range ids {
|
||
data, err := s.store.GetObject(entry.Owner, "cal-"+entry.Name, id)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
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
|
||
}
|
||
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,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
for _, d := range days {
|
||
if len(d.Events) > 0 {
|
||
eventsByDay[d.Date] = d.Events
|
||
}
|
||
}
|
||
return eventsByDay, calSummaries, hasWritable, nil
|
||
}
|
||
|
||
// mondayOf returns the Monday (in loc, at midnight) of the week
|
||
// containing t.
|
||
func mondayOf(t time.Time) time.Time {
|
||
// time.Weekday has Sunday=0..Saturday=6; convert to a Monday-first
|
||
// offset so Monday itself maps to 0.
|
||
offset := (int(t.Weekday()) + 6) % 7
|
||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, -offset)
|
||
}
|
||
|
||
// eventDayRange returns the inclusive [start, end] calendar-day span an
|
||
// event occupies, in loc, for placing it on the month grid.
|
||
func eventDayRange(form templates.EventFormData, loc *time.Location) (start, end time.Time, err error) {
|
||
start, err = time.ParseInLocation(dateLayout, form.StartDate, loc)
|
||
if err != nil {
|
||
return time.Time{}, time.Time{}, err
|
||
}
|
||
endDate := form.EndDate
|
||
if endDate == "" {
|
||
endDate = form.StartDate
|
||
}
|
||
end, err = time.ParseInLocation(dateLayout, endDate, loc)
|
||
if err != nil {
|
||
return time.Time{}, time.Time{}, err
|
||
}
|
||
if end.Before(start) {
|
||
end = start
|
||
}
|
||
return start, end, nil
|
||
}
|
||
|
||
func (s *Server) handleEventNew(w http.ResponseWriter, r *http.Request) {
|
||
username := userFromContext(r.Context())
|
||
|
||
entries, err := s.listCalendarEntries(username)
|
||
if err != nil {
|
||
s.logger.Error("listing calendars", "error", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
var options []templates.CalendarOption
|
||
for _, e := range entries {
|
||
if !e.Writable {
|
||
continue
|
||
}
|
||
label := e.Name
|
||
if e.Owner != username {
|
||
label = e.Name + " (" + s.dbase.DisplayName(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:
|
||
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:
|
||
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(calRef, dateParam string) templates.EventFormData {
|
||
if _, err := time.Parse(dateLayout, dateParam); err == nil {
|
||
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{
|
||
CalRef: calRef,
|
||
StartDate: start.Format(dateLayout),
|
||
StartTime: start.Format(timeLayout),
|
||
EndDate: end.Format(dateLayout),
|
||
EndTime: end.Format(timeLayout),
|
||
}
|
||
}
|
||
|
||
// handleEventView renders the read-only detail view for a single event. It
|
||
// is the landing page when a user clicks an event in the month/week grid;
|
||
// writable calendars link from here to the edit page.
|
||
func (s *Server) handleEventView(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")
|
||
|
||
// For ICS subscriptions, ref is "!<name>" and id is the stable
|
||
// icssub.EventID (already ".ics"-suffixed). For regular calendars,
|
||
// ref is a bare name or "owner~name" and id is a "<hex>.ics" filename
|
||
// from the eventIDRe character set. Both shapes share the same
|
||
// "hex/alnum .ics" shape, so we only need the regex check.
|
||
if !eventIDRe.MatchString(id) {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
color, form, err := s.eventForDisplay(username, ref, id)
|
||
if errors.Is(err, store.ErrNotFound) || errors.Is(err, errCalendarNotFound) {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if err != nil {
|
||
s.handleCalRefError(w, r, err)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
_ = templates.EventDetail(username, templates.EventDetailData{Form: form, Color: color}).Render(context.Background(), w)
|
||
}
|
||
|
||
// eventForDisplay resolves a calendar reference for read access, loads and
|
||
// decodes the event with the given id, populates the form's display fields
|
||
// (CalRef, CalendarLabel, Writable), and returns the calendar's color for
|
||
// the detail view's header dot. It handles both ordinary (stored) events
|
||
// and ICS-subscription events (ref "!<name>").
|
||
func (s *Server) eventForDisplay(username, ref, id string) (color string, form templates.EventFormData, err error) {
|
||
if strings.HasPrefix(ref, icsRefPrefix) {
|
||
return s.icsEventForDisplay(username, ref, id)
|
||
}
|
||
owner, name, err := s.resolveCalRef(username, ref, false)
|
||
if err != nil {
|
||
return "", form, err
|
||
}
|
||
data, err := s.store.GetObject(owner, "cal-"+name, id)
|
||
if err != nil {
|
||
return "", form, err
|
||
}
|
||
form, err = eventFormFromICS(id, data)
|
||
if err != nil {
|
||
s.logger.Error("decoding event", "error", err)
|
||
return "", form, err
|
||
}
|
||
form.CalRef = ref
|
||
label := name
|
||
if owner != username {
|
||
label = name + " (" + s.dbase.DisplayName(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
|
||
}
|
||
}
|
||
color, _ = s.dbase.GetCalendarColor(owner, name)
|
||
return color, form, nil
|
||
}
|
||
|
||
// icsEventForDisplay resolves one of username's ICS subscriptions named
|
||
// ref[len(!):] and looks up the event whose icssub.EventID hashes to id.
|
||
// Returns the subscription's display color and a form with Writable=false.
|
||
func (s *Server) icsEventForDisplay(username, ref, id string) (color string, form templates.EventFormData, err error) {
|
||
name := strings.TrimPrefix(ref, icsRefPrefix)
|
||
sub, err := s.dbase.GetICSSubscription(username, name)
|
||
if err != nil {
|
||
return "", form, errCalendarNotFound
|
||
}
|
||
cal, err := s.icsCache.Get(sub.URL)
|
||
if err != nil {
|
||
return "", form, err
|
||
}
|
||
for _, ev := range cal.Events() {
|
||
if icssub.EventID(ev) != id {
|
||
continue
|
||
}
|
||
form, err = eventFormFromComponent(id, ev)
|
||
if err != nil {
|
||
return "", form, err
|
||
}
|
||
form.CalRef = ref
|
||
form.CalendarLabel = name
|
||
form.Writable = false
|
||
return sub.Color, form, nil
|
||
}
|
||
return "", form, errCalendarNotFound
|
||
}
|
||
|
||
func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) {
|
||
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
|
||
}
|
||
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
data, err := s.store.GetObject(owner, "cal-"+name, id)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
form, err := eventFormFromICS(id, data)
|
||
if err != nil {
|
||
s.logger.Error("decoding event", "error", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
form.CalRef = ref
|
||
label := name
|
||
if owner != username {
|
||
label = name + " (" + s.dbase.DisplayName(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:
|
||
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)
|
||
}
|
||
}
|
||
|
||
func (s *Server) handleEventDelete(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")
|
||
id := r.PathValue("id")
|
||
|
||
owner, name, err := s.resolveCalRef(username, ref, true)
|
||
if err != nil {
|
||
s.handleCalRefError(w, r, err)
|
||
return
|
||
}
|
||
if !eventIDRe.MatchString(id) {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if err := s.store.DeleteObject(owner, "cal-"+name, id); err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/web/calendar", http.StatusSeeOther)
|
||
}
|
||
|
||
// eventFormInput holds the parsed, not-yet-validated values submitted by
|
||
// the event form.
|
||
type eventFormInput struct {
|
||
Summary string
|
||
Description string
|
||
Location string
|
||
AllDay bool
|
||
StartDate string
|
||
StartTime string
|
||
EndDate string
|
||
EndTime string
|
||
}
|
||
|
||
func parseEventForm(r *http.Request) eventFormInput {
|
||
return eventFormInput{
|
||
Summary: strings.TrimSpace(r.PostForm.Get("summary")),
|
||
Description: strings.TrimSpace(r.PostForm.Get("description")),
|
||
Location: strings.TrimSpace(r.PostForm.Get("location")),
|
||
AllDay: r.PostForm.Get("all_day") != "",
|
||
StartDate: strings.TrimSpace(r.PostForm.Get("start_date")),
|
||
StartTime: strings.TrimSpace(r.PostForm.Get("start_time")),
|
||
EndDate: strings.TrimSpace(r.PostForm.Get("end_date")),
|
||
EndTime: strings.TrimSpace(r.PostForm.Get("end_time")),
|
||
}
|
||
}
|
||
|
||
// saveEventFromForm handles both event creation (id == "") and editing
|
||
// (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
|
||
}
|
||
in := parseEventForm(r)
|
||
|
||
reRender := func(errMsg string) {
|
||
form := templates.EventFormData{
|
||
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)
|
||
}
|
||
|
||
if in.Summary == "" {
|
||
reRender("Title is required")
|
||
return
|
||
}
|
||
|
||
start, end, err := computeEventTimes(in)
|
||
if err != nil {
|
||
reRender(err.Error())
|
||
return
|
||
}
|
||
|
||
var uid string
|
||
if id == "" {
|
||
newID, err := newEventID()
|
||
if err != nil {
|
||
s.logger.Error("generating event id", "error", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
id = newID
|
||
uid = strings.TrimSuffix(id, ".ics")
|
||
} else {
|
||
data, err := s.store.GetObject(owner, "cal-"+name, id)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
existing, err := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(data))).Decode()
|
||
if err != nil {
|
||
s.logger.Error("decoding existing event", "error", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if events := existing.Events(); len(events) > 0 {
|
||
if uidProp := events[0].Props.Get(ical.PropUID); uidProp != nil {
|
||
uid = uidProp.Value
|
||
}
|
||
}
|
||
if uid == "" {
|
||
uid = strings.TrimSuffix(id, ".ics")
|
||
}
|
||
}
|
||
|
||
newCal := buildEventCalendar(uid, in, start, end)
|
||
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", http.StatusSeeOther)
|
||
}
|
||
|
||
// computeEventTimes validates and converts the submitted form fields into
|
||
// concrete start/end instants (for timed events) or dates (for all-day
|
||
// events, where end is returned as the exclusive DTEND day).
|
||
func computeEventTimes(in eventFormInput) (start, end time.Time, err error) {
|
||
if in.StartDate == "" {
|
||
return time.Time{}, time.Time{}, fmt.Errorf("start date is required")
|
||
}
|
||
endDate := in.EndDate
|
||
if endDate == "" {
|
||
endDate = in.StartDate
|
||
}
|
||
|
||
if in.AllDay {
|
||
start, err = time.ParseInLocation(dateLayout, in.StartDate, time.Local)
|
||
if err != nil {
|
||
return time.Time{}, time.Time{}, fmt.Errorf("invalid start date")
|
||
}
|
||
end, err = time.ParseInLocation(dateLayout, endDate, time.Local)
|
||
if err != nil {
|
||
return time.Time{}, time.Time{}, fmt.Errorf("invalid end date")
|
||
}
|
||
if end.Before(start) {
|
||
return time.Time{}, time.Time{}, fmt.Errorf("end date must not be before start date")
|
||
}
|
||
// DTEND for all-day events is exclusive: the end date the user
|
||
// picks is the last day the event covers, so add one day.
|
||
end = end.AddDate(0, 0, 1)
|
||
return start, end, nil
|
||
}
|
||
|
||
if in.StartTime == "" || in.EndTime == "" {
|
||
return time.Time{}, time.Time{}, fmt.Errorf("start and end time are required for timed events")
|
||
}
|
||
start, err = time.ParseInLocation(dateLayout+"T"+timeLayout, in.StartDate+"T"+in.StartTime, time.Local)
|
||
if err != nil {
|
||
return time.Time{}, time.Time{}, fmt.Errorf("invalid start date/time")
|
||
}
|
||
end, err = time.ParseInLocation(dateLayout+"T"+timeLayout, endDate+"T"+in.EndTime, time.Local)
|
||
if err != nil {
|
||
return time.Time{}, time.Time{}, fmt.Errorf("invalid end date/time")
|
||
}
|
||
if !end.After(start) {
|
||
return time.Time{}, time.Time{}, fmt.Errorf("end must be after start")
|
||
}
|
||
return start, end, nil
|
||
}
|
||
|
||
// buildEventCalendar builds a fresh VCALENDAR/VEVENT from the submitted
|
||
// form input and resolved start/end instants.
|
||
func buildEventCalendar(uid string, in eventFormInput, start, end time.Time) *ical.Calendar {
|
||
cal := ical.NewCalendar()
|
||
cal.Props.SetText(ical.PropVersion, "2.0")
|
||
cal.Props.SetText(ical.PropProductID, "-//nidus//web calendar//EN")
|
||
|
||
ev := ical.NewEvent()
|
||
ev.Props.SetText(ical.PropUID, uid)
|
||
ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
|
||
ev.Props.SetText(ical.PropSummary, in.Summary)
|
||
if in.Description != "" {
|
||
ev.Props.SetText(ical.PropDescription, in.Description)
|
||
}
|
||
if in.Location != "" {
|
||
ev.Props.SetText(ical.PropLocation, in.Location)
|
||
}
|
||
|
||
if in.AllDay {
|
||
ev.Props.SetDate(ical.PropDateTimeStart, start)
|
||
ev.Props.SetDate(ical.PropDateTimeEnd, end)
|
||
} else {
|
||
ev.Props.SetDateTime(ical.PropDateTimeStart, start.UTC())
|
||
ev.Props.SetDateTime(ical.PropDateTimeEnd, end.UTC())
|
||
}
|
||
|
||
cal.Children = append(cal.Children, ev.Component)
|
||
return cal
|
||
}
|
||
|
||
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(owner, "cal-"+cal, id, []byte(buf.String()))
|
||
}
|
||
|
||
// eventFormFromICS decodes a single-VEVENT .ics object into the shared
|
||
// EventFormData shape used both for pre-filling the edit form and for
|
||
// placing the event on the month grid.
|
||
func eventFormFromICS(id string, data []byte) (templates.EventFormData, error) {
|
||
calendar, err := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(data))).Decode()
|
||
if err != nil {
|
||
return templates.EventFormData{}, err
|
||
}
|
||
events := calendar.Events()
|
||
if len(events) == 0 {
|
||
return templates.EventFormData{}, fmt.Errorf("no VEVENT in %s", id)
|
||
}
|
||
return eventFormFromComponent(id, events[0])
|
||
}
|
||
|
||
// eventFormFromComponent extracts an EventFormData from a single decoded
|
||
// VEVENT, shared by eventFormFromICS (one event per stored .ics object)
|
||
// and the ICS-subscription rendering path (many events per fetched
|
||
// calendar, see addICSEvents).
|
||
func eventFormFromComponent(id string, ev ical.Event) (templates.EventFormData, error) {
|
||
form := templates.EventFormData{ID: id}
|
||
if p := ev.Props.Get(ical.PropSummary); p != nil {
|
||
form.Summary = p.Value
|
||
}
|
||
if p := ev.Props.Get(ical.PropDescription); p != nil {
|
||
form.Description = p.Value
|
||
}
|
||
if p := ev.Props.Get(ical.PropLocation); p != nil {
|
||
form.Location = p.Value
|
||
}
|
||
|
||
startProp := ev.Props.Get(ical.PropDateTimeStart)
|
||
endProp := ev.Props.Get(ical.PropDateTimeEnd)
|
||
if startProp == nil {
|
||
return templates.EventFormData{}, fmt.Errorf("missing DTSTART in %s", id)
|
||
}
|
||
|
||
allDay := startProp.ValueType() == ical.ValueDate || len(startProp.Value) == 8 // RFC 5545 DATE value, e.g. "20060102"
|
||
form.AllDay = allDay
|
||
|
||
if allDay {
|
||
start, err := startProp.DateTime(time.Local)
|
||
if err != nil {
|
||
return templates.EventFormData{}, err
|
||
}
|
||
form.StartDate = start.Format(dateLayout)
|
||
end := start.AddDate(0, 0, 1)
|
||
if endProp != nil {
|
||
if e, err := endProp.DateTime(time.Local); err == nil {
|
||
end = e
|
||
}
|
||
}
|
||
// Displayed end date is the last inclusive day (DTEND is exclusive).
|
||
form.EndDate = end.AddDate(0, 0, -1).Format(dateLayout)
|
||
} else {
|
||
// go-ical's Prop.DateTime always parses a "Z"-suffixed UTC value
|
||
// as UTC regardless of the loc argument passed in, so the
|
||
// returned time must be explicitly converted to the local zone
|
||
// before formatting for display.
|
||
start, err := startProp.DateTime(time.Local)
|
||
if err != nil {
|
||
return templates.EventFormData{}, err
|
||
}
|
||
start = start.In(time.Local)
|
||
form.StartDate = start.Format(dateLayout)
|
||
form.StartTime = start.Format(timeLayout)
|
||
end := start.Add(time.Hour)
|
||
if endProp != nil {
|
||
if e, err := endProp.DateTime(time.Local); err == nil {
|
||
end = e.In(time.Local)
|
||
}
|
||
}
|
||
form.EndDate = end.Format(dateLayout)
|
||
form.EndTime = end.Format(timeLayout)
|
||
}
|
||
|
||
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(icalfix.NormalizeTimeZones(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)
|
||
}
|
||
|
||
// addBirthdayEvents places one virtual all-day event per contact
|
||
// birthday falling within [gridStart, gridEnd] into days, titled
|
||
// "🎂 Name" (or "🎂 Name (Age)" when the birth year is known). Each event
|
||
// links to the contact's edit page instead of an event edit page, since
|
||
// there is no underlying calendar object to edit.
|
||
func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridStart, gridEnd time.Time, dayIndex map[string]int, days []templates.MonthDay) error {
|
||
contacts, err := birthdays.Collect(s.store, s.dbase, username)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(contacts) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// The 42-day grid can span at most two calendar years (e.g. a
|
||
// December/January boundary), so only those years' occurrences need
|
||
// checking.
|
||
years := []int{gridStart.Year()}
|
||
if gridEnd.Year() != gridStart.Year() {
|
||
years = append(years, gridEnd.Year())
|
||
}
|
||
|
||
for _, c := range contacts {
|
||
for _, year := range years {
|
||
occurrence, ok := birthdays.OccurrenceDate(c, year, gridStart.Location())
|
||
if !ok {
|
||
continue
|
||
}
|
||
if occurrence.Before(gridStart) || occurrence.After(gridEnd) {
|
||
continue
|
||
}
|
||
idx, ok := dayIndex[occurrence.Format(dateLayout)]
|
||
if !ok {
|
||
continue
|
||
}
|
||
days[idx].Events = append(days[idx].Events, templates.EventSummary{
|
||
ID: c.Book + "/" + c.ID,
|
||
CalRef: entry.Ref,
|
||
Color: entry.Color,
|
||
Summary: birthdays.Summary(c, year),
|
||
AllDay: true,
|
||
LinkURL: "/web/contacts/" + c.Book + "/" + c.ID + "/edit",
|
||
})
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// addICSEvents fetches entry's remote ICS/webcal calendar (via s.icsCache,
|
||
// which uses stale-while-revalidate so a month-view render never blocks on
|
||
// network I/O) and places each VEVENT's occurrence onto the month grid,
|
||
// the same way a stored calendar object would be. Each event's ID is the
|
||
// stable icssub.EventID hash, which routes through the read-only detail
|
||
// page (eventForDisplay → icsEventForDisplay).
|
||
func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time, loc *time.Location, dayIndex map[string]int, days []templates.MonthDay) error {
|
||
cal, err := s.icsCache.Get(entry.ICSURL)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
const totalDays = 42
|
||
for _, ev := range cal.Events() {
|
||
id := icssub.EventID(ev)
|
||
if id == "" {
|
||
continue
|
||
}
|
||
form, err := eventFormFromComponent(id, ev)
|
||
if err != nil {
|
||
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
|
||
}
|
||
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,
|
||
})
|
||
}
|
||
}
|
||
return nil
|
||
}
|