package calendar import ( "fmt" "net/mail" "strconv" "strings" "time" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/nextcloud" ) const maxRequestBody = 256 << 10 type freeBusyRequest struct { Start string `json:"start"` End string `json:"end"` Attendees []string `json:"attendees"` } type respondInvitationRequest struct { Email string `json:"email"` Response string `json:"response"` IfMatch string `json:"if_match,omitempty"` } type normalizedInvitationResponse struct { Email string Response string IfMatch string } 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 start, startDetail := parseRFC3339Field("start", req.Start) if startDetail != nil { details = append(details, *startDetail) } end, endDetail := parseRFC3339Field("end", req.End) if endDetail != nil { details = append(details, *endDetail) } if startDetail == nil && endDetail == nil && !start.Before(end) { details = append(details, apivalidate.FieldDetail{Field: "end", Message: "must be after start"}) } if len(req.Attendees) == 0 { details = append(details, apivalidate.FieldDetail{Field: "attendees", Message: "required"}) } else { for i, addr := range req.Attendees { if detail := validateAttendeeEmail(addr); detail != nil { detail.Field = "attendees[" + strconv.Itoa(i) + "]" details = append(details, *detail) } } } if len(details) > 0 { return nil, apivalidate.NewValidationError(details...) } return &nextcloud.FreeBusyRequest{ Start: start, End: end, Attendees: req.Attendees, }, nil } func parseRFC3339Field(field, raw string) (time.Time, *apivalidate.FieldDetail) { raw = strings.TrimSpace(raw) if raw == "" { return time.Time{}, &apivalidate.FieldDetail{Field: field, Message: "required"} } t, err := time.Parse(time.RFC3339, raw) if err != nil { return time.Time{}, &apivalidate.FieldDetail{Field: field, Message: "invalid RFC3339 timestamp"} } return t.UTC(), nil } func validateAttendeeEmail(addr string) *apivalidate.FieldDetail { addr = strings.TrimSpace(addr) if addr == "" { return &apivalidate.FieldDetail{Field: "email", Message: "required"} } if len(addr) > 320 || strings.ContainsAny(addr, "\r\n") { return &apivalidate.FieldDetail{Field: "email", Message: "invalid"} } parsed, err := mail.ParseAddress(addr) if err != nil || parsed.Address == "" { return &apivalidate.FieldDetail{Field: "email", Message: "invalid"} } return nil } func validateRespondInvitation(req *respondInvitationRequest) (*normalizedInvitationResponse, *apivalidate.ValidationError) { var details []apivalidate.FieldDetail email := strings.TrimSpace(req.Email) if detail := validateAttendeeEmail(email); detail != nil { detail.Field = "email" details = append(details, *detail) } resp := strings.ToLower(strings.TrimSpace(req.Response)) switch resp { case "accepted", "declined", "tentative": default: details = append(details, apivalidate.FieldDetail{Field: "response", Message: "must be one of accepted|declined|tentative"}) } if strings.ContainsAny(req.IfMatch, "\r\n") { details = append(details, apivalidate.FieldDetail{Field: "if_match", Message: "invalid"}) } if len(details) > 0 { return nil, apivalidate.NewValidationError(details...) } return &normalizedInvitationResponse{ Email: email, Response: strings.ToUpper(resp), IfMatch: strings.TrimSpace(req.IfMatch), }, nil } func validateCreateMeetLink(req *createMeetLinkRequest) *apivalidate.ValidationError { if strings.ContainsAny(req.IfMatch, "\r\n") { return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "if_match", Message: "invalid"}) } return nil } func validateCreateEvent(event *nextcloud.Event) *apivalidate.ValidationError { var details []apivalidate.FieldDetail if strings.TrimSpace(event.Summary) == "" { details = append(details, apivalidate.FieldDetail{Field: "summary", Message: "required"}) } if strings.TrimSpace(event.Start) == "" { details = append(details, apivalidate.FieldDetail{Field: "start", Message: "required"}) } if strings.TrimSpace(event.End) == "" { details = append(details, apivalidate.FieldDetail{Field: "end", Message: "required"}) } for i, attendee := range event.Attendees { if detail := validateAttendeeEmail(attendee.Email); detail != nil { detail.Field = "attendees[" + strconv.Itoa(i) + "].email" details = append(details, *detail) } } if len(details) == 0 { return nil } return apivalidate.NewValidationError(details...) } func validateDeletePath(path string) *apivalidate.ValidationError { if strings.TrimSpace(path) == "" { return apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "path", Message: "required", }) } return nil }