508 lines
13 KiB
Go
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")
|
|
}
|