package main import ( "context" "errors" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) const ( sitemapTargetPage = "page" sitemapTargetPost = "post" sitemapTargetCourse = "course" ) var allowedSitemapTargetTypes = map[string]string{ sitemapTargetPage: "pages", sitemapTargetPost: "blog_posts", sitemapTargetCourse: "live_courses", } func (a *app) sitemapMenusAdminMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if claims == nil { writeError(w, http.StatusUnauthorized, "no authenticated user") return } if !hasRole(claims, "admin") { writeError(w, http.StatusForbidden, "insufficient role for sitemap menu management") return } next.ServeHTTP(w, r) }) } func (a *app) listSitemapMenus(w http.ResponseWriter, r *http.Request) { a.listCollection("sitemap_menus", w) } func (a *app) createSitemapMenu(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } var payload map[string]any if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid payload") return } doc, err := prepareSitemapMenuPayload(Doc(payload), false) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } items, _, _ := normalizeSitemapMenuItems(doc["items"]) if err := a.validateSitemapMenuTargets(items); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } doc, err = a.create("sitemap_menus", doc) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create resource") return } writeJSON(w, http.StatusCreated, doc) } func (a *app) getSitemapMenu(w http.ResponseWriter, r *http.Request) { a.getCollection("sitemap_menus", w, r) } func (a *app) patchSitemapMenu(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } id := chi.URLParam(r, "id") var patch map[string]any if err := decodeJSON(r, &patch); err != nil { writeError(w, http.StatusBadRequest, "invalid patch payload") return } doc, err := prepareSitemapMenuPayload(Doc(patch), true) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if rawItems, hasItems := doc["items"]; hasItems { items, _, err := normalizeSitemapMenuItems(rawItems) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if err := a.validateSitemapMenuTargets(items); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } } doc, err = a.patch("sitemap_menus", id, doc) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to update resource") return } writeJSON(w, http.StatusOK, doc) } func (a *app) deleteSitemapMenu(w http.ResponseWriter, r *http.Request) { a.deleteCollection("sitemap_menus", w, r) } func prepareSitemapMenuPayload(payload Doc, partial bool) (Doc, error) { if payload == nil { payload = Doc{} } name, hasName, err := getOptionalString(payload, "name") if err != nil { return nil, errors.New("name must be a string") } if !partial && (!hasName || name == "") { return nil, errors.New("name is required") } if hasName { payload["name"] = name } description, hasDescription, err := getOptionalString(payload, "description") if err != nil { return nil, errors.New("description must be a string") } if hasDescription { payload["description"] = description } items, hasItems, err := normalizeSitemapMenuItems(payload["items"]) if err != nil { return nil, err } if !partial && !hasItems { payload["items"] = []Doc{} } else if hasItems { payload["items"] = items } return payload, nil } func normalizeSitemapMenuItems(raw any) ([]Doc, bool, error) { if raw == nil { return nil, false, nil } var values []any switch source := raw.(type) { case []any: values = source case List: values = []any(source) case []Doc: values = make([]any, 0, len(source)) for _, item := range source { values = append(values, item) } default: return nil, false, errors.New("items must be an array") } normalized := make([]Doc, 0, len(values)) for _, item := range values { itemMap, ok := item.(map[string]any) if !ok { bsonItem, docOK := item.(Doc) if !docOK { return nil, false, errors.New("menu items must be objects") } itemMap = map[string]any(bsonItem) } labelRaw, hasLabel := itemMap["label"] if !hasLabel { return nil, false, errors.New("menu item label is required") } label, ok := labelRaw.(string) if !ok || strings.TrimSpace(label) == "" { return nil, false, errors.New("menu item label must be a string") } targetTypeRaw, hasTargetType := itemMap["targetType"] if !hasTargetType { return nil, false, errors.New("menu item targetType is required") } targetType, ok := targetTypeRaw.(string) if !ok { return nil, false, errors.New("menu item targetType must be a string") } targetType = strings.ToLower(strings.TrimSpace(targetType)) if _, allowed := allowedSitemapTargetTypes[targetType]; !allowed { return nil, false, errors.New("menu item targetType is invalid") } targetIDRaw, hasTargetID := itemMap["targetId"] if !hasTargetID { return nil, false, errors.New("menu item targetId is required") } targetID, ok := targetIDRaw.(string) if !ok || strings.TrimSpace(targetID) == "" { return nil, false, errors.New("menu item targetId must be a string") } toRaw, hasTo := itemMap["to"] if !hasTo { return nil, false, errors.New("menu item to is required") } to, ok := toRaw.(string) if !ok || strings.TrimSpace(to) == "" || !strings.HasPrefix(strings.TrimSpace(to), "/") { return nil, false, errors.New("menu item to must be an absolute path") } normalizedItem := Doc{ "id": normalizeSitemapItemID(itemMap["id"]), "label": strings.TrimSpace(label), "targetType": targetType, "targetId": strings.TrimSpace(targetID), "to": strings.TrimSpace(to), } if childrenRaw, hasChildren := itemMap["children"]; hasChildren { children, _, err := normalizeSitemapMenuItems(childrenRaw) if err != nil { return nil, false, err } normalizedItem["children"] = children } else { normalizedItem["children"] = []Doc{} } normalized = append(normalized, normalizedItem) } return normalized, true, nil } func normalizeSitemapItemID(raw any) string { id, ok := raw.(string) if ok && strings.TrimSpace(id) != "" { return strings.TrimSpace(id) } return uuid.NewString() } func (a *app) validateSitemapMenuTargets(items []Doc) error { for _, item := range items { targetType, _ := item["targetType"].(string) targetID, _ := item["targetId"].(string) if err := a.validateSitemapTarget(targetType, targetID); err != nil { return err } if childAny, hasChildren := item["children"]; hasChildren { children, _, err := normalizeSitemapMenuItems(childAny) if err != nil { return err } if err := a.validateSitemapMenuTargets(children); err != nil { return err } } } return nil } func (a *app) validateSitemapTarget(targetType, targetID string) error { collection, ok := allowedSitemapTargetTypes[targetType] if !ok { return errors.New("menu item targetType is invalid") } ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() count, err := a.db.Collection(collection).CountDocuments(ctx, Doc{"id": targetID}) if err != nil { return errors.New("failed to validate menu target") } if count == 0 { return errors.New("menu target not found: " + targetType + ":" + targetID) } return nil }