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") }