package calendar import ( "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"` } 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 }