package web import ( "context" "net/http" "strings" "git.arnef.de/arnef/nidus/internal/db" "git.arnef.de/arnef/nidus/internal/web/templates" ) // icsURLValid does a light sanity check on a subscription URL: it must be // http(s):// (fetched directly) or webcal:// (rewritten to https:// by // internal/icssub before fetching), and within a reasonable length. func icsURLValid(u string) bool { if len(u) == 0 || len(u) > 2048 { return false } return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "webcal://") } // handleICSResource handles POST (create) and DELETE (remove) for the // current user's ICS/webcal calendar subscriptions, mounted at // /resources/ics. func (s *Server) handleICSResource(w http.ResponseWriter, r *http.Request) { username := userFromContext(r.Context()) // htmx v2 sends DELETE request parameters as URL query parameters, not // a request body (see internal/web/resources.go's handleResource). 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 } switch r.Method { case http.MethodPost: url := strings.TrimSpace(r.PostForm.Get("url")) if !icsURLValid(url) { http.Error(w, "url must be a http://, https:// or webcal:// address", 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.CreateICSSubscription(username, name, url, color); 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 ics subscription", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } case http.MethodDelete: if err := s.dbase.DeleteICSSubscription(username, name); err != nil && err != db.ErrResourceNotFound { s.logger.Error("deleting ics subscription", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } 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) } // handleICSColor updates the color of one of the current user's ICS // subscriptions, mounted at /resources/ics/color. It re-renders just that // card (not the whole list), since the set of cards doesn't change. func (s *Server) handleICSColor(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.SetICSSubscriptionColor(username, name, color); err != nil && err != db.ErrResourceNotFound { s.logger.Error("setting ics subscription color", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } sub, err := s.dbase.GetICSSubscription(username, name) if err != nil { s.logger.Error("loading ics subscription", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } card := templates.ResourceCard{Kind: "ics", Name: sub.Name, Color: sub.Color, URL: sub.URL} w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.ResourceCardView(card).Render(r.Context(), w) }