package web import ( "context" "net/http" "regexp" "strings" "github.com/yourusername/caldav-server/internal/db" "github.com/yourusername/caldav-server/internal/web/templates" ) // resourceNameRe restricts calendar/address book names to characters that // are safe as both a URL path segment and a filesystem directory name. var resourceNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) // hexColorRe validates the 6-digit hex color format produced by an HTML // , e.g. "#3b82f6". var hexColorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) // handleCalendarResource handles POST (create) and DELETE (remove) for // the current user's own calendars, mounted at /resources/calendar. func (s *Server) handleCalendarResource(w http.ResponseWriter, r *http.Request) { s.handleResource(w, r, "calendar") } func (s *Server) handleAddressBookResource(w http.ResponseWriter, r *http.Request) { s.handleResource(w, r, "addressbook") } // handleCalendarColor updates the color of one of the current user's own // calendars, mounted at /resources/calendar/color. It re-renders just // that calendar's card (not the whole list), since the set of cards // doesn't change. func (s *Server) handleCalendarColor(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } username := userFromContext(r.Context()) if err := r.ParseForm(); err != nil { http.Error(w, "invalid form", http.StatusBadRequest) return } name := strings.TrimSpace(r.PostForm.Get("name")) color := strings.TrimSpace(r.PostForm.Get("color")) if !resourceNameRe.MatchString(name) { http.Error(w, "name must be 1-64 letters, digits, '-' or '_'", http.StatusBadRequest) return } if color != "" && !hexColorRe.MatchString(color) { http.Error(w, "color must be a hex value like #3b82f6", http.StatusBadRequest) return } if err := s.dbase.SetCalendarColor(username, name, color); err != nil && err != db.ErrResourceNotFound { s.logger.Error("setting calendar color", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } card, err := s.resourceCardFor(username, "calendar", name) if err != nil { s.logger.Error("rendering resource card", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.ResourceCardView(card).Render(r.Context(), w) } // handleBirthdayColor updates the current user's display color for the // synthetic "Birthdays" calendar, mounted at /resources/birthdays/color. // It re-renders just that card (not the whole list), since the set of // cards doesn't change. func (s *Server) handleBirthdayColor(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } username := userFromContext(r.Context()) if err := r.ParseForm(); err != nil { http.Error(w, "invalid form", http.StatusBadRequest) return } color := strings.TrimSpace(r.PostForm.Get("color")) if color != "" && !hexColorRe.MatchString(color) { http.Error(w, "color must be a hex value like #3b82f6", http.StatusBadRequest) return } if err := s.dbase.SetBirthdayCalendarColor(username, color); err != nil { s.logger.Error("setting birthday calendar color", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } card := templates.ResourceCard{Kind: "calendar", Name: "Birthdays", Color: s.birthdayCalendarColor(username), Virtual: true} w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.ResourceCardView(card).Render(r.Context(), w) } func (s *Server) handleResource(w http.ResponseWriter, r *http.Request, kind string) { username := userFromContext(r.Context()) // htmx v2 sends DELETE request parameters as URL query parameters, not // a request body — unlike POST/PUT/PATCH (see internal/web/shares.go). if r.Method == http.MethodDelete { r.PostForm = r.URL.Query() } else if err := r.ParseForm(); err != nil { http.Error(w, "invalid form", http.StatusBadRequest) return } name := strings.TrimSpace(r.PostForm.Get("name")) if !resourceNameRe.MatchString(name) { http.Error(w, "name must be 1-64 letters, digits, '-' or '_'", http.StatusBadRequest) return } collPrefix := "col/calendars/" if kind == "addressbook" { collPrefix = "col/addressbooks/" } switch r.Method { case http.MethodPost: var err error if kind == "calendar" { color := strings.TrimSpace(r.PostForm.Get("color")) if color != "" && !hexColorRe.MatchString(color) { http.Error(w, "color must be a hex value like #3b82f6", http.StatusBadRequest) return } err = s.dbase.CreateCalendarWithColor(username, name, color) } else { err = s.dbase.CreateAddressBook(username, name) } if err != nil { if err == db.ErrResourceExists { http.Error(w, "already exists", http.StatusConflict) return } if err == db.ErrReservedName { http.Error(w, "this name is reserved for a computed calendar", http.StatusConflict) return } s.logger.Error("creating resource", "kind", kind, "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if err := s.store.EnsureCollection(username, collPrefix+name); err != nil { s.logger.Error("creating resource storage", "kind", kind, "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } case http.MethodDelete: var err error if kind == "calendar" { err = s.dbase.DeleteCalendar(username, name) } else { err = s.dbase.DeleteAddressBook(username, name) } if err != nil && err != db.ErrResourceNotFound { s.logger.Error("deleting resource", "kind", kind, "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if err := s.store.DeleteCollection(username, collPrefix+name); err != nil { s.logger.Warn("deleting resource storage", "kind", kind, "error", err) } default: w.Header().Set("Allow", "POST, DELETE") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // The set of cards changed (one added/removed), so re-render the // whole #resources list rather than a single card. resources, err := s.resourceCards(username) if err != nil { s.logger.Error("listing resources", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.ResourceList(resources).Render(context.Background(), w) }