308 lines
7.8 KiB
Go
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
|
|
}
|