Files
web-hosts/domains/coppertone.tech/backend/functions/blog-service/main.go
2025-12-26 13:38:04 +01:00

2018 lines
63 KiB
Go

package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"github.com/golang-jwt/jwt/v5"
_ "github.com/lib/pq"
)
// Rate limiting configuration
const (
rateLimitWindow = 1 * time.Minute
maxWriteRequests = 30 // Max write requests (POST/PUT/DELETE) per minute per IP
maxReadRequests = 100 // Max read requests per minute per IP
maxRequestBody = 1 << 20 // 1MB max request body size
)
// Input validation limits
const (
maxTitleLength = 200
maxSlugLength = 100
maxAuthorLength = 100
maxShortDescriptionLength = 500
maxContentLength = 100000 // ~100KB for blog content
maxTagLength = 50
maxTagsCount = 20
maxNotesLength = 2000
)
// rateLimiter tracks requests per IP
type rateLimiter struct {
mu sync.RWMutex
requests map[string]*requestInfo
}
type requestInfo struct {
count int
firstReq time.Time
}
var writeLimiter = &rateLimiter{requests: make(map[string]*requestInfo)}
var readLimiter = &rateLimiter{requests: make(map[string]*requestInfo)}
func (rl *rateLimiter) checkRateLimit(key string, maxRequests int, window time.Duration) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
info, exists := rl.requests[key]
if !exists {
rl.requests[key] = &requestInfo{count: 1, firstReq: now}
return false
}
if now.Sub(info.firstReq) > window {
info.count = 1
info.firstReq = now
return false
}
info.count++
return info.count > maxRequests
}
func getClientIP(r *http.Request) string {
xff := r.Header.Get("X-Forwarded-For")
if xff != "" {
if idx := strings.Index(xff, ","); idx != -1 {
return strings.TrimSpace(xff[:idx])
}
return strings.TrimSpace(xff)
}
xri := r.Header.Get("X-Real-IP")
if xri != "" {
return strings.TrimSpace(xri)
}
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return ip
}
// Blog status constants
const (
StatusDraft = "DRAFT" // Initial state, only author can see
StatusPendingReview = "PENDING_REVIEW" // Submitted for admin review
StatusApproved = "APPROVED" // Approved by admin, ready to publish
StatusPublished = "PUBLISHED" // Live and visible to public
StatusRejected = "REJECTED" // Rejected by admin with feedback
StatusArchived = "ARCHIVED" // Hidden from public, preserved
)
// Blog type constants - SITE blogs are admin/staff content, USER blogs are community content
const (
BlogTypeSite = "SITE" // Official site blogs (admin/staff authored)
BlogTypeUser = "USER" // Community blogs (user authored, separate section)
)
// Context keys
type contextKey string
var userContextKey = contextKey("userClaims")
type Blog struct {
ID int `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author"`
AuthorID int `json:"authorId"`
Date time.Time `json:"date"`
Tags []string `json:"tags"`
ShortDescription string `json:"short_description"`
Content string `json:"content"`
Status string `json:"status"`
BlogType string `json:"blogType"` // "SITE" or "USER"
Verified bool `json:"verified"` // True if admin-verified content (for USER blogs/tutorials)
VerifiedBy *int `json:"verifiedBy,omitempty"`
VerifiedAt *time.Time `json:"verifiedAt,omitempty"`
ReviewedBy *int `json:"reviewedBy,omitempty"`
ReviewedAt *time.Time `json:"reviewedAt,omitempty"`
ReviewNotes string `json:"reviewNotes,omitempty"`
PublishedAt *time.Time `json:"publishedAt,omitempty"`
PromotedAt *time.Time `json:"promotedAt,omitempty"` // When USER blog was promoted to SITE
PromotedBy *int `json:"promotedBy,omitempty"` // Admin who promoted
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateBlogRequest struct {
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author"`
Date string `json:"date"`
Tags []string `json:"tags"`
ShortDescription string `json:"short_description"`
Content string `json:"content"`
}
type UpdateBlogRequest struct {
Title string `json:"title"`
Author string `json:"author"`
Date string `json:"date"`
Tags []string `json:"tags"`
ShortDescription string `json:"short_description"`
Content string `json:"content"`
}
type ReviewRequest struct {
Action string `json:"action"` // "approve", "reject", "request_changes"
Notes string `json:"notes"` // Feedback for the author
}
type StatusChangeRequest struct {
Status string `json:"status"` // Target status
}
var (
db *sql.DB
jwtSecret []byte
)
func initDB() {
var err error
dbHost := strings.TrimSpace(os.Getenv("DB_HOST"))
dbUser := strings.TrimSpace(os.Getenv("DB_USER"))
dbPassword := strings.TrimSpace(os.Getenv("DB_PASSWORD"))
dbName := strings.TrimSpace(os.Getenv("DB_NAME"))
dbSSLMode := strings.TrimSpace(os.Getenv("DB_SSL_MODE"))
dbSchema := strings.TrimSpace(os.Getenv("DB_SCHEMA"))
if dbHost == "" || dbUser == "" || dbPassword == "" || dbName == "" {
log.Fatal("Database configuration missing: DB_HOST, DB_USER, DB_PASSWORD, DB_NAME required")
}
if dbSSLMode == "" {
dbSSLMode = "require"
log.Println("WARNING: DB_SSL_MODE not set, defaulting to 'require' for security")
}
validSSLModes := map[string]bool{"disable": true, "require": true, "verify-ca": true, "verify-full": true}
if !validSSLModes[dbSSLMode] {
log.Fatalf("Invalid DB_SSL_MODE '%s'. Must be: disable, require, verify-ca, or verify-full", dbSSLMode)
}
if dbSSLMode == "disable" {
log.Println("WARNING: Database SSL is DISABLED. This should only be used for local development!")
}
// Validate schema value if provided
validSchemas := map[string]bool{"": true, "public": true, "dev": true, "testing": true, "prod": true}
if !validSchemas[dbSchema] {
log.Fatalf("Invalid DB_SCHEMA '%s'. Must be: dev, testing, prod, or empty for public", dbSchema)
}
connStr := fmt.Sprintf("host=%s user=%s password=%s dbname=%s sslmode=%s", dbHost, dbUser, dbPassword, dbName, dbSSLMode)
if dbSchema != "" && dbSchema != "public" {
connStr += fmt.Sprintf(" search_path=%s,public", dbSchema)
}
db, err = sql.Open("postgres", connStr)
if err != nil {
log.Fatal(err)
}
// Configure connection pool limits
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(1 * time.Minute)
if err = db.Ping(); err != nil {
log.Fatal(err)
}
schemaInfo := "public"
if dbSchema != "" && dbSchema != "public" {
schemaInfo = dbSchema
}
log.Printf("Connected to database (SSL mode: %s, schema: %s, max_conns: 25)", dbSSLMode, schemaInfo)
// Create/update tables with new schema
createTablesSQL := `
CREATE TABLE IF NOT EXISTS blog_posts (
id SERIAL PRIMARY KEY,
slug VARCHAR(255) UNIQUE NOT NULL,
title VARCHAR(500) NOT NULL,
author VARCHAR(255) NOT NULL,
author_id INTEGER NOT NULL,
date TIMESTAMP NOT NULL,
tags TEXT[] DEFAULT '{}',
short_description TEXT NOT NULL,
content TEXT NOT NULL,
status VARCHAR(50) DEFAULT 'DRAFT',
blog_type VARCHAR(20) DEFAULT 'SITE',
verified BOOLEAN DEFAULT FALSE,
verified_by INTEGER,
verified_at TIMESTAMP,
reviewed_by INTEGER,
reviewed_at TIMESTAMP,
review_notes TEXT DEFAULT '',
published_at TIMESTAMP,
promoted_at TIMESTAMP,
promoted_by INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_blog_slug ON blog_posts(slug);
CREATE INDEX IF NOT EXISTS idx_blog_status ON blog_posts(status);
CREATE INDEX IF NOT EXISTS idx_blog_author_id ON blog_posts(author_id);
CREATE INDEX IF NOT EXISTS idx_blog_date ON blog_posts(date DESC);
CREATE INDEX IF NOT EXISTS idx_blog_type ON blog_posts(blog_type);
CREATE INDEX IF NOT EXISTS idx_blog_verified ON blog_posts(verified);
`
if _, err := db.Exec(createTablesSQL); err != nil {
log.Fatal("Failed to create tables:", err)
}
// Migration: Add new columns if they don't exist (for existing databases)
migrations := []string{
"ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS author_id INTEGER DEFAULT 0",
"ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS status VARCHAR(50) DEFAULT 'DRAFT'",
"ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS blog_type VARCHAR(20) DEFAULT 'SITE'",
"ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS verified BOOLEAN DEFAULT FALSE",
"ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS verified_by INTEGER",
"ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS verified_at TIMESTAMP",
"ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS reviewed_by INTEGER",
"ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMP",
"ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS review_notes TEXT DEFAULT ''",
"ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS published_at TIMESTAMP",
"ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS promoted_at TIMESTAMP",
"ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS promoted_by INTEGER",
// Migrate old published boolean to new status
"UPDATE blog_posts SET status = 'PUBLISHED', published_at = updated_at WHERE published = true AND status = 'DRAFT'",
// All existing blogs default to SITE type and verified (since they were created by staff/admin)
"UPDATE blog_posts SET blog_type = 'SITE' WHERE blog_type IS NULL",
"UPDATE blog_posts SET verified = TRUE WHERE blog_type = 'SITE'",
}
for _, migration := range migrations {
if _, err := db.Exec(migration); err != nil {
// Ignore errors for columns that already exist
if !strings.Contains(err.Error(), "already exists") {
log.Printf("Migration warning: %v", err)
}
}
}
log.Println("Database tables initialized")
}
func loadConfig() {
jwtSecret = []byte(strings.TrimSpace(os.Getenv("JWT_SECRET")))
if len(jwtSecret) < 32 {
log.Fatal("JWT_SECRET must be set and at least 32 characters for authentication")
}
}
func enableCORS(w http.ResponseWriter) {
corsOrigin := strings.TrimSpace(os.Getenv("CORS_ALLOW_ORIGIN"))
if corsOrigin == "" {
corsOrigin = "http://localhost:8090"
}
w.Header().Set("Access-Control-Allow-Origin", corsOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// Security headers
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy", "default-src 'self'")
}
// rateLimitMiddleware applies rate limiting based on request method
func rateLimitMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
clientIP := getClientIP(r)
// Apply different limits for read vs write operations
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if readLimiter.checkRateLimit(clientIP, maxReadRequests, rateLimitWindow) {
log.Printf("SECURITY: Read rate limit exceeded for IP %s", clientIP)
http.Error(w, "Too many requests. Please slow down.", http.StatusTooManyRequests)
return
}
} else if r.Method != http.MethodOptions {
if writeLimiter.checkRateLimit(clientIP, maxWriteRequests, rateLimitWindow) {
log.Printf("SECURITY: Write rate limit exceeded for IP %s", clientIP)
http.Error(w, "Too many requests. Please slow down.", http.StatusTooManyRequests)
return
}
}
next.ServeHTTP(w, r)
}
}
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Authorization header required", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
http.Error(w, "Invalid authorization format. Expected: Bearer <token>", http.StatusUnauthorized)
return
}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtSecret, nil
})
if err != nil || !token.Valid {
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userContextKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
}
}
func requireRole(next http.HandlerFunc, allowedRoles ...string) http.HandlerFunc {
return authMiddleware(func(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userRoles, err := extractRoles(claims)
if err != nil {
http.Error(w, "No roles found in token", http.StatusForbidden)
return
}
for _, userRole := range userRoles {
for _, allowedRole := range allowedRoles {
if userRole == allowedRole {
next.ServeHTTP(w, r)
return
}
}
}
http.Error(w, "Insufficient permissions", http.StatusForbidden)
})
}
func extractRoles(claims jwt.MapClaims) ([]string, error) {
rawRoles, ok := claims["roles"]
if !ok {
return nil, errors.New("roles missing")
}
switch v := rawRoles.(type) {
case []interface{}:
out := make([]string, 0, len(v))
for _, r := range v {
roleStr, ok := r.(string)
if !ok {
return nil, errors.New("role value not string")
}
out = append(out, roleStr)
}
return out, nil
case []string:
return v, nil
default:
return nil, errors.New("roles claim type invalid")
}
}
func hasRole(claims jwt.MapClaims, role string) bool {
roles, err := extractRoles(claims)
if err != nil {
return false
}
for _, r := range roles {
// SUPERUSER has all permissions
if r == "SUPERUSER" {
return true
}
if r == role {
return true
}
}
return false
}
func getUserID(claims jwt.MapClaims) int {
if id, ok := claims["userId"].(float64); ok {
return int(id)
}
if id, ok := claims["user_id"].(float64); ok {
return int(id)
}
return 0
}
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, map[string]string{"error": message})
}
func parseTags(tagsStr string) []string {
if tagsStr == "" || tagsStr == "{}" {
return []string{}
}
tagsStr = strings.Trim(tagsStr, "{}")
if tagsStr == "" {
return []string{}
}
return strings.Split(tagsStr, ",")
}
func tagsToPostgres(tags []string) string {
if len(tags) == 0 {
return "{}"
}
return "{" + strings.Join(tags, ",") + "}"
}
func scanBlog(row interface{ Scan(...interface{}) error }) (*Blog, error) {
var blog Blog
var tags string
var blogType sql.NullString
var verified sql.NullBool
var verifiedBy sql.NullInt64
var verifiedAt sql.NullTime
var reviewedBy sql.NullInt64
var reviewedAt sql.NullTime
var reviewNotes sql.NullString
var publishedAt sql.NullTime
var promotedAt sql.NullTime
var promotedBy sql.NullInt64
err := row.Scan(
&blog.ID, &blog.Slug, &blog.Title, &blog.Author, &blog.AuthorID,
&blog.Date, &tags, &blog.ShortDescription, &blog.Content,
&blog.Status, &blogType, &verified, &verifiedBy, &verifiedAt,
&reviewedBy, &reviewedAt, &reviewNotes, &publishedAt,
&promotedAt, &promotedBy, &blog.CreatedAt, &blog.UpdatedAt,
)
if err != nil {
return nil, err
}
blog.Tags = parseTags(tags)
blog.BlogType = BlogTypeSite // Default
if blogType.Valid {
blog.BlogType = blogType.String
}
blog.Verified = verified.Valid && verified.Bool
if verifiedBy.Valid {
id := int(verifiedBy.Int64)
blog.VerifiedBy = &id
}
if verifiedAt.Valid {
blog.VerifiedAt = &verifiedAt.Time
}
if reviewedBy.Valid {
id := int(reviewedBy.Int64)
blog.ReviewedBy = &id
}
if reviewedAt.Valid {
blog.ReviewedAt = &reviewedAt.Time
}
if reviewNotes.Valid {
blog.ReviewNotes = reviewNotes.String
}
if publishedAt.Valid {
blog.PublishedAt = &publishedAt.Time
}
if promotedAt.Valid {
blog.PromotedAt = &promotedAt.Time
}
if promotedBy.Valid {
id := int(promotedBy.Int64)
blog.PromotedBy = &id
}
return &blog, nil
}
// ============ PUBLIC ENDPOINTS ============
// blogSelectColumns is the standard column list for blog queries
const blogSelectColumns = `id, slug, title, author, author_id, date, tags, short_description, content,
status, blog_type, verified, verified_by, verified_at, reviewed_by, reviewed_at, review_notes, published_at, promoted_at, promoted_by, created_at, updated_at`
// GET /blogs - List published SITE blogs only (public - official content)
func listPublishedBlogsHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
// Only show SITE blogs (admin/staff official content) - USER blogs are in /community/blogs
query := `SELECT ` + blogSelectColumns + `
FROM blog_posts WHERE status = $1 AND blog_type = $2 ORDER BY published_at DESC, date DESC`
rows, err := db.Query(query, StatusPublished, BlogTypeSite)
if err != nil {
log.Println("Database error:", err)
respondError(w, http.StatusInternalServerError, "Failed to fetch blogs")
return
}
defer rows.Close()
blogs := []Blog{}
for rows.Next() {
blog, err := scanBlog(rows)
if err != nil {
log.Println("Scan error:", err)
continue
}
// Don't expose review details to public
blog.ReviewedBy = nil
blog.ReviewedAt = nil
blog.ReviewNotes = ""
blog.PromotedBy = nil
blogs = append(blogs, *blog)
}
if err = rows.Err(); err != nil {
log.Println("Rows iteration error:", err)
respondError(w, http.StatusInternalServerError, "Failed to fetch blogs")
return
}
respondJSON(w, http.StatusOK, blogs)
}
// GET /blogs/:slug - Get published SITE blog by slug (public - official content)
func getPublishedBlogHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
slug := strings.TrimPrefix(r.URL.Path, "/blogs/")
if slug == "" {
respondError(w, http.StatusBadRequest, "Slug is required")
return
}
// Only return SITE blogs from /blogs endpoint
query := `SELECT ` + blogSelectColumns + `
FROM blog_posts WHERE slug = $1 AND status = $2 AND blog_type = $3`
blog, err := scanBlog(db.QueryRow(query, slug, StatusPublished, BlogTypeSite))
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "Blog not found")
return
} else if err != nil {
log.Println("Database error:", err)
respondError(w, http.StatusInternalServerError, "Failed to fetch blog")
return
}
// Don't expose review details to public
blog.ReviewedBy = nil
blog.ReviewedAt = nil
blog.ReviewNotes = ""
blog.PromotedBy = nil
respondJSON(w, http.StatusOK, blog)
}
// ============ STAFF/ADMIN ENDPOINTS ============
// GET /admin/blogs - List all SITE blogs with filters (STAFF sees own, ADMIN sees all)
func listAllBlogsHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
isAdmin := hasRole(claims, "ADMIN")
status := r.URL.Query().Get("status")
authorID := r.URL.Query().Get("author_id")
// Admin panel only shows SITE blogs - user community blogs have separate endpoints
query := `SELECT ` + blogSelectColumns + ` FROM blog_posts WHERE blog_type = $1`
args := []interface{}{BlogTypeSite}
argNum := 2
// STAFF can only see their own blogs unless they're ADMIN
if !isAdmin {
query += fmt.Sprintf(" AND author_id = $%d", argNum)
args = append(args, userID)
argNum++
} else if authorID != "" {
query += fmt.Sprintf(" AND author_id = $%d", argNum)
args = append(args, authorID)
argNum++
}
if status != "" {
query += fmt.Sprintf(" AND status = $%d", argNum)
args = append(args, status)
argNum++
}
query += " ORDER BY updated_at DESC"
rows, err := db.Query(query, args...)
if err != nil {
log.Println("Database error:", err)
respondError(w, http.StatusInternalServerError, "Failed to fetch blogs")
return
}
defer rows.Close()
blogs := []Blog{}
for rows.Next() {
blog, err := scanBlog(rows)
if err != nil {
log.Println("Scan error:", err)
continue
}
blogs = append(blogs, *blog)
}
if err = rows.Err(); err != nil {
log.Println("Rows iteration error:", err)
}
respondJSON(w, http.StatusOK, blogs)
}
// GET /admin/blogs/pending - List SITE blogs pending review (ADMIN only)
func listPendingReviewHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
// Only show SITE blogs pending review (staff submissions)
query := `SELECT ` + blogSelectColumns + `
FROM blog_posts WHERE status = $1 AND blog_type = $2 ORDER BY updated_at ASC`
rows, err := db.Query(query, StatusPendingReview, BlogTypeSite)
if err != nil {
log.Println("Database error:", err)
respondError(w, http.StatusInternalServerError, "Failed to fetch blogs")
return
}
defer rows.Close()
blogs := []Blog{}
for rows.Next() {
blog, err := scanBlog(rows)
if err != nil {
log.Println("Scan error:", err)
continue
}
blogs = append(blogs, *blog)
}
if err = rows.Err(); err != nil {
log.Println("Rows iteration error:", err)
}
respondJSON(w, http.StatusOK, blogs)
}
// POST /admin/blogs - Create a new SITE blog (STAFF creates as DRAFT, ADMIN can create as any status)
func createBlogHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
isAdmin := hasRole(claims, "ADMIN")
var req CreateBlogRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.Slug == "" || req.Title == "" || req.ShortDescription == "" || req.Content == "" {
respondError(w, http.StatusBadRequest, "Missing required fields: slug, title, short_description, content")
return
}
// Validate input lengths
if len(req.Slug) > maxSlugLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Slug must be less than %d characters", maxSlugLength))
return
}
if len(req.Title) > maxTitleLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Title must be less than %d characters", maxTitleLength))
return
}
if len(req.ShortDescription) > maxShortDescriptionLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Short description must be less than %d characters", maxShortDescriptionLength))
return
}
if len(req.Content) > maxContentLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Content must be less than %d characters", maxContentLength))
return
}
if len(req.Author) > maxAuthorLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Author name must be less than %d characters", maxAuthorLength))
return
}
if len(req.Tags) > maxTagsCount {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Maximum %d tags allowed", maxTagsCount))
return
}
for _, tag := range req.Tags {
if len(tag) > maxTagLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Each tag must be less than %d characters", maxTagLength))
return
}
}
// Use provided author name or default
author := req.Author
if author == "" {
author = "Staff Writer"
}
var date time.Time
if req.Date != "" {
var err error
date, err = time.Parse(time.RFC3339, req.Date)
if err != nil {
date = time.Now()
}
} else {
date = time.Now()
}
// STAFF always creates as DRAFT, ADMIN can create directly as PUBLISHED
status := StatusDraft
// Admin/staff blogs are always SITE type - USER type is for community blogs
query := `INSERT INTO blog_posts (slug, title, author, author_id, date, tags, short_description, content, status, blog_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, created_at, updated_at`
var blog Blog
err := db.QueryRow(query, req.Slug, req.Title, author, userID, date, tagsToPostgres(req.Tags), req.ShortDescription, req.Content, status, BlogTypeSite).
Scan(&blog.ID, &blog.CreatedAt, &blog.UpdatedAt)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
respondError(w, http.StatusConflict, "Blog with this slug already exists")
return
}
log.Println("Database error:", err)
respondError(w, http.StatusInternalServerError, "Failed to create blog")
return
}
blog.Slug = req.Slug
blog.Title = req.Title
blog.Author = author
blog.AuthorID = userID
blog.Date = date
blog.Tags = req.Tags
blog.ShortDescription = req.ShortDescription
blog.Content = req.Content
blog.Status = status
blog.BlogType = BlogTypeSite
log.Printf("AUDIT: User %d created SITE blog '%s' (status: %s, isAdmin: %v)", userID, req.Slug, status, isAdmin)
respondJSON(w, http.StatusCreated, blog)
}
// PUT /admin/blogs/:slug - Update a blog (author can update own DRAFT/REJECTED, ADMIN can update any)
func updateBlogHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
isAdmin := hasRole(claims, "ADMIN")
slug := strings.TrimPrefix(r.URL.Path, "/admin/blogs/")
if slug == "" {
respondError(w, http.StatusBadRequest, "Slug is required")
return
}
// First check if blog exists and user has permission
var existingAuthorID int
var existingStatus string
err := db.QueryRow("SELECT author_id, status FROM blog_posts WHERE slug = $1", slug).Scan(&existingAuthorID, &existingStatus)
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "Blog not found")
return
} else if err != nil {
respondError(w, http.StatusInternalServerError, "Database error")
return
}
// STAFF can only edit their own blogs that are in DRAFT or REJECTED status
if !isAdmin {
if existingAuthorID != userID {
respondError(w, http.StatusForbidden, "You can only edit your own blogs")
return
}
if existingStatus != StatusDraft && existingStatus != StatusRejected {
respondError(w, http.StatusForbidden, "You can only edit blogs in DRAFT or REJECTED status")
return
}
}
var req UpdateBlogRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Validate input lengths for admin blog updates
if len(req.Title) > maxTitleLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Title must be less than %d characters", maxTitleLength))
return
}
if len(req.ShortDescription) > maxShortDescriptionLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Short description must be less than %d characters", maxShortDescriptionLength))
return
}
if len(req.Content) > maxContentLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Content must be less than %d characters", maxContentLength))
return
}
if len(req.Author) > maxAuthorLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Author name must be less than %d characters", maxAuthorLength))
return
}
if len(req.Tags) > maxTagsCount {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Maximum %d tags allowed", maxTagsCount))
return
}
for _, tag := range req.Tags {
if len(tag) > maxTagLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Each tag must be less than %d characters", maxTagLength))
return
}
}
var date time.Time
if req.Date != "" {
date, err = time.Parse(time.RFC3339, req.Date)
if err != nil {
date = time.Now()
}
} else {
date = time.Now()
}
// If STAFF edits a REJECTED blog, reset it to DRAFT
newStatus := existingStatus
if !isAdmin && existingStatus == StatusRejected {
newStatus = StatusDraft
}
query := `UPDATE blog_posts SET title = $1, author = $2, date = $3, tags = $4,
short_description = $5, content = $6, status = $7, updated_at = CURRENT_TIMESTAMP
WHERE slug = $8`
result, err := db.Exec(query, req.Title, req.Author, date, tagsToPostgres(req.Tags), req.ShortDescription, req.Content, newStatus, slug)
if err != nil {
log.Println("Database error:", err)
respondError(w, http.StatusInternalServerError, "Failed to update blog")
return
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
respondError(w, http.StatusNotFound, "Blog not found")
return
}
log.Printf("AUDIT: User %d updated blog '%s' (new status: %s)", userID, slug, newStatus)
respondJSON(w, http.StatusOK, map[string]string{"message": "Blog updated successfully", "status": newStatus})
}
// POST /admin/blogs/:slug/submit - Submit blog for review (STAFF only, moves DRAFT -> PENDING_REVIEW)
func submitForReviewHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
slug := strings.TrimPrefix(r.URL.Path, "/admin/blogs/")
slug = strings.TrimSuffix(slug, "/submit")
var existingAuthorID int
var existingStatus string
err := db.QueryRow("SELECT author_id, status FROM blog_posts WHERE slug = $1", slug).Scan(&existingAuthorID, &existingStatus)
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "Blog not found")
return
}
if existingAuthorID != userID {
respondError(w, http.StatusForbidden, "You can only submit your own blogs for review")
return
}
if existingStatus != StatusDraft && existingStatus != StatusRejected {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Blog must be in DRAFT or REJECTED status to submit for review (current: %s)", existingStatus))
return
}
_, err = db.Exec("UPDATE blog_posts SET status = $1, review_notes = '', updated_at = CURRENT_TIMESTAMP WHERE slug = $2",
StatusPendingReview, slug)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to submit blog for review")
return
}
log.Printf("AUDIT: User %d submitted blog '%s' for review", userID, slug)
respondJSON(w, http.StatusOK, map[string]string{"message": "Blog submitted for review", "status": StatusPendingReview})
}
// POST /admin/blogs/:slug/review - Review a blog (ADMIN only)
func reviewBlogHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
slug := strings.TrimPrefix(r.URL.Path, "/admin/blogs/")
slug = strings.TrimSuffix(slug, "/review")
var req ReviewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
var existingStatus string
err := db.QueryRow("SELECT status FROM blog_posts WHERE slug = $1", slug).Scan(&existingStatus)
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "Blog not found")
return
}
if existingStatus != StatusPendingReview {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Blog must be in PENDING_REVIEW status to review (current: %s)", existingStatus))
return
}
var newStatus string
switch req.Action {
case "approve":
newStatus = StatusApproved
case "reject":
newStatus = StatusRejected
if req.Notes == "" {
respondError(w, http.StatusBadRequest, "Rejection requires feedback notes")
return
}
case "request_changes":
newStatus = StatusRejected // Same as reject but with constructive feedback
if req.Notes == "" {
respondError(w, http.StatusBadRequest, "Requesting changes requires feedback notes")
return
}
default:
respondError(w, http.StatusBadRequest, "Invalid action. Must be: approve, reject, or request_changes")
return
}
_, err = db.Exec(`UPDATE blog_posts SET status = $1, reviewed_by = $2, reviewed_at = CURRENT_TIMESTAMP,
review_notes = $3, updated_at = CURRENT_TIMESTAMP WHERE slug = $4`,
newStatus, userID, req.Notes, slug)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to review blog")
return
}
log.Printf("AUDIT: Admin %d reviewed blog '%s' (action: %s, new status: %s)", userID, slug, req.Action, newStatus)
respondJSON(w, http.StatusOK, map[string]string{"message": fmt.Sprintf("Blog %s", req.Action), "status": newStatus})
}
// POST /admin/blogs/:slug/publish - Publish an approved blog (ADMIN only)
func publishBlogHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
slug := strings.TrimPrefix(r.URL.Path, "/admin/blogs/")
slug = strings.TrimSuffix(slug, "/publish")
var existingStatus string
err := db.QueryRow("SELECT status FROM blog_posts WHERE slug = $1", slug).Scan(&existingStatus)
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "Blog not found")
return
}
// ADMIN can publish from APPROVED status (normal flow) or DRAFT (skip review for admin-created content)
if existingStatus != StatusApproved && existingStatus != StatusDraft {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Blog must be in APPROVED or DRAFT status to publish (current: %s)", existingStatus))
return
}
_, err = db.Exec(`UPDATE blog_posts SET status = $1, published_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP WHERE slug = $2`,
StatusPublished, slug)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to publish blog")
return
}
log.Printf("AUDIT: Admin %d published blog '%s'", userID, slug)
respondJSON(w, http.StatusOK, map[string]string{"message": "Blog published successfully", "status": StatusPublished})
}
// POST /admin/blogs/:slug/unpublish - Unpublish a blog (ADMIN only, moves to ARCHIVED)
func unpublishBlogHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
slug := strings.TrimPrefix(r.URL.Path, "/admin/blogs/")
slug = strings.TrimSuffix(slug, "/unpublish")
var existingStatus string
err := db.QueryRow("SELECT status FROM blog_posts WHERE slug = $1", slug).Scan(&existingStatus)
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "Blog not found")
return
}
if existingStatus != StatusPublished {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Blog must be PUBLISHED to unpublish (current: %s)", existingStatus))
return
}
_, err = db.Exec("UPDATE blog_posts SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE slug = $2",
StatusArchived, slug)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to unpublish blog")
return
}
log.Printf("AUDIT: Admin %d unpublished/archived blog '%s'", userID, slug)
respondJSON(w, http.StatusOK, map[string]string{"message": "Blog unpublished and archived", "status": StatusArchived})
}
// DELETE /admin/blogs/:slug - Delete a blog (ADMIN only)
func deleteBlogHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
slug := strings.TrimPrefix(r.URL.Path, "/admin/blogs/")
result, err := db.Exec("DELETE FROM blog_posts WHERE slug = $1", slug)
if err != nil {
log.Println("Database error:", err)
respondError(w, http.StatusInternalServerError, "Failed to delete blog")
return
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
respondError(w, http.StatusNotFound, "Blog not found")
return
}
log.Printf("AUDIT: Admin %d deleted blog '%s'", userID, slug)
respondJSON(w, http.StatusOK, map[string]string{"message": "Blog deleted successfully"})
}
// ============ COMMUNITY BLOG ENDPOINTS ============
// These endpoints are completely separate from SITE blogs (admin/staff content)
// GET /community/blogs - List published USER blogs (public - community content)
func listCommunityBlogsHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
query := `SELECT ` + blogSelectColumns + `
FROM blog_posts WHERE status = $1 AND blog_type = $2 ORDER BY published_at DESC, date DESC`
rows, err := db.Query(query, StatusPublished, BlogTypeUser)
if err != nil {
log.Println("Database error:", err)
respondError(w, http.StatusInternalServerError, "Failed to fetch community blogs")
return
}
defer rows.Close()
blogs := []Blog{}
for rows.Next() {
blog, err := scanBlog(rows)
if err != nil {
log.Println("Scan error:", err)
continue
}
// Don't expose internal details to public
blog.ReviewedBy = nil
blog.ReviewedAt = nil
blog.ReviewNotes = ""
blog.PromotedBy = nil
blogs = append(blogs, *blog)
}
if err = rows.Err(); err != nil {
log.Println("Rows iteration error:", err)
respondError(w, http.StatusInternalServerError, "Failed to fetch community blogs")
return
}
respondJSON(w, http.StatusOK, blogs)
}
// GET /community/blogs/:slug - Get published community blog by slug (public)
func getCommunityBlogHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
slug := strings.TrimPrefix(r.URL.Path, "/community/blogs/")
if slug == "" {
respondError(w, http.StatusBadRequest, "Slug is required")
return
}
// Only return USER blogs from /community endpoint
query := `SELECT ` + blogSelectColumns + `
FROM blog_posts WHERE slug = $1 AND status = $2 AND blog_type = $3`
blog, err := scanBlog(db.QueryRow(query, slug, StatusPublished, BlogTypeUser))
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "Community blog not found")
return
} else if err != nil {
log.Println("Database error:", err)
respondError(w, http.StatusInternalServerError, "Failed to fetch community blog")
return
}
// Don't expose internal details to public
blog.ReviewedBy = nil
blog.ReviewedAt = nil
blog.ReviewNotes = ""
blog.PromotedBy = nil
respondJSON(w, http.StatusOK, blog)
}
// GET /community/my-blogs - List current user's community blogs (authenticated)
func listMyBlogsHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
query := `SELECT ` + blogSelectColumns + `
FROM blog_posts WHERE author_id = $1 AND blog_type = $2 ORDER BY updated_at DESC`
rows, err := db.Query(query, userID, BlogTypeUser)
if err != nil {
log.Println("Database error:", err)
respondError(w, http.StatusInternalServerError, "Failed to fetch your blogs")
return
}
defer rows.Close()
blogs := []Blog{}
for rows.Next() {
blog, err := scanBlog(rows)
if err != nil {
log.Println("Scan error:", err)
continue
}
blogs = append(blogs, *blog)
}
if err = rows.Err(); err != nil {
log.Println("Rows iteration error:", err)
}
respondJSON(w, http.StatusOK, blogs)
}
// POST /community/blogs - Create a new community blog (any authenticated user)
func createCommunityBlogHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
// Get user name from claims if available
userName := "Community Member"
if name, ok := claims["name"].(string); ok && name != "" {
userName = name
} else if email, ok := claims["email"].(string); ok && email != "" {
// Use email prefix as fallback
parts := strings.Split(email, "@")
userName = parts[0]
}
var req CreateBlogRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.Slug == "" || req.Title == "" || req.ShortDescription == "" || req.Content == "" {
respondError(w, http.StatusBadRequest, "Missing required fields: slug, title, short_description, content")
return
}
// Prefix community blog slugs to avoid collision with site blogs
communitySlug := "community-" + req.Slug
// Use provided author name or default to user name
author := req.Author
if author == "" {
author = userName
}
var date time.Time
if req.Date != "" {
var err error
date, err = time.Parse(time.RFC3339, req.Date)
if err != nil {
date = time.Now()
}
} else {
date = time.Now()
}
// Community blogs start as PUBLISHED immediately (no review required)
// Users have freedom to publish their own content in the community section
query := `INSERT INTO blog_posts (slug, title, author, author_id, date, tags, short_description, content, status, blog_type, published_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CURRENT_TIMESTAMP)
RETURNING id, created_at, updated_at, published_at`
var blog Blog
var publishedAt sql.NullTime
err := db.QueryRow(query, communitySlug, req.Title, author, userID, date, tagsToPostgres(req.Tags), req.ShortDescription, req.Content, StatusPublished, BlogTypeUser).
Scan(&blog.ID, &blog.CreatedAt, &blog.UpdatedAt, &publishedAt)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
respondError(w, http.StatusConflict, "A community blog with this slug already exists")
return
}
log.Println("Database error:", err)
respondError(w, http.StatusInternalServerError, "Failed to create community blog")
return
}
blog.Slug = communitySlug
blog.Title = req.Title
blog.Author = author
blog.AuthorID = userID
blog.Date = date
blog.Tags = req.Tags
blog.ShortDescription = req.ShortDescription
blog.Content = req.Content
blog.Status = StatusPublished
blog.BlogType = BlogTypeUser
if publishedAt.Valid {
blog.PublishedAt = &publishedAt.Time
}
log.Printf("AUDIT: User %d created community blog '%s'", userID, communitySlug)
respondJSON(w, http.StatusCreated, blog)
}
// PUT /community/blogs/:slug - Update own community blog (author only)
func updateCommunityBlogHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
slug := strings.TrimPrefix(r.URL.Path, "/community/blogs/")
if slug == "" {
respondError(w, http.StatusBadRequest, "Slug is required")
return
}
// Check if blog exists and user owns it
var existingAuthorID int
var existingBlogType string
err := db.QueryRow("SELECT author_id, blog_type FROM blog_posts WHERE slug = $1", slug).Scan(&existingAuthorID, &existingBlogType)
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "Community blog not found")
return
} else if err != nil {
respondError(w, http.StatusInternalServerError, "Database error")
return
}
// Must be USER type blog
if existingBlogType != BlogTypeUser {
respondError(w, http.StatusForbidden, "This is not a community blog")
return
}
// Only author can edit their own community blogs
if existingAuthorID != userID {
respondError(w, http.StatusForbidden, "You can only edit your own community blogs")
return
}
var req UpdateBlogRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Validate input lengths for community blog updates
if len(req.Title) > maxTitleLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Title must be less than %d characters", maxTitleLength))
return
}
if len(req.ShortDescription) > maxShortDescriptionLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Short description must be less than %d characters", maxShortDescriptionLength))
return
}
if len(req.Content) > maxContentLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Content must be less than %d characters", maxContentLength))
return
}
if len(req.Author) > maxAuthorLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Author name must be less than %d characters", maxAuthorLength))
return
}
if len(req.Tags) > maxTagsCount {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Maximum %d tags allowed", maxTagsCount))
return
}
for _, tag := range req.Tags {
if len(tag) > maxTagLength {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Each tag must be less than %d characters", maxTagLength))
return
}
}
var date time.Time
if req.Date != "" {
date, err = time.Parse(time.RFC3339, req.Date)
if err != nil {
date = time.Now()
}
} else {
date = time.Now()
}
query := `UPDATE blog_posts SET title = $1, author = $2, date = $3, tags = $4,
short_description = $5, content = $6, updated_at = CURRENT_TIMESTAMP
WHERE slug = $7 AND author_id = $8`
result, err := db.Exec(query, req.Title, req.Author, date, tagsToPostgres(req.Tags), req.ShortDescription, req.Content, slug, userID)
if err != nil {
log.Println("Database error:", err)
respondError(w, http.StatusInternalServerError, "Failed to update community blog")
return
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
respondError(w, http.StatusNotFound, "Community blog not found")
return
}
log.Printf("AUDIT: User %d updated community blog '%s'", userID, slug)
respondJSON(w, http.StatusOK, map[string]string{"message": "Community blog updated successfully"})
}
// DELETE /community/blogs/:slug - Delete own community blog (author only)
func deleteCommunityBlogHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
slug := strings.TrimPrefix(r.URL.Path, "/community/blogs/")
if slug == "" {
respondError(w, http.StatusBadRequest, "Slug is required")
return
}
// Check if blog exists and user owns it
var existingAuthorID int
var existingBlogType string
err := db.QueryRow("SELECT author_id, blog_type FROM blog_posts WHERE slug = $1", slug).Scan(&existingAuthorID, &existingBlogType)
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "Community blog not found")
return
}
// Must be USER type blog
if existingBlogType != BlogTypeUser {
respondError(w, http.StatusForbidden, "This is not a community blog")
return
}
// Only author can delete their own community blogs
if existingAuthorID != userID {
respondError(w, http.StatusForbidden, "You can only delete your own community blogs")
return
}
result, err := db.Exec("DELETE FROM blog_posts WHERE slug = $1 AND author_id = $2 AND blog_type = $3", slug, userID, BlogTypeUser)
if err != nil {
log.Println("Database error:", err)
respondError(w, http.StatusInternalServerError, "Failed to delete community blog")
return
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
respondError(w, http.StatusNotFound, "Community blog not found")
return
}
log.Printf("AUDIT: User %d deleted community blog '%s'", userID, slug)
respondJSON(w, http.StatusOK, map[string]string{"message": "Community blog deleted successfully"})
}
// ============ ADMIN COMMUNITY MANAGEMENT ENDPOINTS ============
// GET /admin/community/blogs - List all community blogs (ADMIN only)
func listAllCommunityBlogsHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
status := r.URL.Query().Get("status")
query := `SELECT ` + blogSelectColumns + ` FROM blog_posts WHERE blog_type = $1`
args := []interface{}{BlogTypeUser}
argNum := 2
if status != "" {
query += fmt.Sprintf(" AND status = $%d", argNum)
args = append(args, status)
}
query += " ORDER BY updated_at DESC"
rows, err := db.Query(query, args...)
if err != nil {
log.Println("Database error:", err)
respondError(w, http.StatusInternalServerError, "Failed to fetch community blogs")
return
}
defer rows.Close()
blogs := []Blog{}
for rows.Next() {
blog, err := scanBlog(rows)
if err != nil {
log.Println("Scan error:", err)
continue
}
blogs = append(blogs, *blog)
}
if err = rows.Err(); err != nil {
log.Println("Rows iteration error:", err)
}
respondJSON(w, http.StatusOK, blogs)
}
// POST /admin/community/blogs/:slug/promote - Promote a community blog to SITE blog (ADMIN only)
func promoteCommunityBlogHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
slug := strings.TrimPrefix(r.URL.Path, "/admin/community/blogs/")
slug = strings.TrimSuffix(slug, "/promote")
// Check if blog exists and is a USER blog
var existingBlogType string
var existingStatus string
err := db.QueryRow("SELECT blog_type, status FROM blog_posts WHERE slug = $1", slug).Scan(&existingBlogType, &existingStatus)
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "Community blog not found")
return
}
if existingBlogType != BlogTypeUser {
respondError(w, http.StatusBadRequest, "This blog is already a site blog")
return
}
if existingStatus != StatusPublished {
respondError(w, http.StatusBadRequest, "Only published community blogs can be promoted")
return
}
// Promote to SITE blog
_, err = db.Exec(`UPDATE blog_posts SET blog_type = $1, promoted_at = CURRENT_TIMESTAMP,
promoted_by = $2, updated_at = CURRENT_TIMESTAMP WHERE slug = $3`,
BlogTypeSite, userID, slug)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to promote community blog")
return
}
log.Printf("AUDIT: Admin %d promoted community blog '%s' to SITE blog", userID, slug)
respondJSON(w, http.StatusOK, map[string]string{"message": "Community blog promoted to site blog successfully"})
}
// DELETE /admin/community/blogs/:slug - Admin delete any community blog (ADMIN only)
func adminDeleteCommunityBlogHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
slug := strings.TrimPrefix(r.URL.Path, "/admin/community/blogs/")
// Only delete USER type blogs from this endpoint
result, err := db.Exec("DELETE FROM blog_posts WHERE slug = $1 AND blog_type = $2", slug, BlogTypeUser)
if err != nil {
log.Println("Database error:", err)
respondError(w, http.StatusInternalServerError, "Failed to delete community blog")
return
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
respondError(w, http.StatusNotFound, "Community blog not found")
return
}
log.Printf("AUDIT: Admin %d deleted community blog '%s'", userID, slug)
respondJSON(w, http.StatusOK, map[string]string{"message": "Community blog deleted successfully"})
}
// POST /admin/community/blogs/:slug/archive - Archive a community blog (hide from public)
func archiveCommunityBlogHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
slug := strings.TrimPrefix(r.URL.Path, "/admin/community/blogs/")
slug = strings.TrimSuffix(slug, "/archive")
// Check if blog exists and is a USER blog
var existingBlogType string
err := db.QueryRow("SELECT blog_type FROM blog_posts WHERE slug = $1", slug).Scan(&existingBlogType)
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "Community blog not found")
return
}
if existingBlogType != BlogTypeUser {
respondError(w, http.StatusBadRequest, "This is not a community blog")
return
}
_, err = db.Exec("UPDATE blog_posts SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE slug = $2",
StatusArchived, slug)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to archive community blog")
return
}
log.Printf("AUDIT: Admin %d archived community blog '%s'", userID, slug)
respondJSON(w, http.StatusOK, map[string]string{"message": "Community blog archived", "status": StatusArchived})
}
// POST /admin/community/blogs/:slug/verify - Verify a community blog/tutorial (ADMIN only)
// This marks content as admin-verified without promoting to site blog
func verifyCommunityBlogHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
slug := strings.TrimPrefix(r.URL.Path, "/admin/community/blogs/")
slug = strings.TrimSuffix(slug, "/verify")
// Check if blog exists
var existingBlogType string
var existingVerified bool
err := db.QueryRow("SELECT blog_type, verified FROM blog_posts WHERE slug = $1", slug).Scan(&existingBlogType, &existingVerified)
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "Community blog not found")
return
}
if existingBlogType != BlogTypeUser {
respondError(w, http.StatusBadRequest, "This is not a community blog (SITE blogs are already verified by default)")
return
}
if existingVerified {
respondError(w, http.StatusBadRequest, "This community blog is already verified")
return
}
_, err = db.Exec(`UPDATE blog_posts SET verified = TRUE, verified_by = $1, verified_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP WHERE slug = $2`,
userID, slug)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to verify community blog")
return
}
log.Printf("AUDIT: Admin %d verified community blog '%s'", userID, slug)
respondJSON(w, http.StatusOK, map[string]string{"message": "Community blog verified successfully", "verified": "true"})
}
// POST /admin/community/blogs/:slug/unverify - Remove verification from a community blog (ADMIN only)
func unverifyCommunityBlogHandler(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
claims := r.Context().Value(userContextKey).(jwt.MapClaims)
userID := getUserID(claims)
slug := strings.TrimPrefix(r.URL.Path, "/admin/community/blogs/")
slug = strings.TrimSuffix(slug, "/unverify")
// Check if blog exists
var existingBlogType string
var existingVerified bool
err := db.QueryRow("SELECT blog_type, verified FROM blog_posts WHERE slug = $1", slug).Scan(&existingBlogType, &existingVerified)
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "Community blog not found")
return
}
if existingBlogType != BlogTypeUser {
respondError(w, http.StatusBadRequest, "This is not a community blog")
return
}
if !existingVerified {
respondError(w, http.StatusBadRequest, "This community blog is not verified")
return
}
_, err = db.Exec(`UPDATE blog_posts SET verified = FALSE, verified_by = NULL, verified_at = NULL,
updated_at = CURRENT_TIMESTAMP WHERE slug = $1`, slug)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to unverify community blog")
return
}
log.Printf("AUDIT: Admin %d unverified community blog '%s'", userID, slug)
respondJSON(w, http.StatusOK, map[string]string{"message": "Community blog verification removed", "verified": "false"})
}
func main() {
loadConfig()
initDB()
defer db.Close()
// ============ PUBLIC ROUTES ============
// GET /blogs - List published blogs
http.HandleFunc("/blogs", func(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method == http.MethodGet {
listPublishedBlogsHandler(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
})
// GET /blogs/:slug - Get published blog
http.HandleFunc("/blogs/", func(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method == http.MethodGet {
getPublishedBlogHandler(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
})
// ============ ADMIN ROUTES ============
// GET /admin/blogs - List all blogs (STAFF sees own, ADMIN sees all)
http.HandleFunc("/admin/blogs", func(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method == http.MethodGet {
requireRole(listAllBlogsHandler, "STAFF", "ADMIN")(w, r)
} else if r.Method == http.MethodPost {
requireRole(createBlogHandler, "STAFF", "ADMIN")(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
})
// GET /admin/blogs/pending - List pending review (ADMIN only)
http.HandleFunc("/admin/blogs/pending", func(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method == http.MethodGet {
requireRole(listPendingReviewHandler, "ADMIN")(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
})
// Blog-specific actions
http.HandleFunc("/admin/blogs/", func(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
path := r.URL.Path
// Route based on path suffix
switch {
case strings.HasSuffix(path, "/submit"):
if r.Method == http.MethodPost {
requireRole(submitForReviewHandler, "STAFF", "ADMIN")(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
case strings.HasSuffix(path, "/review"):
if r.Method == http.MethodPost {
requireRole(reviewBlogHandler, "ADMIN")(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
case strings.HasSuffix(path, "/publish"):
if r.Method == http.MethodPost {
requireRole(publishBlogHandler, "ADMIN")(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
case strings.HasSuffix(path, "/unpublish"):
if r.Method == http.MethodPost {
requireRole(unpublishBlogHandler, "ADMIN")(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
default:
// /admin/blogs/:slug - CRUD operations
if r.Method == http.MethodPut {
requireRole(updateBlogHandler, "STAFF", "ADMIN")(w, r)
} else if r.Method == http.MethodDelete {
requireRole(deleteBlogHandler, "ADMIN")(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
})
// ============ COMMUNITY BLOG ROUTES (Separate from Site Blogs) ============
// GET /community/blogs - List published community blogs (public)
http.HandleFunc("/community/blogs", func(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method == http.MethodGet {
listCommunityBlogsHandler(w, r)
} else if r.Method == http.MethodPost {
// Any authenticated user can create community blogs
authMiddleware(createCommunityBlogHandler)(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
})
// GET /community/my-blogs - List current user's community blogs
http.HandleFunc("/community/my-blogs", func(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method == http.MethodGet {
authMiddleware(listMyBlogsHandler)(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
})
// Community blog-specific actions (authenticated user operations)
http.HandleFunc("/community/blogs/", func(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method == http.MethodGet {
getCommunityBlogHandler(w, r)
} else if r.Method == http.MethodPut {
authMiddleware(updateCommunityBlogHandler)(w, r)
} else if r.Method == http.MethodDelete {
authMiddleware(deleteCommunityBlogHandler)(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
})
// ============ ADMIN COMMUNITY MANAGEMENT ROUTES ============
// GET /admin/community/blogs - List all community blogs (ADMIN only)
http.HandleFunc("/admin/community/blogs", func(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method == http.MethodGet {
requireRole(listAllCommunityBlogsHandler, "ADMIN")(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
})
// Admin community blog actions
http.HandleFunc("/admin/community/blogs/", func(w http.ResponseWriter, r *http.Request) {
enableCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
path := r.URL.Path
switch {
case strings.HasSuffix(path, "/promote"):
if r.Method == http.MethodPost {
requireRole(promoteCommunityBlogHandler, "ADMIN")(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
case strings.HasSuffix(path, "/verify"):
if r.Method == http.MethodPost {
requireRole(verifyCommunityBlogHandler, "ADMIN")(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
case strings.HasSuffix(path, "/unverify"):
if r.Method == http.MethodPost {
requireRole(unverifyCommunityBlogHandler, "ADMIN")(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
case strings.HasSuffix(path, "/archive"):
if r.Method == http.MethodPost {
requireRole(archiveCommunityBlogHandler, "ADMIN")(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
default:
// DELETE /admin/community/blogs/:slug
if r.Method == http.MethodDelete {
requireRole(adminDeleteCommunityBlogHandler, "ADMIN")(w, r)
} else {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
})
// Health check (both /health and /healthz for compatibility)
healthHandler := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
}
http.HandleFunc("/health", healthHandler)
http.HandleFunc("/healthz", healthHandler)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
// Wrap all routes with rate limiting and body size limit
rateLimitedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Limit request body size to prevent DoS
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
}
clientIP := getClientIP(r)
// Apply different limits for read vs write operations
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if readLimiter.checkRateLimit(clientIP, maxReadRequests, rateLimitWindow) {
log.Printf("SECURITY: Read rate limit exceeded for IP %s on %s", clientIP, r.URL.Path)
http.Error(w, "Too many requests. Please slow down.", http.StatusTooManyRequests)
return
}
} else if r.Method != http.MethodOptions {
if writeLimiter.checkRateLimit(clientIP, maxWriteRequests, rateLimitWindow) {
log.Printf("SECURITY: Write rate limit exceeded for IP %s on %s", clientIP, r.URL.Path)
http.Error(w, "Too many requests. Please slow down.", http.StatusTooManyRequests)
return
}
}
http.DefaultServeMux.ServeHTTP(w, r)
})
server := &http.Server{
Addr: ":" + port,
Handler: rateLimitedHandler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Graceful shutdown
done := make(chan bool, 1)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-quit
log.Println("Blog Service shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.SetKeepAlivesEnabled(false)
if err := server.Shutdown(ctx); err != nil {
log.Printf("Could not gracefully shutdown: %v", err)
}
close(done)
}()
log.Printf("Blog service starting on port %s\n", port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
<-done
log.Println("Blog Service stopped")
}