package handlers import ( "context" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/patbritton/sojorn-backend/internal/services" "github.com/rs/zerolog/log" "golang.org/x/crypto/bcrypt" ) type AdminHandler struct { pool *pgxpool.Pool moderationService *services.ModerationService appealService *services.AppealService jwtSecret string turnstileSecret string s3Client *s3.Client mediaBucket string videoBucket string imgDomain string vidDomain string } func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, jwtSecret string, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler { return &AdminHandler{ pool: pool, moderationService: moderationService, appealService: appealService, jwtSecret: jwtSecret, turnstileSecret: turnstileSecret, s3Client: s3Client, mediaBucket: mediaBucket, videoBucket: videoBucket, imgDomain: imgDomain, vidDomain: vidDomain, } } // ────────────────────────────────────────────── // Admin Login (invisible Turnstile verification) // ────────────────────────────────────────────── func (h *AdminHandler) AdminLogin(c *gin.Context) { ctx := c.Request.Context() var req struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required"` TurnstileToken string `json:"turnstile_token"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.Email = strings.ToLower(strings.TrimSpace(req.Email)) // Verify Turnstile token (invisible mode) if h.turnstileSecret != "" { turnstileService := services.NewTurnstileService(h.turnstileSecret) remoteIP := c.ClientIP() turnstileResp, err := turnstileService.VerifyToken(req.TurnstileToken, remoteIP) if err != nil { log.Error().Err(err).Msg("Admin login: Turnstile verification failed") c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"}) return } if !turnstileResp.Success { log.Warn().Strs("errors", turnstileResp.ErrorCodes).Msg("Admin login: Turnstile validation failed") c.JSON(http.StatusForbidden, gin.H{"error": "Security verification failed. Please try again."}) return } } // Look up user var userID uuid.UUID var passwordHash, status string err := h.pool.QueryRow(ctx, `SELECT id, encrypted_password, COALESCE(status, 'active') FROM users WHERE email = $1 AND deleted_at IS NULL`, req.Email).Scan(&userID, &passwordHash, &status) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) return } if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) return } if status != "active" { c.JSON(http.StatusForbidden, gin.H{"error": "Account is not active"}) return } // Check admin role var role string err = h.pool.QueryRow(ctx, `SELECT COALESCE(role, 'user') FROM profiles WHERE id = $1`, userID).Scan(&role) if err != nil || role != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) return } // Generate JWT token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": userID.String(), "exp": time.Now().Add(24 * time.Hour).Unix(), "role": "authenticated", }) tokenString, err := token.SignedString([]byte(h.jwtSecret)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) return } // Get profile info var handle, displayName string var avatarURL *string h.pool.QueryRow(ctx, `SELECT handle, display_name, avatar_url FROM profiles WHERE id = $1`, userID).Scan(&handle, &displayName, &avatarURL) c.JSON(http.StatusOK, gin.H{ "access_token": tokenString, "user": gin.H{ "id": userID, "email": req.Email, "handle": handle, "display_name": displayName, "avatar_url": avatarURL, "role": role, }, }) } // ────────────────────────────────────────────── // Dashboard / Stats // ────────────────────────────────────────────── func (h *AdminHandler) GetDashboardStats(c *gin.Context) { ctx := c.Request.Context() stats := gin.H{} // Total users var totalUsers, activeUsers, suspendedUsers, bannedUsers int err := h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM public.profiles`).Scan(&totalUsers) if err != nil { log.Error().Err(err).Msg("Failed to count users") } h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE status = 'active'`).Scan(&activeUsers) h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE status = 'suspended'`).Scan(&suspendedUsers) h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE status = 'banned'`).Scan(&bannedUsers) // Total posts var totalPosts, activePosts, flaggedPosts, removedPosts int h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts`).Scan(&totalPosts) h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE status = 'active'`).Scan(&activePosts) h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE status = 'flagged'`).Scan(&flaggedPosts) h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE status = 'removed'`).Scan(&removedPosts) // Moderation var pendingFlags, reviewedFlags int h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM moderation_flags WHERE status = 'pending'`).Scan(&pendingFlags) h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM moderation_flags WHERE status != 'pending'`).Scan(&reviewedFlags) // Appeals var pendingAppeals, approvedAppeals, rejectedAppeals int h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_appeals WHERE status = 'pending'`).Scan(&pendingAppeals) h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_appeals WHERE status = 'approved'`).Scan(&approvedAppeals) h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_appeals WHERE status = 'rejected'`).Scan(&rejectedAppeals) // New users today var newUsersToday int h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE`).Scan(&newUsersToday) // New posts today var newPostsToday int h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE created_at >= CURRENT_DATE`).Scan(&newPostsToday) // Reports var pendingReports int h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM reports WHERE status = 'pending'`).Scan(&pendingReports) stats["users"] = gin.H{ "total": totalUsers, "active": activeUsers, "suspended": suspendedUsers, "banned": bannedUsers, "new_today": newUsersToday, } stats["posts"] = gin.H{ "total": totalPosts, "active": activePosts, "flagged": flaggedPosts, "removed": removedPosts, "new_today": newPostsToday, } stats["moderation"] = gin.H{ "pending_flags": pendingFlags, "reviewed_flags": reviewedFlags, } stats["appeals"] = gin.H{ "pending": pendingAppeals, "approved": approvedAppeals, "rejected": rejectedAppeals, } stats["reports"] = gin.H{ "pending": pendingReports, } c.JSON(http.StatusOK, stats) } // GetGrowthStats returns user/post growth over time for charts func (h *AdminHandler) GetGrowthStats(c *gin.Context) { ctx := c.Request.Context() days, _ := strconv.Atoi(c.DefaultQuery("days", "30")) if days > 365 { days = 365 } // User growth userRows, err := h.pool.Query(ctx, ` SELECT DATE(created_at) as day, COUNT(*) as count FROM users WHERE created_at >= NOW() - $1::int * INTERVAL '1 day' GROUP BY DATE(created_at) ORDER BY day `, days) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch growth stats"}) return } defer userRows.Close() var userGrowth []gin.H for userRows.Next() { var day time.Time var count int if err := userRows.Scan(&day, &count); err == nil { userGrowth = append(userGrowth, gin.H{"date": day.Format("2006-01-02"), "count": count}) } } // Post growth postRows, err := h.pool.Query(ctx, ` SELECT DATE(created_at) as day, COUNT(*) as count FROM posts WHERE created_at >= NOW() - $1::int * INTERVAL '1 day' GROUP BY DATE(created_at) ORDER BY day `, days) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch post growth"}) return } defer postRows.Close() var postGrowth []gin.H for postRows.Next() { var day time.Time var count int if err := postRows.Scan(&day, &count); err == nil { postGrowth = append(postGrowth, gin.H{"date": day.Format("2006-01-02"), "count": count}) } } c.JSON(http.StatusOK, gin.H{ "user_growth": userGrowth, "post_growth": postGrowth, "days": days, }) } // ────────────────────────────────────────────── // User Management // ────────────────────────────────────────────── func (h *AdminHandler) ListUsers(c *gin.Context) { ctx := c.Request.Context() limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) search := c.Query("search") statusFilter := c.Query("status") roleFilter := c.Query("role") if limit > 200 { limit = 200 } query := ` SELECT u.id, u.email, u.status, u.created_at, p.handle, p.display_name, p.avatar_url, p.role, p.is_official, p.is_private, p.strikes FROM users u LEFT JOIN profiles p ON u.id = p.id WHERE u.deleted_at IS NULL ` args := []interface{}{} argIdx := 1 if search != "" { query += fmt.Sprintf(` AND (p.handle ILIKE $%d OR p.display_name ILIKE $%d OR u.email ILIKE $%d)`, argIdx, argIdx, argIdx) args = append(args, "%"+search+"%") argIdx++ } if statusFilter != "" { query += fmt.Sprintf(` AND u.status = $%d`, argIdx) args = append(args, statusFilter) argIdx++ } if roleFilter != "" { query += fmt.Sprintf(` AND p.role = $%d`, argIdx) args = append(args, roleFilter) argIdx++ } // Count total countQuery := "SELECT COUNT(*) FROM (" + query + ") sub" var total int h.pool.QueryRow(ctx, countQuery, args...).Scan(&total) query += fmt.Sprintf(` ORDER BY u.created_at DESC LIMIT $%d OFFSET $%d`, argIdx, argIdx+1) args = append(args, limit, offset) rows, err := h.pool.Query(ctx, query, args...) if err != nil { log.Error().Err(err).Msg("Failed to list users") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"}) return } defer rows.Close() var users []gin.H for rows.Next() { var id uuid.UUID var email, status string var createdAt time.Time var handle, displayName, avatarURL, role *string var isOfficial, isPrivate *bool var strikes *int if err := rows.Scan(&id, &email, &status, &createdAt, &handle, &displayName, &avatarURL, &role, &isOfficial, &isPrivate, &strikes); err != nil { log.Error().Err(err).Msg("Failed to scan user row") continue } users = append(users, gin.H{ "id": id, "email": email, "status": status, "created_at": createdAt, "handle": handle, "display_name": displayName, "avatar_url": avatarURL, "role": role, "is_official": isOfficial, "is_private": isPrivate, "strikes": strikes, }) } if users == nil { users = []gin.H{} } c.JSON(http.StatusOK, gin.H{ "users": users, "total": total, "limit": limit, "offset": offset, }) } func (h *AdminHandler) GetUser(c *gin.Context) { ctx := c.Request.Context() userID := c.Param("id") // User + profile details var id uuid.UUID var email, status string var createdAt time.Time var lastLogin *time.Time var handle, displayName, bio, avatarURL, coverURL, role, location, website, originCountry *string var isOfficial, isPrivate, isVerified *bool var strikes int var beaconEnabled, hasCompletedOnboarding bool err := h.pool.QueryRow(ctx, ` SELECT u.id, u.email, u.status, u.created_at, u.last_login, p.handle, p.display_name, p.bio, p.avatar_url, p.cover_url, p.role, p.is_official, p.is_private, p.is_verified, p.strikes, p.beacon_enabled, p.location, p.website, p.origin_country, p.has_completed_onboarding FROM users u LEFT JOIN profiles p ON u.id = p.id WHERE u.id = $1::uuid `, userID).Scan( &id, &email, &status, &createdAt, &lastLogin, &handle, &displayName, &bio, &avatarURL, &coverURL, &role, &isOfficial, &isPrivate, &isVerified, &strikes, &beaconEnabled, &location, &website, &originCountry, &hasCompletedOnboarding, ) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } // Counts var followerCount, followingCount, postCount int h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM follows WHERE following_id = $1::uuid AND status = 'accepted'`, userID).Scan(&followerCount) h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM follows WHERE follower_id = $1::uuid AND status = 'accepted'`, userID).Scan(&followingCount) h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE author_id = $1::uuid AND deleted_at IS NULL`, userID).Scan(&postCount) // Violation count var violationCount int h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_violations WHERE user_id = $1::uuid`, userID).Scan(&violationCount) // Report count (received) var reportCount int h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM reports WHERE target_user_id = $1::uuid`, userID).Scan(&reportCount) c.JSON(http.StatusOK, gin.H{ "id": id, "email": email, "status": status, "created_at": createdAt, "last_login": lastLogin, "handle": handle, "display_name": displayName, "bio": bio, "avatar_url": avatarURL, "cover_url": coverURL, "role": role, "is_official": isOfficial, "is_private": isPrivate, "is_verified": isVerified, "strikes": strikes, "beacon_enabled": beaconEnabled, "location": location, "website": website, "origin_country": originCountry, "has_completed_onboarding": hasCompletedOnboarding, "follower_count": followerCount, "following_count": followingCount, "post_count": postCount, "violation_count": violationCount, "report_count": reportCount, }) } func (h *AdminHandler) UpdateUserStatus(c *gin.Context) { ctx := c.Request.Context() adminID, _ := c.Get("user_id") targetUserID := c.Param("id") var req struct { Status string `json:"status" binding:"required,oneof=active suspended banned deactivated"` Reason string `json:"reason" binding:"required,min=3"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Get old status var oldStatus string h.pool.QueryRow(ctx, `SELECT status FROM users WHERE id = $1::uuid`, targetUserID).Scan(&oldStatus) // Update user status _, err := h.pool.Exec(ctx, `UPDATE users SET status = $1 WHERE id = $2::uuid`, req.Status, targetUserID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"}) return } // Log status change adminUUID, _ := uuid.Parse(adminID.(string)) h.pool.Exec(ctx, ` INSERT INTO user_status_history (user_id, old_status, new_status, reason, changed_by) VALUES ($1::uuid, $2, $3, $4, $5) `, targetUserID, oldStatus, req.Status, req.Reason, adminUUID) c.JSON(http.StatusOK, gin.H{"message": "User status updated", "status": req.Status}) } func (h *AdminHandler) UpdateUserRole(c *gin.Context) { ctx := c.Request.Context() targetUserID := c.Param("id") var req struct { Role string `json:"role" binding:"required,oneof=user moderator admin"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } _, err := h.pool.Exec(ctx, `UPDATE profiles SET role = $1 WHERE id = $2::uuid`, req.Role, targetUserID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user role"}) return } c.JSON(http.StatusOK, gin.H{"message": "User role updated", "role": req.Role}) } func (h *AdminHandler) UpdateUserVerification(c *gin.Context) { ctx := c.Request.Context() targetUserID := c.Param("id") var req struct { IsOfficial bool `json:"is_official"` IsVerified bool `json:"is_verified"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } _, err := h.pool.Exec(ctx, `UPDATE profiles SET is_official = $1, is_verified = $2 WHERE id = $3::uuid`, req.IsOfficial, req.IsVerified, targetUserID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update verification"}) return } c.JSON(http.StatusOK, gin.H{"message": "Verification updated"}) } func (h *AdminHandler) ResetUserStrikes(c *gin.Context) { ctx := c.Request.Context() targetUserID := c.Param("id") _, err := h.pool.Exec(ctx, `UPDATE profiles SET strikes = 0 WHERE id = $1::uuid`, targetUserID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reset strikes"}) return } c.JSON(http.StatusOK, gin.H{"message": "Strikes reset"}) } // ────────────────────────────────────────────── // Post Management // ────────────────────────────────────────────── func (h *AdminHandler) ListPosts(c *gin.Context) { ctx := c.Request.Context() limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) search := c.Query("search") statusFilter := c.Query("status") authorFilter := c.Query("author_id") if limit > 200 { limit = 200 } query := ` SELECT p.id, p.author_id, p.body, p.status, p.image_url, p.video_url, p.like_count, p.comment_count, p.is_beacon, p.visibility, p.created_at, pr.handle, pr.display_name, pr.avatar_url FROM posts p LEFT JOIN profiles pr ON p.author_id = pr.id WHERE p.deleted_at IS NULL ` args := []interface{}{} argIdx := 1 if search != "" { query += fmt.Sprintf(` AND p.body ILIKE $%d`, argIdx) args = append(args, "%"+search+"%") argIdx++ } if statusFilter != "" { query += fmt.Sprintf(` AND p.status = $%d`, argIdx) args = append(args, statusFilter) argIdx++ } if authorFilter != "" { query += fmt.Sprintf(` AND p.author_id = $%d::uuid`, argIdx) args = append(args, authorFilter) argIdx++ } countQuery := "SELECT COUNT(*) FROM (" + query + ") sub" var total int h.pool.QueryRow(ctx, countQuery, args...).Scan(&total) query += fmt.Sprintf(` ORDER BY p.created_at DESC LIMIT $%d OFFSET $%d`, argIdx, argIdx+1) args = append(args, limit, offset) rows, err := h.pool.Query(ctx, query, args...) if err != nil { log.Error().Err(err).Msg("Failed to list posts") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list posts"}) return } defer rows.Close() var posts []gin.H for rows.Next() { var id, authorID uuid.UUID var body, status, visibility string var imageURL, videoURL *string var likeCount, commentCount int var isBeacon bool var createdAt time.Time var authorHandle, authorDisplayName, authorAvatarURL *string if err := rows.Scan(&id, &authorID, &body, &status, &imageURL, &videoURL, &likeCount, &commentCount, &isBeacon, &visibility, &createdAt, &authorHandle, &authorDisplayName, &authorAvatarURL); err != nil { log.Error().Err(err).Msg("Failed to scan post row") continue } posts = append(posts, gin.H{ "id": id, "author_id": authorID, "body": body, "status": status, "image_url": imageURL, "video_url": videoURL, "like_count": likeCount, "comment_count": commentCount, "is_beacon": isBeacon, "visibility": visibility, "created_at": createdAt, "author": gin.H{ "handle": authorHandle, "display_name": authorDisplayName, "avatar_url": authorAvatarURL, }, }) } if posts == nil { posts = []gin.H{} } c.JSON(http.StatusOK, gin.H{ "posts": posts, "total": total, "limit": limit, "offset": offset, }) } func (h *AdminHandler) GetPost(c *gin.Context) { ctx := c.Request.Context() postID := c.Param("id") var id, authorID uuid.UUID var body, status, bodyFormat, visibility string var imageURL, videoURL, thumbnailURL, toneLabel, beaconType, backgroundID *string var cisScore *float64 var durationMS, likeCount, commentCount int var isBeacon, allowChain bool var createdAt time.Time var editedAt *time.Time var authorHandle, authorDisplayName, authorAvatarURL *string err := h.pool.QueryRow(ctx, ` SELECT p.id, p.author_id, p.body, p.status, p.body_format, p.image_url, p.video_url, p.thumbnail_url, p.tone_label, p.cis_score, COALESCE(p.duration_ms, 0), p.background_id, p.is_beacon, p.beacon_type, p.allow_chain, p.visibility, p.like_count, p.comment_count, p.created_at, p.edited_at, pr.handle, pr.display_name, pr.avatar_url FROM posts p LEFT JOIN profiles pr ON p.author_id = pr.id WHERE p.id = $1::uuid `, postID).Scan( &id, &authorID, &body, &status, &bodyFormat, &imageURL, &videoURL, &thumbnailURL, &toneLabel, &cisScore, &durationMS, &backgroundID, &isBeacon, &beaconType, &allowChain, &visibility, &likeCount, &commentCount, &createdAt, &editedAt, &authorHandle, &authorDisplayName, &authorAvatarURL, ) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Post not found"}) return } // Get moderation flags flagRows, _ := h.pool.Query(ctx, ` SELECT id, flag_reason, scores, status, reviewed_by, reviewed_at, created_at FROM moderation_flags WHERE post_id = $1::uuid ORDER BY created_at DESC `, postID) defer flagRows.Close() var flags []gin.H for flagRows.Next() { var fID uuid.UUID var fReason, fStatus string var fScores []byte var fReviewedBy *uuid.UUID var fReviewedAt *time.Time var fCreatedAt time.Time if err := flagRows.Scan(&fID, &fReason, &fScores, &fStatus, &fReviewedBy, &fReviewedAt, &fCreatedAt); err == nil { var scores map[string]float64 json.Unmarshal(fScores, &scores) flags = append(flags, gin.H{ "id": fID, "flag_reason": fReason, "scores": scores, "status": fStatus, "reviewed_by": fReviewedBy, "reviewed_at": fReviewedAt, "created_at": fCreatedAt, }) } } c.JSON(http.StatusOK, gin.H{ "id": id, "author_id": authorID, "body": body, "status": status, "body_format": bodyFormat, "image_url": imageURL, "video_url": videoURL, "thumbnail_url": thumbnailURL, "tone_label": toneLabel, "cis_score": cisScore, "duration_ms": durationMS, "background_id": backgroundID, "is_beacon": isBeacon, "beacon_type": beaconType, "allow_chain": allowChain, "visibility": visibility, "like_count": likeCount, "comment_count": commentCount, "created_at": createdAt, "edited_at": editedAt, "author": gin.H{ "handle": authorHandle, "display_name": authorDisplayName, "avatar_url": authorAvatarURL, }, "moderation_flags": flags, }) } func (h *AdminHandler) UpdatePostStatus(c *gin.Context) { ctx := c.Request.Context() adminID, _ := c.Get("user_id") postID := c.Param("id") var req struct { Status string `json:"status" binding:"required,oneof=active flagged removed"` Reason string `json:"reason"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } _, err := h.pool.Exec(ctx, `UPDATE posts SET status = $1 WHERE id = $2::uuid`, req.Status, postID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update post status"}) return } // Log the action adminUUID, _ := uuid.Parse(adminID.(string)) h.pool.Exec(ctx, ` INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, $2, 'post', $3::uuid, $4) `, adminUUID, "post_status_change", postID, fmt.Sprintf(`{"status":"%s","reason":"%s"}`, req.Status, req.Reason)) c.JSON(http.StatusOK, gin.H{"message": "Post status updated", "status": req.Status}) } func (h *AdminHandler) DeletePost(c *gin.Context) { ctx := c.Request.Context() adminID, _ := c.Get("user_id") postID := c.Param("id") _, err := h.pool.Exec(ctx, `UPDATE posts SET deleted_at = NOW(), status = 'removed' WHERE id = $1::uuid`, postID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete post"}) return } adminUUID, _ := uuid.Parse(adminID.(string)) h.pool.Exec(ctx, ` INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'admin_delete_post', 'post', $2::uuid, '{}') `, adminUUID, postID) c.JSON(http.StatusOK, gin.H{"message": "Post deleted"}) } // ────────────────────────────────────────────── // Moderation Queue // ────────────────────────────────────────────── func (h *AdminHandler) GetModerationQueue(c *gin.Context) { ctx := c.Request.Context() limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) statusFilter := c.DefaultQuery("status", "pending") if limit > 200 { limit = 200 } rows, err := h.pool.Query(ctx, ` SELECT mf.id, mf.post_id, mf.comment_id, mf.flag_reason, mf.scores, mf.status, mf.reviewed_by, mf.reviewed_at, mf.created_at, p.body as post_body, p.image_url as post_image, p.video_url as post_video, p.author_id as post_author_id, c.body as comment_body, c.author_id as comment_author_id, COALESCE(pr_post.handle, pr_comment.handle) as author_handle, COALESCE(pr_post.display_name, pr_comment.display_name) as author_display_name FROM moderation_flags mf LEFT JOIN posts p ON mf.post_id = p.id LEFT JOIN comments c ON mf.comment_id = c.id LEFT JOIN profiles pr_post ON p.author_id = pr_post.id LEFT JOIN profiles pr_comment ON c.author_id = pr_comment.id WHERE mf.status = $1 ORDER BY mf.created_at ASC LIMIT $2 OFFSET $3 `, statusFilter, limit, offset) if err != nil { log.Error().Err(err).Msg("Failed to fetch moderation queue") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch moderation queue"}) return } defer rows.Close() var total int h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM moderation_flags WHERE status = $1`, statusFilter).Scan(&total) var items []gin.H for rows.Next() { var fID uuid.UUID var postID, commentID *uuid.UUID var flagReason, fStatus string var fScores []byte var reviewedBy *uuid.UUID var reviewedAt *time.Time var fCreatedAt time.Time var postBody, postImage, postVideo *string var postAuthorID, commentAuthorID *uuid.UUID var commentBody *string var authorHandle, authorDisplayName *string if err := rows.Scan(&fID, &postID, &commentID, &flagReason, &fScores, &fStatus, &reviewedBy, &reviewedAt, &fCreatedAt, &postBody, &postImage, &postVideo, &postAuthorID, &commentBody, &commentAuthorID, &authorHandle, &authorDisplayName); err != nil { log.Error().Err(err).Msg("Failed to scan moderation flag") continue } var scores map[string]float64 json.Unmarshal(fScores, &scores) contentType := "post" if commentID != nil { contentType = "comment" } items = append(items, gin.H{ "id": fID, "post_id": postID, "comment_id": commentID, "flag_reason": flagReason, "scores": scores, "status": fStatus, "reviewed_by": reviewedBy, "reviewed_at": reviewedAt, "created_at": fCreatedAt, "content_type": contentType, "post_body": postBody, "post_image": postImage, "post_video": postVideo, "comment_body": commentBody, "author_handle": authorHandle, "author_name": authorDisplayName, "post_author_id": postAuthorID, "comment_author_id": commentAuthorID, }) } if items == nil { items = []gin.H{} } c.JSON(http.StatusOK, gin.H{ "items": items, "total": total, "limit": limit, "offset": offset, }) } func (h *AdminHandler) ReviewModerationFlag(c *gin.Context) { ctx := c.Request.Context() adminID, _ := c.Get("user_id") flagID := c.Param("id") var req struct { Action string `json:"action" binding:"required,oneof=approve dismiss remove_content ban_user"` Reason string `json:"reason"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } adminUUID, _ := uuid.Parse(adminID.(string)) flagUUID, err := uuid.Parse(flagID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid flag ID"}) return } switch req.Action { case "approve": // Content is fine, dismiss the flag h.moderationService.UpdateFlagStatus(ctx, flagUUID, "dismissed", adminUUID) case "dismiss": h.moderationService.UpdateFlagStatus(ctx, flagUUID, "dismissed", adminUUID) case "remove_content": // Remove the flagged content h.moderationService.UpdateFlagStatus(ctx, flagUUID, "actioned", adminUUID) // Get the post/comment ID and remove var postID, commentID *uuid.UUID h.pool.QueryRow(ctx, `SELECT post_id, comment_id FROM moderation_flags WHERE id = $1`, flagUUID).Scan(&postID, &commentID) if postID != nil { h.pool.Exec(ctx, `UPDATE posts SET status = 'removed', deleted_at = NOW() WHERE id = $1`, postID) } if commentID != nil { h.pool.Exec(ctx, `UPDATE comments SET status = 'removed', deleted_at = NOW() WHERE id = $1`, commentID) } case "ban_user": h.moderationService.UpdateFlagStatus(ctx, flagUUID, "actioned", adminUUID) // Get the author and ban them var postID, commentID *uuid.UUID h.pool.QueryRow(ctx, `SELECT post_id, comment_id FROM moderation_flags WHERE id = $1`, flagUUID).Scan(&postID, &commentID) var authorID *uuid.UUID if postID != nil { h.pool.QueryRow(ctx, `SELECT author_id FROM posts WHERE id = $1`, postID).Scan(&authorID) } if commentID != nil { h.pool.QueryRow(ctx, `SELECT author_id FROM comments WHERE id = $1`, commentID).Scan(&authorID) } if authorID != nil { h.moderationService.UpdateUserStatus(ctx, *authorID, "banned", adminUUID, req.Reason) } } c.JSON(http.StatusOK, gin.H{"message": "Flag reviewed", "action": req.Action}) } // ────────────────────────────────────────────── // Appeal Management // ────────────────────────────────────────────── func (h *AdminHandler) ListAppeals(c *gin.Context) { ctx := c.Request.Context() limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) statusFilter := c.DefaultQuery("status", "pending") if limit > 200 { limit = 200 } rows, err := h.pool.Query(ctx, ` SELECT ua.id, ua.user_violation_id, ua.user_id, ua.appeal_reason, ua.appeal_context, ua.status, ua.reviewed_by, ua.review_decision, ua.reviewed_at, ua.created_at, uv.violation_type, uv.violation_reason, uv.severity_score, mf.flag_reason, mf.scores, p.body as post_body, p.image_url, c.body as comment_body, pr.handle, pr.display_name, pr.avatar_url FROM user_appeals ua JOIN user_violations uv ON ua.user_violation_id = uv.id LEFT JOIN moderation_flags mf ON uv.moderation_flag_id = mf.id LEFT JOIN posts p ON mf.post_id = p.id LEFT JOIN comments c ON mf.comment_id = c.id JOIN profiles pr ON ua.user_id = pr.id WHERE ua.status = $1 ORDER BY ua.created_at ASC LIMIT $2 OFFSET $3 `, statusFilter, limit, offset) if err != nil { log.Error().Err(err).Msg("Failed to list appeals") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list appeals"}) return } defer rows.Close() var total int h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_appeals WHERE status = $1`, statusFilter).Scan(&total) var appeals []gin.H for rows.Next() { var aID, violationID, userID uuid.UUID var appealReason, appealContext, aStatus string var reviewedBy *uuid.UUID var reviewDecision *string var reviewedAt *time.Time var aCreatedAt time.Time var violationType, violationReason string var severityScore float64 var flagReason *string var flagScores []byte var postBody, postImage, commentBody *string var handle, displayName, avatarURL *string if err := rows.Scan(&aID, &violationID, &userID, &appealReason, &appealContext, &aStatus, &reviewedBy, &reviewDecision, &reviewedAt, &aCreatedAt, &violationType, &violationReason, &severityScore, &flagReason, &flagScores, &postBody, &postImage, &commentBody, &handle, &displayName, &avatarURL); err != nil { log.Error().Err(err).Msg("Failed to scan appeal") continue } var scores map[string]float64 if flagScores != nil { json.Unmarshal(flagScores, &scores) } appeals = append(appeals, gin.H{ "id": aID, "violation_id": violationID, "user_id": userID, "appeal_reason": appealReason, "appeal_context": appealContext, "status": aStatus, "reviewed_by": reviewedBy, "review_decision": reviewDecision, "reviewed_at": reviewedAt, "created_at": aCreatedAt, "violation_type": violationType, "violation_reason": violationReason, "severity_score": severityScore, "flag_reason": flagReason, "flag_scores": scores, "post_body": postBody, "post_image": postImage, "comment_body": commentBody, "user": gin.H{ "handle": handle, "display_name": displayName, "avatar_url": avatarURL, }, }) } if appeals == nil { appeals = []gin.H{} } c.JSON(http.StatusOK, gin.H{ "appeals": appeals, "total": total, "limit": limit, "offset": offset, }) } func (h *AdminHandler) ReviewAppeal(c *gin.Context) { ctx := c.Request.Context() adminID, _ := c.Get("user_id") appealID := c.Param("id") var req struct { Decision string `json:"decision" binding:"required,oneof=approved rejected"` ReviewDecision string `json:"review_decision" binding:"required,min=5"` RestoreContent bool `json:"restore_content"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } adminUUID, _ := uuid.Parse(adminID.(string)) appealUUID, err := uuid.Parse(appealID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid appeal ID"}) return } err = h.appealService.ReviewAppeal(ctx, appealUUID, adminUUID, req.Decision, req.ReviewDecision) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to review appeal"}) return } // If approved and restore_content, restore the content if req.Decision == "approved" && req.RestoreContent { var violationID uuid.UUID h.pool.QueryRow(ctx, `SELECT user_violation_id FROM user_appeals WHERE id = $1`, appealUUID).Scan(&violationID) var flagID uuid.UUID h.pool.QueryRow(ctx, `SELECT moderation_flag_id FROM user_violations WHERE id = $1`, violationID).Scan(&flagID) var postID, commentID *uuid.UUID h.pool.QueryRow(ctx, `SELECT post_id, comment_id FROM moderation_flags WHERE id = $1`, flagID).Scan(&postID, &commentID) if postID != nil { h.pool.Exec(ctx, `UPDATE posts SET status = 'active', deleted_at = NULL WHERE id = $1`, postID) } if commentID != nil { h.pool.Exec(ctx, `UPDATE comments SET status = 'active', deleted_at = NULL WHERE id = $1`, commentID) } } c.JSON(http.StatusOK, gin.H{"message": "Appeal reviewed", "decision": req.Decision}) } // ────────────────────────────────────────────── // Reports Management // ────────────────────────────────────────────── func (h *AdminHandler) ListReports(c *gin.Context) { ctx := c.Request.Context() limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) statusFilter := c.DefaultQuery("status", "pending") rows, err := h.pool.Query(ctx, ` SELECT r.id, r.reporter_id, r.target_user_id, r.post_id, r.comment_id, r.violation_type, r.description, r.status, r.created_at, pr_reporter.handle as reporter_handle, pr_target.handle as target_handle FROM reports r LEFT JOIN profiles pr_reporter ON r.reporter_id = pr_reporter.id LEFT JOIN profiles pr_target ON r.target_user_id = pr_target.id WHERE r.status = $1 ORDER BY r.created_at ASC LIMIT $2 OFFSET $3 `, statusFilter, limit, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list reports"}) return } defer rows.Close() var total int h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM reports WHERE status = $1`, statusFilter).Scan(&total) var reports []gin.H for rows.Next() { var rID, reporterID, targetUserID uuid.UUID var postID, commentID *uuid.UUID var violationType, description, rStatus string var rCreatedAt time.Time var reporterHandle, targetHandle *string if err := rows.Scan(&rID, &reporterID, &targetUserID, &postID, &commentID, &violationType, &description, &rStatus, &rCreatedAt, &reporterHandle, &targetHandle); err != nil { continue } reports = append(reports, gin.H{ "id": rID, "reporter_id": reporterID, "target_user_id": targetUserID, "post_id": postID, "comment_id": commentID, "violation_type": violationType, "description": description, "status": rStatus, "created_at": rCreatedAt, "reporter_handle": reporterHandle, "target_handle": targetHandle, }) } if reports == nil { reports = []gin.H{} } c.JSON(http.StatusOK, gin.H{"reports": reports, "total": total, "limit": limit, "offset": offset}) } func (h *AdminHandler) UpdateReportStatus(c *gin.Context) { ctx := c.Request.Context() reportID := c.Param("id") var req struct { Status string `json:"status" binding:"required,oneof=reviewed dismissed actioned"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } _, err := h.pool.Exec(ctx, `UPDATE reports SET status = $1 WHERE id = $2::uuid`, req.Status, reportID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update report"}) return } c.JSON(http.StatusOK, gin.H{"message": "Report updated"}) } // ────────────────────────────────────────────── // Algorithm / Feed Settings // ────────────────────────────────────────────── func (h *AdminHandler) GetAlgorithmConfig(c *gin.Context) { ctx := c.Request.Context() rows, err := h.pool.Query(ctx, ` SELECT key, value, description, updated_at FROM algorithm_config ORDER BY key `) if err != nil { // Table may not exist yet, return defaults c.JSON(http.StatusOK, gin.H{ "config": []gin.H{ {"key": "feed_recency_weight", "value": "0.4", "description": "Weight for post recency in feed ranking"}, {"key": "feed_engagement_weight", "value": "0.3", "description": "Weight for engagement metrics"}, {"key": "feed_harmony_weight", "value": "0.2", "description": "Weight for author harmony score"}, {"key": "feed_diversity_weight", "value": "0.1", "description": "Weight for content diversity"}, {"key": "moderation_auto_flag_threshold", "value": "0.7", "description": "AI score threshold for auto-flagging"}, {"key": "moderation_auto_remove_threshold", "value": "0.95", "description": "AI score threshold for auto-removal"}, }, }) return } defer rows.Close() var configs []gin.H for rows.Next() { var key, value string var description *string var updatedAt time.Time if err := rows.Scan(&key, &value, &description, &updatedAt); err == nil { configs = append(configs, gin.H{ "key": key, "value": value, "description": description, "updated_at": updatedAt, }) } } if configs == nil { configs = []gin.H{} } c.JSON(http.StatusOK, gin.H{"config": configs}) } func (h *AdminHandler) UpdateAlgorithmConfig(c *gin.Context) { ctx := c.Request.Context() var req struct { Key string `json:"key" binding:"required"` Value string `json:"value" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } _, err := h.pool.Exec(ctx, ` INSERT INTO algorithm_config (key, value, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW() `, req.Key, req.Value) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update config"}) return } c.JSON(http.StatusOK, gin.H{"message": "Config updated"}) } // ────────────────────────────────────────────── // Categories Management // ────────────────────────────────────────────── func (h *AdminHandler) ListCategories(c *gin.Context) { ctx := c.Request.Context() rows, err := h.pool.Query(ctx, ` SELECT id, slug, name, description, is_sensitive, created_at FROM categories ORDER BY name `) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list categories"}) return } defer rows.Close() var categories []gin.H for rows.Next() { var id uuid.UUID var slug, name string var description *string var isSensitive bool var createdAt time.Time if err := rows.Scan(&id, &slug, &name, &description, &isSensitive, &createdAt); err == nil { categories = append(categories, gin.H{ "id": id, "slug": slug, "name": name, "description": description, "is_sensitive": isSensitive, "created_at": createdAt, }) } } c.JSON(http.StatusOK, gin.H{"categories": categories}) } func (h *AdminHandler) CreateCategory(c *gin.Context) { ctx := c.Request.Context() var req struct { Slug string `json:"slug" binding:"required"` Name string `json:"name" binding:"required"` Description string `json:"description"` IsSensitive bool `json:"is_sensitive"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var id uuid.UUID err := h.pool.QueryRow(ctx, ` INSERT INTO categories (slug, name, description, is_sensitive) VALUES ($1, $2, $3, $4) RETURNING id `, req.Slug, req.Name, req.Description, req.IsSensitive).Scan(&id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create category"}) return } c.JSON(http.StatusCreated, gin.H{"id": id, "message": "Category created"}) } func (h *AdminHandler) UpdateCategory(c *gin.Context) { ctx := c.Request.Context() catID := c.Param("id") var req struct { Name string `json:"name"` Description string `json:"description"` IsSensitive *bool `json:"is_sensitive"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Name != "" { h.pool.Exec(ctx, `UPDATE categories SET name = $1 WHERE id = $2::uuid`, req.Name, catID) } if req.Description != "" { h.pool.Exec(ctx, `UPDATE categories SET description = $1 WHERE id = $2::uuid`, req.Description, catID) } if req.IsSensitive != nil { h.pool.Exec(ctx, `UPDATE categories SET is_sensitive = $1 WHERE id = $2::uuid`, *req.IsSensitive, catID) } c.JSON(http.StatusOK, gin.H{"message": "Category updated"}) } // ────────────────────────────────────────────── // System Health // ────────────────────────────────────────────── func (h *AdminHandler) GetSystemHealth(c *gin.Context) { ctx := c.Request.Context() health := gin.H{"status": "healthy"} // Database check start := time.Now() err := h.pool.Ping(ctx) dbLatency := time.Since(start).Milliseconds() if err != nil { health["database"] = gin.H{"status": "unhealthy", "error": err.Error()} } else { health["database"] = gin.H{"status": "healthy", "latency_ms": dbLatency} } // Pool stats poolStats := h.pool.Stat() health["connection_pool"] = gin.H{ "total": poolStats.TotalConns(), "idle": poolStats.IdleConns(), "acquired": poolStats.AcquiredConns(), "max": poolStats.MaxConns(), "constructing": poolStats.ConstructingConns(), } // Table sizes var dbSize string h.pool.QueryRow(ctx, `SELECT pg_size_pretty(pg_database_size('sojorn'))`).Scan(&dbSize) health["database_size"] = dbSize c.JSON(http.StatusOK, health) } // ────────────────────────────────────────────── // Audit Log // ────────────────────────────────────────────── func (h *AdminHandler) GetAuditLog(c *gin.Context) { ctx := c.Request.Context() limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) rows, err := h.pool.Query(ctx, ` SELECT al.id, al.actor_id, al.action, al.target_type, al.target_id, al.details, al.created_at, pr.handle as actor_handle FROM audit_log al LEFT JOIN profiles pr ON al.actor_id = pr.id ORDER BY al.created_at DESC LIMIT $1 OFFSET $2 `, limit, offset) if err != nil { // Table may not exist c.JSON(http.StatusOK, gin.H{"entries": []gin.H{}, "total": 0}) return } defer rows.Close() var entries []gin.H for rows.Next() { var id uuid.UUID var actorID *uuid.UUID var action, targetType string var targetID *uuid.UUID var details *string var createdAt time.Time var actorHandle *string if err := rows.Scan(&id, &actorID, &action, &targetType, &targetID, &details, &createdAt, &actorHandle); err == nil { entries = append(entries, gin.H{ "id": id, "actor_id": actorID, "action": action, "target_type": targetType, "target_id": targetID, "details": details, "created_at": createdAt, "actor_handle": actorHandle, }) } } if entries == nil { entries = []gin.H{} } c.JSON(http.StatusOK, gin.H{"entries": entries, "limit": limit, "offset": offset}) } // ────────────────────────────────────────────── // R2 Storage Browser // ────────────────────────────────────────────── func (h *AdminHandler) GetStorageStats(c *gin.Context) { if h.s3Client == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "R2 storage not configured"}) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) defer cancel() type bucketStats struct { Name string `json:"name"` ObjectCount int `json:"object_count"` TotalSize int64 `json:"total_size"` Domain string `json:"domain"` } getBucketStats := func(bucket, domain string) bucketStats { stats := bucketStats{Name: bucket, Domain: domain} var continuationToken *string for { input := &s3.ListObjectsV2Input{ Bucket: aws.String(bucket), ContinuationToken: continuationToken, MaxKeys: aws.Int32(1000), } output, err := h.s3Client.ListObjectsV2(ctx, input) if err != nil { log.Error().Err(err).Str("bucket", bucket).Msg("Failed to list R2 objects for stats") break } for _, obj := range output.Contents { stats.ObjectCount++ stats.TotalSize += aws.ToInt64(obj.Size) } if !aws.ToBool(output.IsTruncated) { break } continuationToken = output.NextContinuationToken } return stats } mediaStats := getBucketStats(h.mediaBucket, h.imgDomain) videoStats := getBucketStats(h.videoBucket, h.vidDomain) c.JSON(http.StatusOK, gin.H{ "buckets": []bucketStats{mediaStats, videoStats}, "total_objects": mediaStats.ObjectCount + videoStats.ObjectCount, "total_size": mediaStats.TotalSize + videoStats.TotalSize, }) } func (h *AdminHandler) ListStorageObjects(c *gin.Context) { if h.s3Client == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "R2 storage not configured"}) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() bucket := c.DefaultQuery("bucket", h.mediaBucket) prefix := c.Query("prefix") marker := c.Query("cursor") limitStr := c.DefaultQuery("limit", "50") limit, _ := strconv.Atoi(limitStr) if limit > 200 { limit = 200 } // Validate bucket if bucket != h.mediaBucket && bucket != h.videoBucket { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bucket name"}) return } // Determine public domain domain := h.imgDomain if bucket == h.videoBucket { domain = h.vidDomain } input := &s3.ListObjectsV2Input{ Bucket: aws.String(bucket), MaxKeys: aws.Int32(int32(limit)), } if prefix != "" { input.Prefix = aws.String(prefix) input.Delimiter = aws.String("/") } if marker != "" { input.ContinuationToken = aws.String(marker) } output, err := h.s3Client.ListObjectsV2(ctx, input) if err != nil { log.Error().Err(err).Str("bucket", bucket).Msg("Failed to list R2 objects") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list objects"}) return } // Folders (common prefixes) var folders []string for _, cp := range output.CommonPrefixes { folders = append(folders, aws.ToString(cp.Prefix)) } // Objects var objects []gin.H for _, obj := range output.Contents { key := aws.ToString(obj.Key) publicURL := fmt.Sprintf("https://%s/%s", domain, key) objects = append(objects, gin.H{ "key": key, "size": aws.ToInt64(obj.Size), "last_modified": obj.LastModified, "etag": strings.Trim(aws.ToString(obj.ETag), "\""), "url": publicURL, }) } if objects == nil { objects = []gin.H{} } if folders == nil { folders = []string{} } var nextCursor *string if aws.ToBool(output.IsTruncated) { nextCursor = output.NextContinuationToken } c.JSON(http.StatusOK, gin.H{ "objects": objects, "folders": folders, "bucket": bucket, "prefix": prefix, "next_cursor": nextCursor, "count": len(objects), }) } func (h *AdminHandler) GetStorageObject(c *gin.Context) { if h.s3Client == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "R2 storage not configured"}) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() bucket := c.DefaultQuery("bucket", h.mediaBucket) key := c.Query("key") if key == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "key parameter required"}) return } if bucket != h.mediaBucket && bucket != h.videoBucket { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bucket name"}) return } domain := h.imgDomain if bucket == h.videoBucket { domain = h.vidDomain } output, err := h.s3Client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), }) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Object not found"}) return } c.JSON(http.StatusOK, gin.H{ "key": key, "bucket": bucket, "size": aws.ToInt64(output.ContentLength), "content_type": aws.ToString(output.ContentType), "last_modified": output.LastModified, "etag": strings.Trim(aws.ToString(output.ETag), "\""), "url": fmt.Sprintf("https://%s/%s", domain, key), "cache_control": aws.ToString(output.CacheControl), }) } func (h *AdminHandler) DeleteStorageObject(c *gin.Context) { if h.s3Client == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "R2 storage not configured"}) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() adminID, _ := c.Get("user_id") var req struct { Bucket string `json:"bucket" binding:"required"` Key string `json:"key" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Bucket != h.mediaBucket && req.Bucket != h.videoBucket { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bucket name"}) return } _, err := h.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(req.Bucket), Key: aws.String(req.Key), }) if err != nil { log.Error().Err(err).Str("bucket", req.Bucket).Str("key", req.Key).Msg("Failed to delete R2 object") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete object"}) return } // Audit log adminUUID, _ := uuid.Parse(adminID.(string)) h.pool.Exec(ctx, ` INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'admin_delete_storage_object', 'storage', NULL, $2) `, adminUUID, fmt.Sprintf(`{"bucket":"%s","key":"%s"}`, req.Bucket, req.Key)) c.JSON(http.StatusOK, gin.H{"message": "Object deleted"}) }