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
}