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")
+ }
+}