Magnifique
Some checks failed
CI / Go tests (push) Has been cancelled
CI / Integration tests (push) Has been cancelled
CI / DB migrations (push) Has been cancelled

This commit is contained in:
R3D347HR4Y 2026-06-11 10:11:03 +02:00
parent 0466a1c169
commit 1fda9e7bac
6 changed files with 784 additions and 35 deletions

View File

@ -357,6 +357,20 @@ server {
proxy_set_header Connection $connection_upgrade; 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 # Console d'administration suite
location ^~ /admin { location ^~ /admin {
resolver 127.0.0.11 valid=10s ipv6=off; resolver 127.0.0.11 valid=10s ipv6=off;

View File

@ -1,6 +1,7 @@
package calendar package calendar
import ( import (
"context"
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
@ -12,6 +13,7 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/auth"
meetpkg "github.com/ultisuite/ulti-backend/internal/meet" meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
"github.com/ultisuite/ulti-backend/internal/nextcloud" "github.com/ultisuite/ulti-backend/internal/nextcloud"
"github.com/ultisuite/ulti-backend/internal/permission" "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("/", h.ListCalendars)
r.With(read).Get("/{calID}/events", h.ListEvents) r.With(read).Get("/{calID}/events", h.ListEvents)
r.With(read).Post("/freebusy", h.FreeBusy) 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).Post("/{calID}/events", h.CreateEvent)
r.With(write).Put("/events/*", h.UpdateEvent) r.With(write).Put("/events/*", h.UpdateEvent)
r.With(write).Post("/events/response/*", h.RespondToInvitation) r.With(write).Post("/events/response/*", h.RespondToInvitation)
@ -45,9 +50,46 @@ func (h *Handler) Routes() chi.Router {
return r 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) { func (h *Handler) ListCalendars(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) 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 { if err != nil {
h.logger.Error("list calendars", "error", err) h.logger.Error("list calendars", "error", err)
apivalidate.WriteInternal(w, r) 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}) 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) { func (h *Handler) ListEvents(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
params, err := query.ParseListRequest(r) params, err := query.ParseListRequest(r)
if err != nil { if err != nil {
apivalidate.WriteQueryError(w, r, err) apivalidate.WriteQueryError(w, r, err)
return 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 { if err != nil {
h.logger.Error("list events", "error", err) h.logger.Error("list events", "error", err)
apivalidate.WriteInternal(w, r) 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) { func (h *Handler) FreeBusy(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
var req freeBusyRequest var req freeBusyRequest
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil { 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 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 { if err != nil {
h.logger.Error("free/busy", "error", err) h.logger.Error("free/busy", "error", err)
apivalidate.WriteInternal(w, r) 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) { func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
var event nextcloud.Event var event nextcloud.Event
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &event); err != nil { 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 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) h.logger.Error("create event", "error", err)
apivalidate.WriteInternal(w, r) apivalidate.WriteInternal(w, r)
return 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) { func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
eventPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/") eventPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/")
if verr := validateDeletePath(eventPath); verr != nil { if verr := validateDeletePath(eventPath); verr != nil {
apivalidate.WriteValidationError(w, r, verr) apivalidate.WriteValidationError(w, r, verr)
@ -139,7 +285,15 @@ func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
return 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 err != nil {
if errors.Is(err, nextcloud.ErrETagMismatch) { if errors.Is(err, nextcloud.ErrETagMismatch) {
apiresponse.WriteError(w, r, http.StatusPreconditionFailed, "etag_mismatch", "etag does not match current resource version", nil) 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) { func (h *Handler) RespondToInvitation(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
eventPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/") eventPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/")
if verr := validateDeletePath(eventPath); verr != nil { if verr := validateDeletePath(eventPath); verr != nil {
apivalidate.WriteValidationError(w, r, verr) apivalidate.WriteValidationError(w, r, verr)
@ -171,7 +329,12 @@ func (h *Handler) RespondToInvitation(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteValidationError(w, r, verr) apivalidate.WriteValidationError(w, r, verr)
return 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 { if err != nil {
switch { switch {
case errors.Is(err, nextcloud.ErrAttendeeNotFound): 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) { func (h *Handler) CreateMeetLink(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
eventPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/") eventPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/")
if verr := validateDeletePath(eventPath); verr != nil { if verr := validateDeletePath(eventPath); verr != nil {
apivalidate.WriteValidationError(w, r, verr) apivalidate.WriteValidationError(w, r, verr)
@ -204,7 +371,12 @@ func (h *Handler) CreateMeetLink(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteValidationError(w, r, verr) apivalidate.WriteValidationError(w, r, verr)
return 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 err != nil {
if errors.Is(err, ErrMeetDisabled) { if errors.Is(err, ErrMeetDisabled) {
apiresponse.WriteError(w, r, http.StatusConflict, "meet_disabled", "meet integration is disabled", nil) 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) { func (h *Handler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
eventPath := chi.URLParam(r, "*") eventPath := chi.URLParam(r, "*")
if verr := validateDeletePath(eventPath); verr != nil { if verr := validateDeletePath(eventPath); verr != nil {
apivalidate.WriteValidationError(w, r, verr) apivalidate.WriteValidationError(w, r, verr)
return 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) h.logger.Error("delete event", "error", err)
apivalidate.WriteInternal(w, r) apivalidate.WriteInternal(w, r)
return return

View File

@ -9,6 +9,7 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/paginate" "github.com/ultisuite/ulti-backend/internal/api/paginate"
"github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/auth"
meetpkg "github.com/ultisuite/ulti-backend/internal/meet" meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
"github.com/ultisuite/ulti-backend/internal/nextcloud" "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} 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 { func calendarPath(userID, calID string) string {
return "/remote.php/dav/calendars/" + userID + "/" + calID + "/" 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) 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 { type EventsList struct {
Events []nextcloud.Event `json:"events"` Events []nextcloud.Event `json:"events"`
Pagination query.PaginationMeta `json:"pagination,omitempty"` Pagination query.PaginationMeta `json:"pagination,omitempty"`

View File

@ -1,6 +1,7 @@
package calendar package calendar
import ( import (
"fmt"
"net/mail" "net/mail"
"strconv" "strconv"
"strings" "strings"
@ -34,6 +35,115 @@ type createMeetLinkRequest struct {
IfMatch string `json:"if_match,omitempty"` 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) { func validateFreeBusy(req *freeBusyRequest) (*nextcloud.FreeBusyRequest, *apivalidate.ValidationError) {
var details []apivalidate.FieldDetail var details []apivalidate.FieldDetail

View File

@ -31,6 +31,9 @@ type Event struct {
Organizer string `json:"organizer,omitempty"` Organizer string `json:"organizer,omitempty"`
Attendees []EventAttendee `json:"attendees,omitempty"` Attendees []EventAttendee `json:"attendees,omitempty"`
MeetURL string `json:"meet_url,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"` 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) 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("<d:displayname>%s</d:displayname>", xmlEscape(displayName)))
if strings.TrimSpace(color) != "" {
props.WriteString(fmt.Sprintf("<a:calendar-color>%s</a:calendar-color>", xmlEscape(color)))
}
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/">
<d:set><d:prop>%s</d:prop></d:set>
</c:mkcalendar>`, 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("<d:displayname>%s</d:displayname>", xmlEscape(displayName)))
}
if strings.TrimSpace(color) != "" {
props.WriteString(fmt.Sprintf("<a:calendar-color>%s</a:calendar-color>", xmlEscape(color)))
}
if props.Len() == 0 {
return nil
}
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:a="http://apple.com/ns/ical/">
<d:set><d:prop>%s</d:prop></d:set>
</d:propertyupdate>`, 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) { func (c *Client) ListEvents(ctx context.Context, userID, calendarPath string, from, to time.Time) ([]Event, error) {
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?> body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> <c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
@ -317,6 +391,59 @@ func extractMailto(value string) string {
return value 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 { func buildICS(event *Event) string {
var b strings.Builder var b strings.Builder
b.WriteString("BEGIN:VCALENDAR\r\n") b.WriteString("BEGIN:VCALENDAR\r\n")
@ -326,12 +453,12 @@ func buildICS(event *Event) string {
if event.UID != "" { if event.UID != "" {
b.WriteString(fmt.Sprintf("UID:%s\r\n", 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 != "" { 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 != "" { 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 != "" { if event.Organizer != "" {
b.WriteString(fmt.Sprintf("ORGANIZER:mailto:%s\r\n", 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("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("X-ULTI-MEET-URL:%s\r\n", strings.TrimSpace(event.MeetURL)))
} }
b.WriteString(fmt.Sprintf("DTSTART:%s\r\n", event.Start)) if strings.TrimSpace(event.Color) != "" {
b.WriteString(fmt.Sprintf("DTEND:%s\r\n", event.End)) 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(fmt.Sprintf("DTSTAMP:%s\r\n", time.Now().UTC().Format("20060102T150405Z")))
b.WriteString("END:VEVENT\r\n") b.WriteString("END:VEVENT\r\n")
b.WriteString("END:VCALENDAR\r\n") b.WriteString("END:VCALENDAR\r\n")
@ -408,31 +552,144 @@ func parseEventList(body io.Reader) ([]Event, error) {
return events, nil 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 { func parseICS(ics string) Event {
var e Event var e Event
for _, line := range strings.Split(ics, "\n") { block := extractVEventBlock(unfoldICSLines(ics))
line = strings.TrimSpace(line) for _, line := range block {
switch { name, params, value := parsePropLine(line)
case strings.HasPrefix(line, "UID:"): switch name {
e.UID = strings.TrimPrefix(line, "UID:") case "UID":
case strings.HasPrefix(line, "SUMMARY:"): e.UID = value
e.Summary = strings.TrimPrefix(line, "SUMMARY:") case "SUMMARY":
case strings.HasPrefix(line, "DESCRIPTION:"): e.Summary = unescapeICSText(value)
e.Description = strings.TrimPrefix(line, "DESCRIPTION:") case "DESCRIPTION":
case strings.HasPrefix(line, "LOCATION:"): e.Description = unescapeICSText(value)
e.Location = strings.TrimPrefix(line, "LOCATION:") case "LOCATION":
case strings.HasPrefix(line, "ORGANIZER"): e.Location = unescapeICSText(value)
e.Organizer = extractMailto(extractValue(line)) case "ORGANIZER":
case strings.HasPrefix(line, "ATTENDEE"): e.Organizer = extractMailto(value)
case "ATTENDEE":
e.Attendees = append(e.Attendees, parseAttendeeLine(line)) e.Attendees = append(e.Attendees, parseAttendeeLine(line))
case strings.HasPrefix(line, "X-ULTI-MEET-URL:"): case "X-ULTI-MEET-URL":
e.MeetURL = strings.TrimPrefix(line, "X-ULTI-MEET-URL:") e.MeetURL = value
case strings.HasPrefix(line, "URL:") && e.MeetURL == "": case "URL":
e.MeetURL = strings.TrimPrefix(line, "URL:") if e.MeetURL == "" {
case strings.HasPrefix(line, "DTSTART"): e.MeetURL = value
e.Start = extractValue(line) }
case strings.HasPrefix(line, "DTEND"): case "COLOR":
e.End = extractValue(line) 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 return e

View File

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