ultisuite-backend/internal/api/calendar/validate.go
R3D347HR4Y 1fda9e7bac
Some checks failed
CI / Go tests (push) Has been cancelled
CI / Integration tests (push) Has been cancelled
CI / DB migrations (push) Has been cancelled
Magnifique
2026-06-11 10:11:03 +02:00

274 lines
7.9 KiB
Go

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
}