From 1fda9e7bacae2cb3470a4e1ad936358ca8e67de7 Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Thu, 11 Jun 2026 10:11:03 +0200 Subject: [PATCH] Magnifique --- deploy/nginx/default.conf.template | 14 ++ internal/api/calendar/handlers.go | 195 ++++++++++++++++- internal/api/calendar/service.go | 32 +++ internal/api/calendar/validate.go | 110 ++++++++++ internal/nextcloud/calendar.go | 311 +++++++++++++++++++++++++--- internal/nextcloud/calendar_test.go | 157 ++++++++++++++ 6 files changed, 784 insertions(+), 35 deletions(-) create mode 100644 internal/nextcloud/calendar_test.go diff --git a/deploy/nginx/default.conf.template b/deploy/nginx/default.conf.template index e5594ea..c7776aa 100644 --- a/deploy/nginx/default.conf.template +++ b/deploy/nginx/default.conf.template @@ -357,6 +357,20 @@ server { proxy_set_header Connection $connection_upgrade; } + # Réglages du compte Ulti + location ^~ /compte { + resolver 127.0.0.11 valid=10s ipv6=off; + set $mail_upstream ${MAIL_FRONTEND_UPSTREAM}; + proxy_pass http://$mail_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + # Console d'administration suite location ^~ /admin { resolver 127.0.0.11 valid=10s ipv6=off; diff --git a/internal/api/calendar/handlers.go b/internal/api/calendar/handlers.go index 182f5de..85985e1 100644 --- a/internal/api/calendar/handlers.go +++ b/internal/api/calendar/handlers.go @@ -1,6 +1,7 @@ package calendar import ( + "context" "errors" "log/slog" "net/http" @@ -12,6 +13,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/query" + "github.com/ultisuite/ulti-backend/internal/auth" meetpkg "github.com/ultisuite/ulti-backend/internal/meet" "github.com/ultisuite/ulti-backend/internal/nextcloud" "github.com/ultisuite/ulti-backend/internal/permission" @@ -37,6 +39,9 @@ func (h *Handler) Routes() chi.Router { r.With(read).Get("/", h.ListCalendars) r.With(read).Get("/{calID}/events", h.ListEvents) r.With(read).Post("/freebusy", h.FreeBusy) + r.With(write).Post("/", h.CreateCalendar) + r.With(write).Patch("/{calID}", h.UpdateCalendar) + r.With(write).Delete("/{calID}", h.DeleteCalendar) r.With(write).Post("/{calID}/events", h.CreateEvent) r.With(write).Put("/events/*", h.UpdateEvent) r.With(write).Post("/events/response/*", h.RespondToInvitation) @@ -45,9 +50,46 @@ func (h *Handler) Routes() chi.Router { return r } +func (h *Handler) nextcloudUser(w http.ResponseWriter, r *http.Request, claims *auth.Claims) (string, bool) { + userID, err := h.svc.EnsureNextcloudUser(r.Context(), claims) + if err != nil { + h.logger.Error("ensure nextcloud user", "error", err, "sub", claims.Sub, "email", claims.Email) + apivalidate.WriteInternal(w, r) + return "", false + } + return userID, true +} + +func (h *Handler) retryOnDAVMissing( + ctx context.Context, + claims *auth.Claims, + ncUser string, + op func(userID string) error, +) error { + err := op(ncUser) + if !errors.Is(err, nextcloud.ErrDAVCredentialsMissing) { + return err + } + refreshed, reproErr := h.svc.ReprovisionPrincipal(ctx, claims) + if reproErr != nil { + h.logger.Error("reprovision nextcloud principal", "error", reproErr, "user", ncUser) + return err + } + return op(refreshed) +} + func (h *Handler) ListCalendars(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) - cals, err := h.svc.ListCalendars(r.Context(), claims.Sub) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } + var cals []nextcloud.Calendar + err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error { + var listErr error + cals, listErr = h.svc.ListCalendars(r.Context(), userID) + return listErr + }) if err != nil { h.logger.Error("list calendars", "error", err) apivalidate.WriteInternal(w, r) @@ -56,15 +98,95 @@ func (h *Handler) ListCalendars(w http.ResponseWriter, r *http.Request) { apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"calendars": cals}) } +func (h *Handler) CreateCalendar(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } + var req calendarPropsRequest + if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil { + return + } + normalized, verr := validateCreateCalendar(&req) + if verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error { + return h.svc.CreateCalendar(r.Context(), userID, normalized.ID, normalized.DisplayName, normalized.Color) + }) + if err != nil { + h.logger.Error("create calendar", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusCreated, map[string]any{"id": normalized.ID}) +} + +func (h *Handler) UpdateCalendar(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } + calID := chi.URLParam(r, "calID") + var req calendarPropsRequest + if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil { + return + } + if verr := validateUpdateCalendar(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error { + return h.svc.UpdateCalendar(r.Context(), userID, calID, strings.TrimSpace(req.DisplayName), strings.TrimSpace(req.Color)) + }) + if err != nil { + h.logger.Error("update calendar", "error", err) + apivalidate.WriteInternal(w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) DeleteCalendar(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } + calID := chi.URLParam(r, "calID") + err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error { + return h.svc.DeleteCalendar(r.Context(), userID, calID) + }) + if err != nil { + h.logger.Error("delete calendar", "error", err) + apivalidate.WriteInternal(w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *Handler) ListEvents(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } params, err := query.ParseListRequest(r) if err != nil { apivalidate.WriteQueryError(w, r, err) return } - result, err := h.svc.ListEvents(r.Context(), claims.Sub, chi.URLParam(r, "calID"), params) + calID := chi.URLParam(r, "calID") + var result EventsList + err = h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error { + var listErr error + result, listErr = h.svc.ListEvents(r.Context(), userID, calID, params) + return listErr + }) if err != nil { h.logger.Error("list events", "error", err) apivalidate.WriteInternal(w, r) @@ -75,6 +197,10 @@ func (h *Handler) ListEvents(w http.ResponseWriter, r *http.Request) { func (h *Handler) FreeBusy(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } var req freeBusyRequest if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil { @@ -86,7 +212,12 @@ func (h *Handler) FreeBusy(w http.ResponseWriter, r *http.Request) { return } - result, err := h.svc.FreeBusy(r.Context(), claims.Sub, ncReq) + var result *nextcloud.FreeBusyResponse + err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error { + var fbErr error + result, fbErr = h.svc.FreeBusy(r.Context(), userID, ncReq) + return fbErr + }) if err != nil { h.logger.Error("free/busy", "error", err) apivalidate.WriteInternal(w, r) @@ -97,6 +228,10 @@ func (h *Handler) FreeBusy(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } var event nextcloud.Event if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &event); err != nil { @@ -107,7 +242,14 @@ func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) { return } - if err := h.svc.CreateEvent(r.Context(), claims.Sub, chi.URLParam(r, "calID"), &event); err != nil { + calID := chi.URLParam(r, "calID") + if strings.TrimSpace(event.Organizer) == "" { + event.Organizer = claims.Email + } + err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error { + return h.svc.CreateEvent(r.Context(), userID, calID, &event) + }) + if err != nil { h.logger.Error("create event", "error", err) apivalidate.WriteInternal(w, r) return @@ -117,6 +259,10 @@ func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } eventPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/") if verr := validateDeletePath(eventPath); verr != nil { apivalidate.WriteValidationError(w, r, verr) @@ -139,7 +285,15 @@ func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) { return } - etag, err := h.svc.UpdateEvent(r.Context(), claims.Sub, eventPath, ifMatch, &event) + if strings.TrimSpace(event.Organizer) == "" { + event.Organizer = claims.Email + } + var etag string + err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error { + var updErr error + etag, updErr = h.svc.UpdateEvent(r.Context(), userID, eventPath, ifMatch, &event) + return updErr + }) if err != nil { if errors.Is(err, nextcloud.ErrETagMismatch) { apiresponse.WriteError(w, r, http.StatusPreconditionFailed, "etag_mismatch", "etag does not match current resource version", nil) @@ -154,6 +308,10 @@ func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) { func (h *Handler) RespondToInvitation(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } eventPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/") if verr := validateDeletePath(eventPath); verr != nil { apivalidate.WriteValidationError(w, r, verr) @@ -171,7 +329,12 @@ func (h *Handler) RespondToInvitation(w http.ResponseWriter, r *http.Request) { apivalidate.WriteValidationError(w, r, verr) return } - etag, err := h.svc.RespondToInvitation(r.Context(), claims.Sub, eventPath, normalized.Email, normalized.Response, normalized.IfMatch) + var etag string + err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error { + var respErr error + etag, respErr = h.svc.RespondToInvitation(r.Context(), userID, eventPath, normalized.Email, normalized.Response, normalized.IfMatch) + return respErr + }) if err != nil { switch { case errors.Is(err, nextcloud.ErrAttendeeNotFound): @@ -189,6 +352,10 @@ func (h *Handler) RespondToInvitation(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateMeetLink(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } eventPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/") if verr := validateDeletePath(eventPath); verr != nil { apivalidate.WriteValidationError(w, r, verr) @@ -204,7 +371,12 @@ func (h *Handler) CreateMeetLink(w http.ResponseWriter, r *http.Request) { apivalidate.WriteValidationError(w, r, verr) return } - meetURL, etag, err := h.svc.CreateMeetLink(r.Context(), claims.Sub, claims.Name, claims.Email, eventPath, req.IfMatch) + var meetURL, etag string + err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error { + var linkErr error + meetURL, etag, linkErr = h.svc.CreateMeetLink(r.Context(), userID, claims.Name, claims.Email, eventPath, req.IfMatch) + return linkErr + }) if err != nil { if errors.Is(err, ErrMeetDisabled) { apiresponse.WriteError(w, r, http.StatusConflict, "meet_disabled", "meet integration is disabled", nil) @@ -226,12 +398,19 @@ func (h *Handler) CreateMeetLink(w http.ResponseWriter, r *http.Request) { func (h *Handler) DeleteEvent(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } eventPath := chi.URLParam(r, "*") if verr := validateDeletePath(eventPath); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } - if err := h.svc.DeleteEvent(r.Context(), claims.Sub, eventPath); err != nil { + err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error { + return h.svc.DeleteEvent(r.Context(), userID, eventPath) + }) + if err != nil { h.logger.Error("delete event", "error", err) apivalidate.WriteInternal(w, r) return diff --git a/internal/api/calendar/service.go b/internal/api/calendar/service.go index 54c75f0..d36e317 100644 --- a/internal/api/calendar/service.go +++ b/internal/api/calendar/service.go @@ -9,6 +9,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/paginate" "github.com/ultisuite/ulti-backend/internal/api/query" + "github.com/ultisuite/ulti-backend/internal/auth" meetpkg "github.com/ultisuite/ulti-backend/internal/meet" "github.com/ultisuite/ulti-backend/internal/nextcloud" ) @@ -24,6 +25,25 @@ func NewService(nc *nextcloud.Client, meetCfg *meetpkg.Config) *Service { return &Service{nc: nc, meetCfg: meetCfg} } +func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) { + if s.nc == nil { + return "", fmt.Errorf("nextcloud unavailable") + } + return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name) +} + +// ReprovisionPrincipal drops cached CalDAV credentials and provisions a fresh app password. +func (s *Service) ReprovisionPrincipal(ctx context.Context, claims *auth.Claims) (string, error) { + if s.nc == nil { + return "", fmt.Errorf("nextcloud unavailable") + } + userID := nextcloud.UserIDFromClaims(claims.Email, claims.Sub) + if err := s.nc.InvalidatePrincipalCredentials(ctx, userID); err != nil { + return "", err + } + return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name) +} + func calendarPath(userID, calID string) string { return "/remote.php/dav/calendars/" + userID + "/" + calID + "/" } @@ -32,6 +52,18 @@ func (s *Service) ListCalendars(ctx context.Context, userID string) ([]nextcloud return s.nc.ListCalendars(ctx, userID) } +func (s *Service) CreateCalendar(ctx context.Context, userID, calID, displayName, color string) error { + return s.nc.CreateCalendar(ctx, userID, calID, displayName, color) +} + +func (s *Service) UpdateCalendar(ctx context.Context, userID, calID, displayName, color string) error { + return s.nc.UpdateCalendar(ctx, userID, calID, displayName, color) +} + +func (s *Service) DeleteCalendar(ctx context.Context, userID, calID string) error { + return s.nc.DeleteCalendar(ctx, userID, calID) +} + type EventsList struct { Events []nextcloud.Event `json:"events"` Pagination query.PaginationMeta `json:"pagination,omitempty"` diff --git a/internal/api/calendar/validate.go b/internal/api/calendar/validate.go index 256d20b..ec718e2 100644 --- a/internal/api/calendar/validate.go +++ b/internal/api/calendar/validate.go @@ -1,6 +1,7 @@ package calendar import ( + "fmt" "net/mail" "strconv" "strings" @@ -34,6 +35,115 @@ type createMeetLinkRequest struct { IfMatch string `json:"if_match,omitempty"` } +type calendarPropsRequest struct { + ID string `json:"id,omitempty"` + DisplayName string `json:"display_name"` + Color string `json:"color,omitempty"` +} + +type normalizedCalendarProps struct { + ID string + DisplayName string + Color string +} + +func isHexColor(value string) bool { + if len(value) != 7 && len(value) != 9 { + return false + } + if value[0] != '#' { + return false + } + for _, r := range value[1:] { + switch { + case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F': + default: + return false + } + } + return true +} + +// slugifyCalendarID derives a DAV-safe collection ID from a display name. +func slugifyCalendarID(name string) string { + var b strings.Builder + lastDash := true + for _, r := range strings.ToLower(name) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + lastDash = false + default: + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + } + return strings.Trim(b.String(), "-") +} + +func isValidCalendarID(id string) bool { + if id == "" || len(id) > 120 { + return false + } + for _, r := range id { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_': + default: + return false + } + } + return true +} + +func validateCreateCalendar(req *calendarPropsRequest) (*normalizedCalendarProps, *apivalidate.ValidationError) { + var details []apivalidate.FieldDetail + name := strings.TrimSpace(req.DisplayName) + if name == "" { + details = append(details, apivalidate.FieldDetail{Field: "display_name", Message: "required"}) + } else if len(name) > 200 { + details = append(details, apivalidate.FieldDetail{Field: "display_name", Message: "too long"}) + } + color := strings.TrimSpace(req.Color) + if color != "" && !isHexColor(color) { + details = append(details, apivalidate.FieldDetail{Field: "color", Message: "must be a #RRGGBB hex color"}) + } + id := strings.TrimSpace(req.ID) + if id == "" { + id = slugifyCalendarID(name) + if id == "" { + id = fmt.Sprintf("cal-%d", time.Now().UnixNano()) + } + } + if !isValidCalendarID(id) { + details = append(details, apivalidate.FieldDetail{Field: "id", Message: "must contain only a-z, 0-9, - and _"}) + } + if len(details) > 0 { + return nil, apivalidate.NewValidationError(details...) + } + return &normalizedCalendarProps{ID: id, DisplayName: name, Color: color}, nil +} + +func validateUpdateCalendar(req *calendarPropsRequest) *apivalidate.ValidationError { + var details []apivalidate.FieldDetail + name := strings.TrimSpace(req.DisplayName) + color := strings.TrimSpace(req.Color) + if name == "" && color == "" { + details = append(details, apivalidate.FieldDetail{Field: "display_name", Message: "display_name or color required"}) + } + if len(name) > 200 { + details = append(details, apivalidate.FieldDetail{Field: "display_name", Message: "too long"}) + } + if color != "" && !isHexColor(color) { + details = append(details, apivalidate.FieldDetail{Field: "color", Message: "must be a #RRGGBB hex color"}) + } + if len(details) > 0 { + return apivalidate.NewValidationError(details...) + } + return nil +} + func validateFreeBusy(req *freeBusyRequest) (*nextcloud.FreeBusyRequest, *apivalidate.ValidationError) { var details []apivalidate.FieldDetail diff --git a/internal/nextcloud/calendar.go b/internal/nextcloud/calendar.go index 1db3ff9..e2271bc 100644 --- a/internal/nextcloud/calendar.go +++ b/internal/nextcloud/calendar.go @@ -31,6 +31,9 @@ type Event struct { Organizer string `json:"organizer,omitempty"` Attendees []EventAttendee `json:"attendees,omitempty"` MeetURL string `json:"meet_url,omitempty"` + Color string `json:"color,omitempty"` + RRule string `json:"rrule,omitempty"` + ExDates []string `json:"exdates,omitempty"` RawICS string `json:"raw_ics,omitempty"` } @@ -90,6 +93,77 @@ func (c *Client) ListCalendars(ctx context.Context, userID string) ([]Calendar, return parseCalendarList(resp.Body, path) } +// CreateCalendar provisions a new calendar collection via MKCALENDAR. +func (c *Client) CreateCalendar(ctx context.Context, userID, calID, displayName, color string) error { + path := fmt.Sprintf("/remote.php/dav/calendars/%s/%s/", userID, calID) + var props strings.Builder + props.WriteString(fmt.Sprintf("%s", xmlEscape(displayName))) + if strings.TrimSpace(color) != "" { + props.WriteString(fmt.Sprintf("%s", xmlEscape(color))) + } + body := fmt.Sprintf(` + + %s +`, props.String()) + + resp, err := c.DoAsUser(ctx, "MKCALENDAR", path, strings.NewReader(body), userID, map[string]string{ + "Content-Type": "application/xml", + }) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("create calendar failed: %d", resp.StatusCode) + } + return nil +} + +// UpdateCalendar patches display name and/or color via PROPPATCH. +func (c *Client) UpdateCalendar(ctx context.Context, userID, calID, displayName, color string) error { + path := fmt.Sprintf("/remote.php/dav/calendars/%s/%s/", userID, calID) + var props strings.Builder + if strings.TrimSpace(displayName) != "" { + props.WriteString(fmt.Sprintf("%s", xmlEscape(displayName))) + } + if strings.TrimSpace(color) != "" { + props.WriteString(fmt.Sprintf("%s", xmlEscape(color))) + } + if props.Len() == 0 { + return nil + } + body := fmt.Sprintf(` + + %s +`, props.String()) + + resp, err := c.DoAsUser(ctx, "PROPPATCH", path, strings.NewReader(body), userID, map[string]string{ + "Content-Type": "application/xml", + }) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK { + return fmt.Errorf("update calendar failed: %d", resp.StatusCode) + } + return nil +} + +// DeleteCalendar removes a calendar collection and all of its events. +func (c *Client) DeleteCalendar(ctx context.Context, userID, calID string) error { + path := fmt.Sprintf("/remote.php/dav/calendars/%s/%s/", userID, calID) + resp, err := c.DoAsUser(ctx, "DELETE", path, nil, userID, nil) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + return fmt.Errorf("delete calendar failed: %d", resp.StatusCode) + } + return nil +} + func (c *Client) ListEvents(ctx context.Context, userID, calendarPath string, from, to time.Time) ([]Event, error) { body := fmt.Sprintf(` @@ -317,6 +391,59 @@ func extractMailto(value string) string { return value } +// escapeICSText escapes TEXT property values per RFC 5545 §3.3.11. +func escapeICSText(value string) string { + value = strings.ReplaceAll(value, "\\", "\\\\") + value = strings.ReplaceAll(value, "\r\n", "\n") + value = strings.ReplaceAll(value, "\r", "\n") + value = strings.ReplaceAll(value, "\n", "\\n") + value = strings.ReplaceAll(value, ";", "\\;") + value = strings.ReplaceAll(value, ",", "\\,") + return value +} + +func unescapeICSText(value string) string { + var b strings.Builder + b.Grow(len(value)) + for i := 0; i < len(value); i++ { + if value[i] == '\\' && i+1 < len(value) { + switch value[i+1] { + case 'n', 'N': + b.WriteByte('\n') + i++ + continue + case '\\', ';', ',': + b.WriteByte(value[i+1]) + i++ + continue + } + } + b.WriteByte(value[i]) + } + return b.String() +} + +func isDateOnlyValue(value string) bool { + if len(value) != 8 { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func writeDateProp(b *strings.Builder, name, value string, allDay bool) { + value = strings.TrimSpace(value) + if allDay || isDateOnlyValue(value) { + fmt.Fprintf(b, "%s;VALUE=DATE:%s\r\n", name, value) + return + } + fmt.Fprintf(b, "%s:%s\r\n", name, value) +} + func buildICS(event *Event) string { var b strings.Builder b.WriteString("BEGIN:VCALENDAR\r\n") @@ -326,12 +453,12 @@ func buildICS(event *Event) string { if event.UID != "" { b.WriteString(fmt.Sprintf("UID:%s\r\n", event.UID)) } - b.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", event.Summary)) + b.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", escapeICSText(event.Summary))) if event.Description != "" { - b.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", event.Description)) + b.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", escapeICSText(event.Description))) } if event.Location != "" { - b.WriteString(fmt.Sprintf("LOCATION:%s\r\n", event.Location)) + b.WriteString(fmt.Sprintf("LOCATION:%s\r\n", escapeICSText(event.Location))) } if event.Organizer != "" { b.WriteString(fmt.Sprintf("ORGANIZER:mailto:%s\r\n", event.Organizer)) @@ -357,8 +484,25 @@ func buildICS(event *Event) string { b.WriteString(fmt.Sprintf("URL:%s\r\n", strings.TrimSpace(event.MeetURL))) b.WriteString(fmt.Sprintf("X-ULTI-MEET-URL:%s\r\n", strings.TrimSpace(event.MeetURL))) } - b.WriteString(fmt.Sprintf("DTSTART:%s\r\n", event.Start)) - b.WriteString(fmt.Sprintf("DTEND:%s\r\n", event.End)) + if strings.TrimSpace(event.Color) != "" { + b.WriteString(fmt.Sprintf("COLOR:%s\r\n", strings.TrimSpace(event.Color))) + } + if strings.TrimSpace(event.RRule) != "" { + b.WriteString(fmt.Sprintf("RRULE:%s\r\n", strings.TrimSpace(event.RRule))) + } + for _, exDate := range event.ExDates { + exDate = strings.TrimSpace(exDate) + if exDate == "" { + continue + } + if isDateOnlyValue(exDate) { + b.WriteString(fmt.Sprintf("EXDATE;VALUE=DATE:%s\r\n", exDate)) + } else { + b.WriteString(fmt.Sprintf("EXDATE:%s\r\n", exDate)) + } + } + writeDateProp(&b, "DTSTART", event.Start, event.AllDay) + writeDateProp(&b, "DTEND", event.End, event.AllDay) b.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", time.Now().UTC().Format("20060102T150405Z"))) b.WriteString("END:VEVENT\r\n") b.WriteString("END:VCALENDAR\r\n") @@ -408,31 +552,144 @@ func parseEventList(body io.Reader) ([]Event, error) { return events, nil } +// unfoldICSLines splits raw ICS into logical lines, joining folded +// continuation lines (RFC 5545 §3.1: lines starting with space or tab). +func unfoldICSLines(ics string) []string { + raw := strings.Split(strings.ReplaceAll(ics, "\r\n", "\n"), "\n") + lines := make([]string, 0, len(raw)) + for _, l := range raw { + if (strings.HasPrefix(l, " ") || strings.HasPrefix(l, "\t")) && len(lines) > 0 { + lines[len(lines)-1] += strings.TrimLeft(l, " \t") + continue + } + l = strings.TrimRight(l, "\r") + if l != "" { + lines = append(lines, l) + } + } + return lines +} + +// parsePropLine splits "NAME;PARAM=V;PARAM2=V2:value" into name, params, value. +func parsePropLine(line string) (name string, params map[string]string, value string) { + head, val, ok := strings.Cut(line, ":") + if !ok { + return strings.ToUpper(line), nil, "" + } + parts := strings.Split(head, ";") + name = strings.ToUpper(strings.TrimSpace(parts[0])) + if len(parts) > 1 { + params = make(map[string]string, len(parts)-1) + for _, p := range parts[1:] { + k, v, found := strings.Cut(p, "=") + if !found { + continue + } + params[strings.ToUpper(strings.TrimSpace(k))] = strings.Trim(strings.TrimSpace(v), "\"") + } + } + return name, params, val +} + +// normalizeICSDate converts a DTSTART/DTEND value to either a date-only value +// (YYYYMMDD) or a UTC datetime (YYYYMMDDTHHMMSSZ) when a TZID is provided. +// Floating times without TZID are returned unchanged. +func normalizeICSDate(value string, params map[string]string) (normalized string, allDay bool) { + value = strings.TrimSpace(value) + if params["VALUE"] == "DATE" || isDateOnlyValue(value) { + return value, true + } + if strings.HasSuffix(value, "Z") { + return value, false + } + if tzid := params["TZID"]; tzid != "" { + if loc, err := time.LoadLocation(tzid); err == nil { + if t, err := time.ParseInLocation("20060102T150405", value, loc); err == nil { + return t.UTC().Format("20060102T150405Z"), false + } + } + } + return value, false +} + +// extractVEventBlock returns the logical lines of the primary VEVENT in an ICS +// payload: the master component (no RECURRENCE-ID) when present, else the first. +func extractVEventBlock(lines []string) []string { + var blocks [][]string + var current []string + depth := 0 + for _, line := range lines { + upper := strings.ToUpper(line) + switch { + case upper == "BEGIN:VEVENT": + depth++ + current = nil + case upper == "END:VEVENT": + if depth > 0 { + blocks = append(blocks, current) + depth = 0 + } + case depth > 0: + current = append(current, line) + } + } + if len(blocks) == 0 { + return nil + } + for _, b := range blocks { + master := true + for _, line := range b { + if strings.HasPrefix(strings.ToUpper(line), "RECURRENCE-ID") { + master = false + break + } + } + if master { + return b + } + } + return blocks[0] +} + func parseICS(ics string) Event { var e Event - for _, line := range strings.Split(ics, "\n") { - line = strings.TrimSpace(line) - switch { - case strings.HasPrefix(line, "UID:"): - e.UID = strings.TrimPrefix(line, "UID:") - case strings.HasPrefix(line, "SUMMARY:"): - e.Summary = strings.TrimPrefix(line, "SUMMARY:") - case strings.HasPrefix(line, "DESCRIPTION:"): - e.Description = strings.TrimPrefix(line, "DESCRIPTION:") - case strings.HasPrefix(line, "LOCATION:"): - e.Location = strings.TrimPrefix(line, "LOCATION:") - case strings.HasPrefix(line, "ORGANIZER"): - e.Organizer = extractMailto(extractValue(line)) - case strings.HasPrefix(line, "ATTENDEE"): + block := extractVEventBlock(unfoldICSLines(ics)) + for _, line := range block { + name, params, value := parsePropLine(line) + switch name { + case "UID": + e.UID = value + case "SUMMARY": + e.Summary = unescapeICSText(value) + case "DESCRIPTION": + e.Description = unescapeICSText(value) + case "LOCATION": + e.Location = unescapeICSText(value) + case "ORGANIZER": + e.Organizer = extractMailto(value) + case "ATTENDEE": e.Attendees = append(e.Attendees, parseAttendeeLine(line)) - case strings.HasPrefix(line, "X-ULTI-MEET-URL:"): - e.MeetURL = strings.TrimPrefix(line, "X-ULTI-MEET-URL:") - case strings.HasPrefix(line, "URL:") && e.MeetURL == "": - e.MeetURL = strings.TrimPrefix(line, "URL:") - case strings.HasPrefix(line, "DTSTART"): - e.Start = extractValue(line) - case strings.HasPrefix(line, "DTEND"): - e.End = extractValue(line) + case "X-ULTI-MEET-URL": + e.MeetURL = value + case "URL": + if e.MeetURL == "" { + e.MeetURL = value + } + case "COLOR": + e.Color = value + case "RRULE": + e.RRule = value + case "EXDATE": + for _, ex := range strings.Split(value, ",") { + normalized, _ := normalizeICSDate(ex, params) + if normalized != "" { + e.ExDates = append(e.ExDates, normalized) + } + } + case "DTSTART": + e.Start, e.AllDay = normalizeICSDate(value, params) + case "DTEND": + e.End, _ = normalizeICSDate(value, params) } } return e diff --git a/internal/nextcloud/calendar_test.go b/internal/nextcloud/calendar_test.go new file mode 100644 index 0000000..55c04e8 --- /dev/null +++ b/internal/nextcloud/calendar_test.go @@ -0,0 +1,157 @@ +package nextcloud + +import ( + "strings" + "testing" +) + +func TestParseICSBasics(t *testing.T) { + ics := strings.Join([]string{ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "BEGIN:VTIMEZONE", + "TZID:Europe/Paris", + "BEGIN:STANDARD", + "DTSTART:19961027T030000", + "END:STANDARD", + "END:VTIMEZONE", + "BEGIN:VEVENT", + "UID:abc-123", + "SUMMARY:Réunion\\, équipe", + "DESCRIPTION:Ligne 1\\nLigne 2", + "DTSTART;TZID=Europe/Paris:20260611T100000", + "DTEND;TZID=Europe/Paris:20260611T110000", + "RRULE:FREQ=WEEKLY;BYDAY=TH", + "EXDATE;TZID=Europe/Paris:20260618T100000", + "END:VEVENT", + "END:VCALENDAR", + }, "\r\n") + + e := parseICS(ics) + if e.UID != "abc-123" { + t.Fatalf("UID = %q", e.UID) + } + if e.Summary != "Réunion, équipe" { + t.Fatalf("Summary = %q", e.Summary) + } + if e.Description != "Ligne 1\nLigne 2" { + t.Fatalf("Description = %q", e.Description) + } + // VTIMEZONE DTSTART must not leak into the event. + if e.Start != "20260611T080000Z" { + t.Fatalf("Start = %q, want UTC-converted 20260611T080000Z", e.Start) + } + if e.End != "20260611T090000Z" { + t.Fatalf("End = %q", e.End) + } + if e.AllDay { + t.Fatal("AllDay should be false") + } + if e.RRule != "FREQ=WEEKLY;BYDAY=TH" { + t.Fatalf("RRule = %q", e.RRule) + } + if len(e.ExDates) != 1 || e.ExDates[0] != "20260618T080000Z" { + t.Fatalf("ExDates = %v", e.ExDates) + } +} + +func TestParseICSAllDayAndFolding(t *testing.T) { + ics := strings.Join([]string{ + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + "UID:x", + "SUMMARY:Long title that", + " continues folded", + "DTSTART;VALUE=DATE:20260611", + "DTEND;VALUE=DATE:20260612", + "END:VEVENT", + "END:VCALENDAR", + }, "\r\n") + + e := parseICS(ics) + if !e.AllDay { + t.Fatal("AllDay should be true") + } + if e.Start != "20260611" || e.End != "20260612" { + t.Fatalf("Start/End = %q/%q", e.Start, e.End) + } + if e.Summary != "Long title thatcontinues folded" { + t.Fatalf("Summary = %q", e.Summary) + } +} + +func TestParseICSPrefersMasterVEvent(t *testing.T) { + ics := strings.Join([]string{ + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + "UID:rec", + "RECURRENCE-ID:20260618T100000Z", + "SUMMARY:Exception", + "DTSTART:20260618T120000Z", + "DTEND:20260618T130000Z", + "END:VEVENT", + "BEGIN:VEVENT", + "UID:rec", + "SUMMARY:Master", + "DTSTART:20260611T100000Z", + "DTEND:20260611T110000Z", + "RRULE:FREQ=DAILY", + "END:VEVENT", + "END:VCALENDAR", + }, "\r\n") + + e := parseICS(ics) + if e.Summary != "Master" { + t.Fatalf("Summary = %q, want master VEVENT", e.Summary) + } +} + +func TestBuildICSRoundTrip(t *testing.T) { + event := &Event{ + UID: "round-trip", + Summary: "Point; hebdo, équipe", + Description: "Ordre du jour:\npoint 1", + Location: "Salle A", + Start: "20260611T100000Z", + End: "20260611T110000Z", + RRule: "FREQ=WEEKLY;BYDAY=TH", + ExDates: []string{"20260618T100000Z"}, + Color: "#1A73E8", + Attendees: []EventAttendee{ + {Email: "invite@example.com", Name: "Invité", Status: "ACCEPTED"}, + }, + } + parsed := parseICS(buildICS(event)) + if parsed.Summary != event.Summary { + t.Fatalf("Summary = %q", parsed.Summary) + } + if parsed.Description != event.Description { + t.Fatalf("Description = %q", parsed.Description) + } + if parsed.RRule != event.RRule { + t.Fatalf("RRule = %q", parsed.RRule) + } + if parsed.Color != event.Color { + t.Fatalf("Color = %q", parsed.Color) + } + if len(parsed.ExDates) != 1 || parsed.ExDates[0] != "20260618T100000Z" { + t.Fatalf("ExDates = %v", parsed.ExDates) + } + if len(parsed.Attendees) != 1 || parsed.Attendees[0].Email != "invite@example.com" || parsed.Attendees[0].Status != "ACCEPTED" { + t.Fatalf("Attendees = %+v", parsed.Attendees) + } + if parsed.Start != event.Start || parsed.End != event.End { + t.Fatalf("Start/End = %q/%q", parsed.Start, parsed.End) + } +} + +func TestBuildICSAllDay(t *testing.T) { + ics := buildICS(&Event{UID: "ad", Summary: "Férié", Start: "20260714", End: "20260715", AllDay: true}) + if !strings.Contains(ics, "DTSTART;VALUE=DATE:20260714") { + t.Fatalf("missing all-day DTSTART:\n%s", ics) + } + parsed := parseICS(ics) + if !parsed.AllDay { + t.Fatal("AllDay should round-trip") + } +}