508 lines
13 KiB
Go

package main
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
const (
blogStatusDraft = "draft"
blogStatusScheduled = "scheduled"
blogStatusPublished = "published"
)
var blogPostPublishingPolicy = blogPublishingPolicy{}
type blogPublishingPolicy struct{}
func (a *app) blogEditorMiddleware(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") && !hasRole(claims, "editor") {
writeError(w, http.StatusForbidden, "insufficient role for blog management")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) listBlogPosts(w http.ResponseWriter, r *http.Request) {
a.listCollection("blog_posts", w)
}
func (a *app) createBlogPost(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 := prepareBlogPostPayload(Doc(payload), false, nil)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err = a.create("blog_posts", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create resource")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) getBlogPost(w http.ResponseWriter, r *http.Request) {
a.getCollection("blog_posts", w, r)
}
func (a *app) patchBlogPost(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
}
existing, err := a.getByID("blog_posts", id)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load resource")
return
}
doc, err := prepareBlogPostPayload(Doc(patch), true, existing)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err = a.patch("blog_posts", 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) deleteBlogPost(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("blog_posts", w, r)
}
func (a *app) listBlogCategories(w http.ResponseWriter, r *http.Request) {
a.listCollection("blog_categories", w)
}
func (a *app) createBlogCategory(w http.ResponseWriter, r *http.Request) {
a.createCollection("blog_categories", w, r)
}
func (a *app) getBlogCategory(w http.ResponseWriter, r *http.Request) {
a.getCollection("blog_categories", w, r)
}
func (a *app) patchBlogCategory(w http.ResponseWriter, r *http.Request) {
a.patchCollection("blog_categories", w, r)
}
func (a *app) deleteBlogCategory(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("blog_categories", w, r)
}
func (a *app) listBlogTags(w http.ResponseWriter, r *http.Request) {
a.listCollection("blog_tags", w)
}
func (a *app) createBlogTag(w http.ResponseWriter, r *http.Request) {
a.createCollection("blog_tags", w, r)
}
func (a *app) getBlogTag(w http.ResponseWriter, r *http.Request) {
a.getCollection("blog_tags", w, r)
}
func (a *app) patchBlogTag(w http.ResponseWriter, r *http.Request) {
a.patchCollection("blog_tags", w, r)
}
func (a *app) deleteBlogTag(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("blog_tags", w, r)
}
func (a *app) listBlogAuthors(w http.ResponseWriter, r *http.Request) {
a.listCollection("blog_authors", w)
}
func (a *app) createBlogAuthor(w http.ResponseWriter, r *http.Request) {
a.createCollection("blog_authors", w, r)
}
func (a *app) getBlogAuthor(w http.ResponseWriter, r *http.Request) {
a.getCollection("blog_authors", w, r)
}
func (a *app) patchBlogAuthor(w http.ResponseWriter, r *http.Request) {
a.patchCollection("blog_authors", w, r)
}
func (a *app) deleteBlogAuthor(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("blog_authors", w, r)
}
func (a *app) listPublicBlogPosts(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
docs, err := a.list("blog_posts", Doc{}, Sort{{Key: "publishedAt", Value: -1}, {Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list blog posts")
return
}
now := time.Now().UTC()
posts := make([]Doc, 0, len(docs))
for _, doc := range docs {
if isPublicBlogPost(doc, now) {
posts = append(posts, doc)
}
}
writeJSON(w, http.StatusOK, posts)
}
func (a *app) getPublicBlogPostBySlug(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
slug := strings.TrimSpace(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("blog_posts").FindOne(ctx, Doc{"slug": slug}).Decode(&doc)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "blog post not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load blog post")
return
}
normalizeDocument(doc)
if !isPublicBlogPost(doc, time.Now().UTC()) {
writeError(w, http.StatusNotFound, "blog post not found")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) listPublicBlogCategories(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
posts, err := a.list("blog_posts", Doc{}, Sort{{Key: "publishedAt", Value: -1}, {Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load blog posts")
return
}
allowed := collectPublicBlogTaxonomyValues(posts, "categories", time.Now().UTC())
docs, err := a.list("blog_categories", Doc{}, Sort{{Key: "name", Value: 1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list blog categories")
return
}
writeJSON(w, http.StatusOK, filterBlogTaxonomyDocuments(docs, allowed))
}
func (a *app) listPublicBlogTags(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
posts, err := a.list("blog_posts", Doc{}, Sort{{Key: "publishedAt", Value: -1}, {Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load blog posts")
return
}
allowed := collectPublicBlogTaxonomyValues(posts, "tags", time.Now().UTC())
docs, err := a.list("blog_tags", Doc{}, Sort{{Key: "name", Value: 1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list blog tags")
return
}
writeJSON(w, http.StatusOK, filterBlogTaxonomyDocuments(docs, allowed))
}
func collectPublicBlogTaxonomyValues(posts []Doc, field string, now time.Time) map[string]struct{} {
allowed := make(map[string]struct{})
for _, post := range posts {
if !isPublicBlogPost(post, now) {
continue
}
values, hasValues, err := normalizeStringSlice(post[field])
if err != nil || !hasValues {
continue
}
for _, value := range values {
normalized := normalizeTaxonomyValue(value)
if normalized == "" {
continue
}
allowed[normalized] = struct{}{}
}
}
return allowed
}
func filterBlogTaxonomyDocuments(docs []Doc, allowed map[string]struct{}) []Doc {
if len(allowed) == 0 {
return []Doc{}
}
filtered := make([]Doc, 0, len(docs))
for _, doc := range docs {
if taxonomyDocumentAllowed(doc, allowed) {
filtered = append(filtered, doc)
}
}
return filtered
}
func taxonomyDocumentAllowed(doc Doc, allowed map[string]struct{}) bool {
for _, field := range []string{"id", "slug", "name"} {
value, ok := asString(doc[field])
if !ok {
continue
}
if _, exists := allowed[normalizeTaxonomyValue(value)]; exists {
return true
}
}
return false
}
func normalizeTaxonomyValue(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func prepareBlogPostPayload(payload Doc, partial bool, existing Doc) (Doc, error) {
if payload == nil {
payload = Doc{}
}
title, hasTitle, err := getOptionalString(payload, "title")
if err != nil {
return nil, errors.New("title must be a string")
}
if !partial && (!hasTitle || title == "") {
return nil, errors.New("title is required")
}
if hasTitle {
payload["title"] = title
}
if err := applyPublishingPolicy(payload, partial, existing, blogPostPublishingPolicy); err != nil {
return nil, err
}
if slug, hasSlug, err := getOptionalString(payload, "slug"); err != nil {
return nil, errors.New("slug must be a string")
} else if hasSlug {
payload["slug"] = normalizeSlug(slug)
}
for _, field := range []string{"excerpt", "content", "authorId"} {
value, has, err := getOptionalString(payload, field)
if err != nil {
return nil, errors.New(field + " must be a string")
}
if has {
payload[field] = value
}
}
if categories, has, err := normalizeStringSlice(payload["categories"]); err != nil {
return nil, errors.New("categories must be an array of strings")
} else if has {
payload["categories"] = categories
}
if tags, has, err := normalizeStringSlice(payload["tags"]); err != nil {
return nil, errors.New("tags must be an array of strings")
} else if has {
payload["tags"] = tags
}
if publishedAt, has, err := parseOptionalTime(payload["publishedAt"]); err != nil {
return nil, errors.New("publishedAt must be a unix milliseconds number or RFC3339 string")
} else if has {
payload["publishedAt"] = publishedAt.UnixMilli()
}
return payload, nil
}
func (blogPublishingPolicy) defaultStatus() string {
return blogStatusDraft
}
func (blogPublishingPolicy) isAllowedStatus(status string) bool {
return isValidBlogStatus(status)
}
func (blogPublishingPolicy) invalidStatusMessage() string {
return "status must be one of: draft, scheduled, published"
}
func (blogPublishingPolicy) validate(payload Doc, existing Doc) error {
if status, ok := mergedPublishingString(payload, existing, "status"); ok && status == blogStatusScheduled {
if _, has := mergedPublishingValue(payload, existing, "publishedAt"); !has {
return errors.New("publishedAt is required when status is scheduled")
}
}
if status, ok := mergedPublishingString(payload, existing, "status"); ok && status == blogStatusPublished {
if _, has := mergedPublishingString(payload, existing, "slug"); !has {
return errors.New("slug is required when status is published")
}
}
return nil
}
func isPublicBlogPost(post Doc, now time.Time) bool {
status, ok := post["status"].(string)
normalizedStatus := strings.TrimSpace(strings.ToLower(status))
if !ok || (normalizedStatus != blogStatusPublished && normalizedStatus != blogStatusScheduled) {
return false
}
slug, ok := asString(post["slug"])
if !ok || slug == "" {
return false
}
if private, ok := asBool(post["private"]); ok && private {
return false
}
if visibility, ok := asString(post["visibility"]); ok && strings.EqualFold(visibility, "private") {
return false
}
publishedAt, hasPublishedAt, err := parseOptionalTime(post["publishedAt"])
if err == nil && hasPublishedAt {
return !publishedAt.After(now)
}
if normalizedStatus == blogStatusScheduled {
return false
}
return true
}
func isValidBlogStatus(status string) bool {
switch strings.TrimSpace(strings.ToLower(status)) {
case blogStatusDraft, blogStatusScheduled, blogStatusPublished:
return true
default:
return false
}
}
func normalizeStringSlice(raw any) ([]string, bool, error) {
if raw == nil {
return nil, false, nil
}
switch values := raw.(type) {
case []string:
out := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out, true, nil
case List:
out := make([]string, 0, len(values))
for _, value := range values {
text, ok := value.(string)
if !ok {
return nil, false, errors.New("array must contain strings")
}
trimmed := strings.TrimSpace(text)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out, true, nil
case []any:
out := make([]string, 0, len(values))
for _, value := range values {
text, ok := value.(string)
if !ok {
return nil, false, errors.New("array must contain strings")
}
trimmed := strings.TrimSpace(text)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out, true, nil
default:
return nil, false, errors.New("not an array")
}
}
func parseOptionalTime(raw any) (time.Time, bool, error) {
if raw == nil {
return time.Time{}, false, nil
}
if parsed, ok := asTime(raw); ok {
return parsed.UTC(), true, nil
}
return time.Time{}, false, errors.New("invalid time")
}