- OpenAPI: add missing endpoints (add-from-url, subscriptions, public availability) - OpenAPI: CalendarSubscription schema, Subscriptions tag - Frontend app - Migrations: count_for_availability, subscriptions_sync, user_preferences, calendar_settings - Config, rate limit, auth, calendar, booking, ICS, availability, user service updates Made-with: Cursor
909 lines
25 KiB
Go
909 lines
25 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
ics "github.com/arran4/golang-ical"
|
|
"github.com/calendarapi/internal/middleware"
|
|
"github.com/calendarapi/internal/models"
|
|
"github.com/calendarapi/internal/repository"
|
|
"github.com/calendarapi/internal/service"
|
|
"github.com/calendarapi/internal/utils"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
type ICSHandler struct {
|
|
calSvc *service.CalendarService
|
|
eventSvc *service.EventService
|
|
queries *repository.Queries
|
|
}
|
|
|
|
func NewICSHandler(calSvc *service.CalendarService, eventSvc *service.EventService, queries *repository.Queries) *ICSHandler {
|
|
return &ICSHandler{calSvc: calSvc, eventSvc: eventSvc, queries: queries}
|
|
}
|
|
|
|
func (h *ICSHandler) Export(w http.ResponseWriter, r *http.Request) {
|
|
userID, _ := middleware.GetUserID(r.Context())
|
|
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
|
|
if _, err := h.calSvc.GetRole(r.Context(), calID, userID); err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
|
|
cal, err := h.queries.GetCalendarByID(r.Context(), utils.ToPgUUID(calID))
|
|
if err != nil {
|
|
utils.WriteError(w, models.ErrInternal)
|
|
return
|
|
}
|
|
|
|
h.writeICSFeed(w, r.Context(), cal.Name, calID)
|
|
}
|
|
|
|
func (h *ICSHandler) PublicFeed(w http.ResponseWriter, r *http.Request) {
|
|
token := chi.URLParam(r, "token")
|
|
if token == "" {
|
|
utils.WriteError(w, models.ErrNotFound)
|
|
return
|
|
}
|
|
|
|
cal, err := h.queries.GetCalendarByToken(r.Context(), pgtype.Text{String: token, Valid: true})
|
|
if err != nil {
|
|
utils.WriteError(w, models.ErrNotFound)
|
|
return
|
|
}
|
|
|
|
calID := utils.FromPgUUID(cal.ID)
|
|
h.writeICSFeed(w, r.Context(), cal.Name, calID)
|
|
}
|
|
|
|
func (h *ICSHandler) writeICSFeed(w http.ResponseWriter, ctx context.Context, calName string, calID uuid.UUID) {
|
|
now := time.Now().UTC()
|
|
rangeStart := now.AddDate(-1, 0, 0)
|
|
rangeEnd := now.AddDate(1, 0, 0)
|
|
|
|
events, err := h.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{
|
|
CalendarID: utils.ToPgUUID(calID),
|
|
EndTime: utils.ToPgTimestamptz(rangeStart),
|
|
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
|
})
|
|
if err != nil {
|
|
utils.WriteError(w, models.ErrInternal)
|
|
return
|
|
}
|
|
|
|
eventIDs := make([]uuid.UUID, len(events))
|
|
for i, ev := range events {
|
|
eventIDs[i] = utils.FromPgUUID(ev.ID)
|
|
}
|
|
|
|
reminderMap := h.loadReminders(ctx, eventIDs)
|
|
attendeeMap := h.loadAttendees(ctx, eventIDs)
|
|
|
|
calendar := ics.NewCalendar()
|
|
calendar.SetProductId("-//CalendarAPI//EN")
|
|
calendar.SetCalscale("GREGORIAN")
|
|
calendar.SetXWRCalName(calName)
|
|
|
|
for _, ev := range events {
|
|
evID := utils.FromPgUUID(ev.ID)
|
|
event := calendar.AddEvent(evID.String() + "@calendarapi")
|
|
event.SetDtStampTime(time.Now().UTC())
|
|
event.SetCreatedTime(utils.FromPgTimestamptz(ev.CreatedAt))
|
|
event.SetModifiedAt(utils.FromPgTimestamptz(ev.UpdatedAt))
|
|
|
|
startTime := utils.FromPgTimestamptz(ev.StartTime)
|
|
endTime := utils.FromPgTimestamptz(ev.EndTime)
|
|
|
|
if ev.AllDay {
|
|
event.SetAllDayStartAt(startTime)
|
|
event.SetAllDayEndAt(endTime)
|
|
} else {
|
|
event.SetStartAt(startTime)
|
|
event.SetEndAt(endTime)
|
|
}
|
|
|
|
event.SetSummary(ev.Title)
|
|
|
|
if ev.Description.Valid {
|
|
event.SetDescription(ev.Description.String)
|
|
}
|
|
if ev.Location.Valid {
|
|
event.SetLocation(ev.Location.String)
|
|
}
|
|
if ev.RecurrenceRule.Valid {
|
|
event.AddRrule(ev.RecurrenceRule.String)
|
|
}
|
|
|
|
event.SetStatus(ics.ObjectStatusConfirmed)
|
|
|
|
for _, rem := range reminderMap[evID] {
|
|
alarm := event.AddAlarm()
|
|
alarm.SetAction(ics.ActionDisplay)
|
|
alarm.SetTrigger(fmt.Sprintf("-PT%dM", rem.MinutesBefore))
|
|
alarm.SetProperty(ics.ComponentPropertyDescription, "Reminder")
|
|
}
|
|
|
|
for _, att := range attendeeMap[evID] {
|
|
email := ""
|
|
if att.Email.Valid {
|
|
email = att.Email.String
|
|
}
|
|
if email == "" {
|
|
continue
|
|
}
|
|
|
|
partStat := mapStatusToPartStat(att.Status)
|
|
event.AddAttendee("mailto:"+email, partStat)
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.ics", calName))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(calendar.Serialize()))
|
|
}
|
|
|
|
func (h *ICSHandler) Import(w http.ResponseWriter, r *http.Request) {
|
|
userID, _ := middleware.GetUserID(r.Context())
|
|
|
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
|
utils.WriteError(w, models.NewValidationError("invalid multipart form"))
|
|
return
|
|
}
|
|
|
|
calIDStr := r.FormValue("calendar_id")
|
|
calID, err := utils.ValidateUUID(calIDStr)
|
|
if err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
|
|
role, err := h.calSvc.GetRole(r.Context(), calID, userID)
|
|
if err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
if role != "owner" && role != "editor" {
|
|
utils.WriteError(w, models.ErrForbidden)
|
|
return
|
|
}
|
|
|
|
file, _, err := r.FormFile("file")
|
|
if err != nil {
|
|
utils.WriteError(w, models.NewValidationError("file required"))
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
utils.WriteError(w, models.ErrInternal)
|
|
return
|
|
}
|
|
|
|
count := h.importICSData(r.Context(), string(data), calID, userID)
|
|
|
|
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"imported": map[string]int{"events": count},
|
|
})
|
|
}
|
|
|
|
func (h *ICSHandler) ImportURL(w http.ResponseWriter, r *http.Request) {
|
|
userID, _ := middleware.GetUserID(r.Context())
|
|
|
|
var req struct {
|
|
CalendarID string `json:"calendar_id"`
|
|
URL string `json:"url"`
|
|
}
|
|
if err := utils.DecodeJSON(r, &req); err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
|
|
calID, err := utils.ValidateUUID(req.CalendarID)
|
|
if err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
|
|
if req.URL == "" {
|
|
utils.WriteError(w, models.NewValidationError("url is required"))
|
|
return
|
|
}
|
|
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") && !strings.HasPrefix(req.URL, "webcal://") {
|
|
utils.WriteError(w, models.NewValidationError("url must be http, https, or webcal"))
|
|
return
|
|
}
|
|
|
|
role, err := h.calSvc.GetRole(r.Context(), calID, userID)
|
|
if err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
if role != "owner" && role != "editor" {
|
|
utils.WriteError(w, models.ErrForbidden)
|
|
return
|
|
}
|
|
|
|
fetchURL := req.URL
|
|
if strings.HasPrefix(fetchURL, "webcal://") {
|
|
fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://")
|
|
}
|
|
if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok {
|
|
fetchURL = normalized
|
|
}
|
|
|
|
body, fetchErr := fetchICSURL(fetchURL)
|
|
if fetchErr != nil {
|
|
utils.WriteError(w, models.NewValidationError(fetchErr.Error()))
|
|
return
|
|
}
|
|
|
|
count := h.importICSData(r.Context(), string(body), calID, userID)
|
|
|
|
h.queries.CreateCalendarSubscription(r.Context(), repository.CreateCalendarSubscriptionParams{
|
|
ID: utils.ToPgUUID(uuid.New()),
|
|
CalendarID: utils.ToPgUUID(calID),
|
|
SourceUrl: req.URL,
|
|
SyncIntervalMinutes: pgtype.Int4{Valid: false},
|
|
})
|
|
|
|
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"imported": map[string]int{"events": count},
|
|
"source": req.URL,
|
|
})
|
|
}
|
|
|
|
func (h *ICSHandler) AddFromURL(w http.ResponseWriter, r *http.Request) {
|
|
userID, _ := middleware.GetUserID(r.Context())
|
|
|
|
var req struct {
|
|
URL string `json:"url"`
|
|
Name *string `json:"name"`
|
|
Color *string `json:"color"`
|
|
}
|
|
if err := utils.DecodeJSON(r, &req); err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
|
|
req.URL = strings.TrimSpace(req.URL)
|
|
if req.URL == "" {
|
|
utils.WriteError(w, models.NewValidationError("url is required"))
|
|
return
|
|
}
|
|
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") && !strings.HasPrefix(req.URL, "webcal://") {
|
|
utils.WriteError(w, models.NewValidationError("url must be http, https, or webcal"))
|
|
return
|
|
}
|
|
|
|
fetchURL := req.URL
|
|
if strings.HasPrefix(fetchURL, "webcal://") {
|
|
fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://")
|
|
}
|
|
if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok {
|
|
fetchURL = normalized
|
|
}
|
|
|
|
body, fetchErr := fetchICSURL(fetchURL)
|
|
if fetchErr != nil {
|
|
utils.WriteError(w, models.NewValidationError(fetchErr.Error()))
|
|
return
|
|
}
|
|
|
|
name := "Imported Calendar"
|
|
if req.Name != nil && *req.Name != "" {
|
|
name = *req.Name
|
|
}
|
|
color := "#3B82F6"
|
|
if req.Color != nil && *req.Color != "" {
|
|
if err := utils.ValidateColor(*req.Color); err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
color = *req.Color
|
|
}
|
|
|
|
cal, err := h.calSvc.Create(r.Context(), userID, name, color)
|
|
if err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
calID := cal.ID
|
|
|
|
count := h.importICSData(r.Context(), string(body), calID, userID)
|
|
|
|
h.queries.CreateCalendarSubscription(r.Context(), repository.CreateCalendarSubscriptionParams{
|
|
ID: utils.ToPgUUID(uuid.New()),
|
|
CalendarID: utils.ToPgUUID(calID),
|
|
SourceUrl: req.URL,
|
|
SyncIntervalMinutes: pgtype.Int4{Valid: false},
|
|
})
|
|
|
|
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"calendar": cal,
|
|
"imported": map[string]int{"events": count},
|
|
"source": req.URL,
|
|
})
|
|
}
|
|
|
|
func (h *ICSHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request) {
|
|
userID, _ := middleware.GetUserID(r.Context())
|
|
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
|
|
if _, err := h.calSvc.GetRole(r.Context(), calID, userID); err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
|
|
subs, err := h.queries.ListSubscriptionsByCalendar(r.Context(), utils.ToPgUUID(calID))
|
|
if err != nil {
|
|
utils.WriteError(w, models.ErrInternal)
|
|
return
|
|
}
|
|
|
|
items := make([]map[string]interface{}, len(subs))
|
|
for i, s := range subs {
|
|
syncMins := (*int32)(nil)
|
|
if s.SyncIntervalMinutes.Valid {
|
|
syncMins = &s.SyncIntervalMinutes.Int32
|
|
}
|
|
lastSynced := ""
|
|
if s.LastSyncedAt.Valid {
|
|
lastSynced = s.LastSyncedAt.Time.Format(time.RFC3339)
|
|
}
|
|
items[i] = map[string]interface{}{
|
|
"id": utils.FromPgUUID(s.ID),
|
|
"calendar_id": utils.FromPgUUID(s.CalendarID),
|
|
"source_url": s.SourceUrl,
|
|
"last_synced_at": lastSynced,
|
|
"sync_interval_minutes": syncMins,
|
|
"created_at": s.CreatedAt.Time.Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
|
"items": items,
|
|
"page": map[string]interface{}{"limit": 100, "next_cursor": nil},
|
|
})
|
|
}
|
|
|
|
func (h *ICSHandler) AddSubscription(w http.ResponseWriter, r *http.Request) {
|
|
userID, _ := middleware.GetUserID(r.Context())
|
|
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
|
|
role, err := h.calSvc.GetRole(r.Context(), calID, userID)
|
|
if err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
if role != "owner" && role != "editor" {
|
|
utils.WriteError(w, models.ErrForbidden)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
URL string `json:"url"`
|
|
SyncIntervalMinutes *int32 `json:"sync_interval_minutes"`
|
|
}
|
|
if err := utils.DecodeJSON(r, &req); err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
|
|
if req.URL == "" {
|
|
utils.WriteError(w, models.NewValidationError("url is required"))
|
|
return
|
|
}
|
|
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") && !strings.HasPrefix(req.URL, "webcal://") {
|
|
utils.WriteError(w, models.NewValidationError("url must be http, https, or webcal"))
|
|
return
|
|
}
|
|
|
|
fetchURL := req.URL
|
|
if strings.HasPrefix(fetchURL, "webcal://") {
|
|
fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://")
|
|
}
|
|
if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok {
|
|
fetchURL = normalized
|
|
}
|
|
|
|
body, fetchErr := fetchICSURL(fetchURL)
|
|
if fetchErr != nil {
|
|
utils.WriteError(w, models.NewValidationError(fetchErr.Error()))
|
|
return
|
|
}
|
|
|
|
count := h.importICSData(r.Context(), string(body), calID, userID)
|
|
|
|
syncMins := pgtype.Int4{Valid: false}
|
|
if req.SyncIntervalMinutes != nil && *req.SyncIntervalMinutes > 0 {
|
|
syncMins = pgtype.Int4{Int32: *req.SyncIntervalMinutes, Valid: true}
|
|
}
|
|
|
|
h.queries.CreateCalendarSubscription(r.Context(), repository.CreateCalendarSubscriptionParams{
|
|
ID: utils.ToPgUUID(uuid.New()),
|
|
CalendarID: utils.ToPgUUID(calID),
|
|
SourceUrl: req.URL,
|
|
SyncIntervalMinutes: syncMins,
|
|
})
|
|
|
|
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"imported": map[string]int{"events": count},
|
|
"source": req.URL,
|
|
})
|
|
}
|
|
|
|
func (h *ICSHandler) DeleteSubscription(w http.ResponseWriter, r *http.Request) {
|
|
userID, _ := middleware.GetUserID(r.Context())
|
|
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
subID, err := utils.ValidateUUID(chi.URLParam(r, "subId"))
|
|
if err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
|
|
if _, err := h.calSvc.GetRole(r.Context(), calID, userID); err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
|
|
sub, err := h.queries.GetSubscriptionByID(r.Context(), utils.ToPgUUID(subID))
|
|
if err != nil {
|
|
utils.WriteError(w, models.ErrNotFound)
|
|
return
|
|
}
|
|
if utils.FromPgUUID(sub.CalendarID) != calID {
|
|
utils.WriteError(w, models.ErrNotFound)
|
|
return
|
|
}
|
|
|
|
if err := h.queries.DeleteSubscription(r.Context(), utils.ToPgUUID(subID)); err != nil {
|
|
utils.WriteError(w, models.ErrInternal)
|
|
return
|
|
}
|
|
|
|
utils.WriteOK(w)
|
|
}
|
|
|
|
func (h *ICSHandler) SyncSubscription(w http.ResponseWriter, r *http.Request) {
|
|
userID, _ := middleware.GetUserID(r.Context())
|
|
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
subID, err := utils.ValidateUUID(chi.URLParam(r, "subId"))
|
|
if err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
|
|
role, err := h.calSvc.GetRole(r.Context(), calID, userID)
|
|
if err != nil {
|
|
utils.WriteError(w, err)
|
|
return
|
|
}
|
|
if role != "owner" && role != "editor" {
|
|
utils.WriteError(w, models.ErrForbidden)
|
|
return
|
|
}
|
|
|
|
sub, err := h.queries.GetSubscriptionByID(r.Context(), utils.ToPgUUID(subID))
|
|
if err != nil {
|
|
utils.WriteError(w, models.ErrNotFound)
|
|
return
|
|
}
|
|
if utils.FromPgUUID(sub.CalendarID) != calID {
|
|
utils.WriteError(w, models.ErrNotFound)
|
|
return
|
|
}
|
|
|
|
fetchURL := sub.SourceUrl
|
|
if strings.HasPrefix(fetchURL, "webcal://") {
|
|
fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://")
|
|
}
|
|
if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok {
|
|
fetchURL = normalized
|
|
}
|
|
|
|
body, fetchErr := fetchICSURL(fetchURL)
|
|
if fetchErr != nil {
|
|
utils.WriteError(w, models.NewValidationError(fetchErr.Error()))
|
|
return
|
|
}
|
|
|
|
count := h.importICSData(r.Context(), string(body), calID, userID)
|
|
|
|
if err := h.queries.UpdateSubscriptionLastSynced(r.Context(), utils.ToPgUUID(subID)); err != nil {
|
|
utils.WriteError(w, models.ErrInternal)
|
|
return
|
|
}
|
|
|
|
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"imported": map[string]int{"events": count},
|
|
})
|
|
}
|
|
|
|
// SyncSubscriptionBackground performs a subscription sync (used by background worker).
|
|
func (h *ICSHandler) SyncSubscriptionBackground(ctx context.Context, subscriptionID string) error {
|
|
subID, err := uuid.Parse(subscriptionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sub, err := h.queries.GetSubscriptionByID(ctx, utils.ToPgUUID(subID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
calID := utils.FromPgUUID(sub.CalendarID)
|
|
cal, err := h.queries.GetCalendarByID(ctx, sub.CalendarID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ownerID := utils.FromPgUUID(cal.OwnerID)
|
|
|
|
fetchURL := sub.SourceUrl
|
|
if strings.HasPrefix(fetchURL, "webcal://") {
|
|
fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://")
|
|
}
|
|
if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok {
|
|
fetchURL = normalized
|
|
}
|
|
|
|
body, err := fetchICSURL(fetchURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_ = h.importICSData(ctx, string(body), calID, ownerID)
|
|
return h.queries.UpdateSubscriptionLastSynced(ctx, utils.ToPgUUID(subID))
|
|
}
|
|
|
|
// normalizeGoogleCalendarURL converts Google Calendar embed/share URLs to the iCal feed format.
|
|
// Returns (fetchURL, true) if the URL was converted or is already a Google iCal URL.
|
|
func normalizeGoogleCalendarURL(raw string) (string, bool) {
|
|
raw = strings.TrimSpace(raw)
|
|
if !strings.Contains(raw, "calendar.google.com") {
|
|
return raw, false
|
|
}
|
|
|
|
// Already an iCal URL - use as-is (may be public or private)
|
|
if strings.Contains(raw, "/calendar/ical/") && strings.HasSuffix(raw, ".ics") {
|
|
return raw, true
|
|
}
|
|
|
|
// Embed URL: https://calendar.google.com/calendar/embed?src=CALENDAR_ID
|
|
if strings.Contains(raw, "/calendar/embed") {
|
|
if u, err := url.Parse(raw); err == nil {
|
|
if src := u.Query().Get("src"); src != "" {
|
|
decoded, _ := url.QueryUnescape(src)
|
|
icalURL := "https://calendar.google.com/calendar/ical/" + url.PathEscape(decoded) + "/public/basic.ics"
|
|
return icalURL, true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calendar link with cid: https://calendar.google.com/calendar?cid=BASE64_CALENDAR_ID
|
|
if strings.Contains(raw, "cid=") {
|
|
if u, err := url.Parse(raw); err == nil {
|
|
if cid := u.Query().Get("cid"); cid != "" {
|
|
// cid can be base64, base64url, or base64url without padding
|
|
var decoded []byte
|
|
for _, enc := range []*base64.Encoding{base64.URLEncoding, base64.RawURLEncoding, base64.StdEncoding} {
|
|
d, decodeErr := enc.DecodeString(cid)
|
|
if decodeErr == nil && len(d) > 0 {
|
|
decoded = d
|
|
break
|
|
}
|
|
}
|
|
if len(decoded) > 0 {
|
|
calID := string(decoded)
|
|
icalURL := "https://calendar.google.com/calendar/ical/" + url.PathEscape(calID) + "/public/basic.ics"
|
|
return icalURL, true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return raw, true
|
|
}
|
|
|
|
// googleCalendarHelp is the error hint shown when Google Calendar requests fail.
|
|
const googleCalendarHelp = "Use the iCal link from Google: Settings → your calendar → Integrate calendar → copy \"Secret address in iCal format\" (not the embed or share URL)"
|
|
|
|
// fetchICSURL fetches an ICS file from a URL. Google Calendar blocks non-browser User-Agents,
|
|
// so we use a full Chrome User-Agent. Other providers generally accept it.
|
|
func fetchICSURL(fetchURL string) ([]byte, error) {
|
|
req, err := http.NewRequest(http.MethodGet, fetchURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
// Google Calendar returns 403 for non-browser User-Agents; use a real Chrome UA
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
|
req.Header.Set("Accept", "text/calendar,text/plain,*/*")
|
|
if strings.Contains(fetchURL, "calendar.google.com") {
|
|
req.Header.Set("Referer", "https://calendar.google.com/")
|
|
}
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch URL: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
if strings.Contains(fetchURL, "calendar.google.com") {
|
|
switch resp.StatusCode {
|
|
case http.StatusNotFound:
|
|
return nil, fmt.Errorf("Google Calendar returned 404. %s", googleCalendarHelp)
|
|
case http.StatusForbidden:
|
|
return nil, fmt.Errorf("Google Calendar returned 403 (access denied). %s", googleCalendarHelp)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("URL returned HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
// Google may return 200 with an HTML login/error page instead of ICS
|
|
bodyStr := string(body)
|
|
if len(body) > 10 && (body[0] == '<' || strings.HasPrefix(bodyStr, "<!") || strings.Contains(bodyStr[:min(500, len(bodyStr))], "<html")) {
|
|
if strings.Contains(fetchURL, "calendar.google.com") {
|
|
return nil, fmt.Errorf("Google returned HTML instead of calendar data. %s", googleCalendarHelp)
|
|
}
|
|
return nil, fmt.Errorf("URL returned HTML instead of calendar data (ICS)")
|
|
}
|
|
|
|
return body, nil
|
|
}
|
|
|
|
func (h *ICSHandler) importICSData(ctx context.Context, data string, calID, userID uuid.UUID) int {
|
|
cal, err := ics.ParseCalendar(strings.NewReader(data))
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
count := 0
|
|
for _, ev := range cal.Events() {
|
|
imported := h.importEvent(ctx, ev, calID, userID)
|
|
if imported {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func (h *ICSHandler) importEvent(ctx context.Context, ev *ics.VEvent, calID, userID uuid.UUID) bool {
|
|
summaryProp := ev.GetProperty(ics.ComponentPropertySummary)
|
|
if summaryProp == nil {
|
|
return false
|
|
}
|
|
title := summaryProp.Value
|
|
|
|
allDay := false
|
|
var startTime, endTime time.Time
|
|
|
|
dtStartProp := ev.GetProperty(ics.ComponentPropertyDtStart)
|
|
if dtStartProp == nil {
|
|
return false
|
|
}
|
|
|
|
if dtStartProp.GetValueType() == ics.ValueDataTypeDate {
|
|
allDay = true
|
|
var err error
|
|
startTime, err = ev.GetAllDayStartAt()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
endTime, _ = ev.GetAllDayEndAt()
|
|
if endTime.IsZero() {
|
|
endTime = startTime.AddDate(0, 0, 1)
|
|
}
|
|
} else {
|
|
var err error
|
|
startTime, err = ev.GetStartAt()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
endTime, _ = ev.GetEndAt()
|
|
if endTime.IsZero() {
|
|
endTime = startTime.Add(time.Hour)
|
|
}
|
|
}
|
|
|
|
tz := "UTC"
|
|
if tzid, ok := dtStartProp.ICalParameters["TZID"]; ok && len(tzid) > 0 {
|
|
if _, err := time.LoadLocation(tzid[0]); err == nil {
|
|
tz = tzid[0]
|
|
}
|
|
}
|
|
|
|
description := ""
|
|
if p := ev.GetProperty(ics.ComponentPropertyDescription); p != nil {
|
|
description = p.Value
|
|
}
|
|
location := ""
|
|
if p := ev.GetProperty(ics.ComponentPropertyLocation); p != nil {
|
|
location = p.Value
|
|
}
|
|
rrule := ""
|
|
if p := ev.GetProperty(ics.ComponentPropertyRrule); p != nil {
|
|
rrule = p.Value
|
|
}
|
|
|
|
eventID := uuid.New()
|
|
_, err := h.queries.CreateEvent(ctx, repository.CreateEventParams{
|
|
ID: utils.ToPgUUID(eventID),
|
|
CalendarID: utils.ToPgUUID(calID),
|
|
Title: title,
|
|
Description: utils.ToPgText(description),
|
|
Location: utils.ToPgText(location),
|
|
StartTime: utils.ToPgTimestamptz(startTime.UTC()),
|
|
EndTime: utils.ToPgTimestamptz(endTime.UTC()),
|
|
Timezone: tz,
|
|
AllDay: allDay,
|
|
RecurrenceRule: utils.ToPgText(rrule),
|
|
Tags: []string{},
|
|
CreatedBy: utils.ToPgUUID(userID),
|
|
UpdatedBy: utils.ToPgUUID(userID),
|
|
})
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
for _, alarm := range ev.Alarms() {
|
|
triggerProp := alarm.GetProperty(ics.ComponentPropertyTrigger)
|
|
if triggerProp == nil {
|
|
continue
|
|
}
|
|
minutes := parseTriggerMinutes(triggerProp.Value)
|
|
if minutes > 0 && minutes <= 10080 {
|
|
h.queries.CreateReminder(ctx, repository.CreateReminderParams{
|
|
ID: utils.ToPgUUID(uuid.New()),
|
|
EventID: utils.ToPgUUID(eventID),
|
|
MinutesBefore: minutes,
|
|
})
|
|
}
|
|
}
|
|
|
|
for _, att := range ev.Attendees() {
|
|
email := strings.TrimPrefix(att.Value, "mailto:")
|
|
email = strings.TrimPrefix(email, "MAILTO:")
|
|
if email == "" {
|
|
continue
|
|
}
|
|
h.queries.CreateAttendee(ctx, repository.CreateAttendeeParams{
|
|
ID: utils.ToPgUUID(uuid.New()),
|
|
EventID: utils.ToPgUUID(eventID),
|
|
Email: pgtype.Text{String: email, Valid: true},
|
|
})
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (h *ICSHandler) loadReminders(ctx context.Context, eventIDs []uuid.UUID) map[uuid.UUID][]repository.EventReminder {
|
|
m := make(map[uuid.UUID][]repository.EventReminder)
|
|
for _, id := range eventIDs {
|
|
rows, err := h.queries.ListRemindersByEvent(ctx, utils.ToPgUUID(id))
|
|
if err == nil && len(rows) > 0 {
|
|
m[id] = rows
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (h *ICSHandler) loadAttendees(ctx context.Context, eventIDs []uuid.UUID) map[uuid.UUID][]repository.EventAttendee {
|
|
m := make(map[uuid.UUID][]repository.EventAttendee)
|
|
for _, id := range eventIDs {
|
|
rows, err := h.queries.ListAttendeesByEvent(ctx, utils.ToPgUUID(id))
|
|
if err == nil && len(rows) > 0 {
|
|
m[id] = rows
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func mapStatusToPartStat(status string) ics.ParticipationStatus {
|
|
switch status {
|
|
case "accepted":
|
|
return ics.ParticipationStatusAccepted
|
|
case "declined":
|
|
return ics.ParticipationStatusDeclined
|
|
case "tentative":
|
|
return ics.ParticipationStatusTentative
|
|
default:
|
|
return ics.ParticipationStatusNeedsAction
|
|
}
|
|
}
|
|
|
|
// parseTriggerMinutes parses an iCal TRIGGER value like "-PT15M", "-PT1H", "-P1D" into minutes.
|
|
func parseTriggerMinutes(trigger string) int32 {
|
|
trigger = strings.TrimPrefix(trigger, "-")
|
|
trigger = strings.TrimPrefix(trigger, "+")
|
|
|
|
if strings.HasPrefix(trigger, "PT") {
|
|
trigger = strings.TrimPrefix(trigger, "PT")
|
|
return parseTimePart(trigger)
|
|
}
|
|
if strings.HasPrefix(trigger, "P") {
|
|
trigger = strings.TrimPrefix(trigger, "P")
|
|
total := int32(0)
|
|
if idx := strings.Index(trigger, "W"); idx >= 0 {
|
|
if w, err := strconv.Atoi(trigger[:idx]); err == nil {
|
|
total += int32(w) * 7 * 24 * 60
|
|
}
|
|
return total
|
|
}
|
|
if idx := strings.Index(trigger, "D"); idx >= 0 {
|
|
if d, err := strconv.Atoi(trigger[:idx]); err == nil {
|
|
total += int32(d) * 24 * 60
|
|
}
|
|
trigger = trigger[idx+1:]
|
|
}
|
|
if strings.HasPrefix(trigger, "T") {
|
|
total += parseTimePart(strings.TrimPrefix(trigger, "T"))
|
|
}
|
|
return total
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func parseTimePart(s string) int32 {
|
|
total := int32(0)
|
|
if idx := strings.Index(s, "H"); idx >= 0 {
|
|
if h, err := strconv.Atoi(s[:idx]); err == nil {
|
|
total += int32(h) * 60
|
|
}
|
|
s = s[idx+1:]
|
|
}
|
|
if idx := strings.Index(s, "M"); idx >= 0 {
|
|
if m, err := strconv.Atoi(s[:idx]); err == nil {
|
|
total += int32(m)
|
|
}
|
|
s = s[idx+1:]
|
|
}
|
|
if idx := strings.Index(s, "S"); idx >= 0 {
|
|
if sec, err := strconv.Atoi(s[:idx]); err == nil {
|
|
total += int32(sec) / 60
|
|
}
|
|
}
|
|
return total
|
|
}
|