489 lines
12 KiB
Go
489 lines
12 KiB
Go
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
|
|
} |