2018 lines
63 KiB
Go
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")
|
|
}
|