package main import ( "context" "errors" "fmt" "net/http" "net/url" "regexp" "strings" "time" "github.com/go-chi/chi/v5" ) var slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) func (a *app) createPage(w http.ResponseWriter, r *http.Request) { var payload map[string]any if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid payload") return } doc, err := a.preparePagePayload(Doc(payload), "", false) if err != nil { a.writePageValidationError(w, err) return } doc, err = a.create("pages", doc) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create resource") return } writeJSON(w, http.StatusCreated, doc) } func (a *app) patchPage(w http.ResponseWriter, r *http.Request) { 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 } expectedUpdatedAt, hasExpectedUpdatedAt, err := optionalExpectedUpdatedAt(patch) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } delete(patch, "expectedUpdatedAt") doc, unset, err := a.preparePagePatchPayload(Doc(patch), id) if err != nil { a.writePageValidationError(w, err) return } if hasExpectedUpdatedAt { doc, err = a.patchPageAtRevision(id, expectedUpdatedAt, doc, unset) } else { doc, err = a.patchWithUnset("pages", id, doc, unset) } if err != nil { if errors.Is(err, ErrNotFound) { if hasExpectedUpdatedAt { if _, loadErr := a.getByID("pages", id); loadErr == nil { writeError(w, http.StatusConflict, "page changed since it was opened; reload before saving") return } } writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to update resource") return } writeJSON(w, http.StatusOK, doc) } func optionalExpectedUpdatedAt(payload map[string]any) (int64, bool, error) { value, exists := payload["expectedUpdatedAt"] if !exists { return 0, false, nil } number, ok := numericValue(value) if !ok || number <= 0 || number != float64(int64(number)) { return 0, false, errors.New("expectedUpdatedAt must be a positive integer") } return int64(number), true, nil } func (a *app) patchPageAtRevision(id string, expectedUpdatedAt int64, patch, unset Doc) (Doc, error) { collection, err := a.resolveCollection("pages") if err != nil { return nil, err } delete(patch, "id") delete(patch, "createdAt") patch["updatedAt"] = time.Now().UnixMilli() update := Doc{"$set": patch} if len(unset) > 0 { update["$unset"] = unset } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() var updated Doc err = collection.FindOneAndUpdate( ctx, Doc{"id": id, "updatedAt": expectedUpdatedAt}, update, NewFindOneAndUpdateOptions().SetReturnDocument(ReturnAfter), ).Decode(&updated) if err != nil { return nil, err } normalizeDocument(updated) return updated, nil } func (a *app) getPublicPageBySlug(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } slug := normalizeSlug(chi.URLParam(r, "slug")) if slug == "" { writeError(w, http.StatusBadRequest, "slug is required") return } ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) defer cancel() var doc Doc err := a.db.Collection("pages").FindOne(ctx, Doc{"slug": slug}).Decode(&doc) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "page not found") return } writeError(w, http.StatusInternalServerError, "failed to load page") return } normalizeDocument(doc) if !isPublishedPublicPage(doc) { writeError(w, http.StatusNotFound, "page not found") return } writeJSON(w, http.StatusOK, doc) } func (a *app) preparePagePatchPayload(payload Doc, pageID string) (Doc, Doc, error) { if payload == nil { payload = Doc{} } unset := Doc{} if rawSlug, ok := payload["slug"]; ok { slug, isString := rawSlug.(string) if !isString { return nil, nil, validationError{field: "slug", message: "slug must be a string"} } if strings.TrimSpace(slug) == "" { unset["slug"] = "" delete(payload, "slug") } } if rawSEO, ok := payload["seo"]; ok { if rawSEO == nil { unset["seo"] = "" delete(payload, "seo") } else { seo, hasSEO, err := normalizeSEOMetadata(rawSEO) if err != nil { return nil, nil, err } if hasSEO { payload["seo"] = seo } else { unset["seo"] = "" delete(payload, "seo") } } } doc, err := a.preparePagePayload(payload, pageID, true) if err != nil { return nil, nil, err } return doc, unset, nil } func (a *app) preparePagePayload(payload Doc, pageID string, partial bool) (Doc, error) { if payload == nil { payload = Doc{} } name, hasName, err := getOptionalString(payload, "name") if err != nil { return nil, validationError{field: "name", message: "name must be a string"} } if !partial && (!hasName || name == "") { return nil, validationError{field: "name", message: "name is required"} } rawSlug, hasSlug, err := getOptionalString(payload, "slug") if err != nil { return nil, validationError{field: "slug", message: "slug must be a string"} } if !hasSlug && !partial && name != "" { rawSlug = slugify(name) hasSlug = true } if hasSlug { slug := normalizeSlug(rawSlug) if slug == "" { return nil, validationError{field: "slug", message: "slug is required"} } if !slugPattern.MatchString(slug) { return nil, validationError{field: "slug", message: "slug must contain lowercase letters, numbers, and hyphens only"} } inUse, err := a.pageSlugExists(slug, pageID) if err != nil { return nil, err } if inUse { return nil, validationError{field: "slug", message: "slug already exists"} } payload["slug"] = slug } seo, hasSEO, err := normalizeSEOMetadata(payload["seo"]) if err != nil { return nil, err } if hasSEO { payload["seo"] = seo } if payload["seo"] == nil { delete(payload, "seo") } return payload, nil } func (a *app) pageSlugExists(slug, excludeID string) (bool, error) { if a.pageSlugInUse != nil { return a.pageSlugInUse(slug, excludeID) } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() filter := Doc{"slug": slug} if excludeID != "" { filter["id"] = Doc{"$ne": excludeID} } count, err := a.db.Collection("pages").CountDocuments(ctx, filter) if err != nil { return false, fmt.Errorf("check page slug uniqueness: %w", err) } return count > 0, nil } type validationError struct { field string message string } func (e validationError) Error() string { return e.message } func (a *app) writePageValidationError(w http.ResponseWriter, err error) { var validationErr validationError if errors.As(err, &validationErr) { writeJSON(w, http.StatusBadRequest, map[string]any{ "error": validationErr.message, "field": validationErr.field, "message": validationErr.message, }) return } writeError(w, http.StatusInternalServerError, "failed to validate page payload") } func normalizeSEOMetadata(raw any) (Doc, bool, error) { if raw == nil { return nil, false, nil } meta, ok := raw.(map[string]any) if !ok { if docMeta, docOK := raw.(Doc); docOK { meta = map[string]any(docMeta) } else { return nil, false, validationError{field: "seo", message: "seo must be an object"} } } out := Doc{} for _, field := range []string{"title", "description", "canonicalUrl"} { value, exists, err := getOptionalString(meta, field) if err != nil { return nil, false, validationError{field: "seo." + field, message: field + " must be a string"} } if exists { out[field] = value } } if canonicalURL, ok := out["canonicalUrl"].(string); ok && canonicalURL != "" { if err := validateAbsoluteURL(canonicalURL); err != nil { return nil, false, validationError{field: "seo.canonicalUrl", message: "canonicalUrl must be an absolute http or https URL"} } } openGraph, hasOpenGraph, err := normalizeOpenGraph(meta["openGraph"]) if err != nil { return nil, false, err } if hasOpenGraph { out["openGraph"] = openGraph } robots, hasRobots, err := normalizeRobots(meta["robots"]) if err != nil { return nil, false, err } if hasRobots { out["robots"] = robots } if len(out) == 0 { return nil, false, nil } return out, true, nil } func normalizeOpenGraph(raw any) (Doc, bool, error) { if raw == nil { return nil, false, nil } meta, ok := raw.(map[string]any) if !ok { if docMeta, docOK := raw.(Doc); docOK { meta = map[string]any(docMeta) } else { return nil, false, validationError{field: "seo.openGraph", message: "openGraph must be an object"} } } out := Doc{} for _, field := range []string{"title", "description", "image", "url", "type"} { value, exists, err := getOptionalString(meta, field) if err != nil { return nil, false, validationError{field: "seo.openGraph." + field, message: field + " must be a string"} } if exists { out[field] = value } } for _, field := range []string{"image", "url"} { value, ok := out[field].(string) if ok && value != "" { if err := validateAbsoluteURL(value); err != nil { return nil, false, validationError{field: "seo.openGraph." + field, message: field + " must be an absolute http or https URL"} } } } if len(out) == 0 { return nil, false, nil } return out, true, nil } func normalizeRobots(raw any) (Doc, bool, error) { if raw == nil { return nil, false, nil } meta, ok := raw.(map[string]any) if !ok { if docMeta, docOK := raw.(Doc); docOK { meta = map[string]any(docMeta) } else { return nil, false, validationError{field: "seo.robots", message: "robots must be an object"} } } out := Doc{} for _, field := range []string{"index", "follow", "noarchive", "nosnippet"} { value, exists, err := getOptionalBool(meta, field) if err != nil { return nil, false, validationError{field: "seo.robots." + field, message: field + " must be a boolean"} } if exists { out[field] = value } } if len(out) == 0 { return nil, false, nil } return out, true, nil } func getOptionalString(payload map[string]any, field string) (string, bool, error) { raw, ok := payload[field] if !ok || raw == nil { return "", false, nil } value, ok := raw.(string) if !ok { return "", false, errors.New("not a string") } return strings.TrimSpace(value), true, nil } func getOptionalBool(payload map[string]any, field string) (bool, bool, error) { raw, ok := payload[field] if !ok || raw == nil { return false, false, nil } value, ok := raw.(bool) if !ok { return false, false, errors.New("not a boolean") } return value, true, nil } func normalizeSlug(value string) string { value = strings.TrimSpace(strings.ToLower(value)) value = strings.Trim(value, "/") value = strings.ReplaceAll(value, "_", "-") value = strings.Join(strings.Fields(value), "-") var builder strings.Builder lastHyphen := false for _, r := range value { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': builder.WriteRune(r) lastHyphen = false case r == '-': if builder.Len() > 0 && !lastHyphen { builder.WriteRune(r) lastHyphen = true } } } return strings.Trim(builder.String(), "-") } func slugify(value string) string { return normalizeSlug(value) } func validateAbsoluteURL(value string) error { parsed, err := url.Parse(value) if err != nil { return err } if parsed.Scheme != "http" && parsed.Scheme != "https" { return errors.New("unsupported scheme") } if parsed.Host == "" { return errors.New("missing host") } return nil }