274 lines
7.9 KiB
Go
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
|
|
}
|