308 lines
7.8 KiB
Go

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
}