package handlers import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "path/filepath" "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/rs/zerolog/log" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/services" "golang.org/x/crypto/bcrypt" ) type AdminHandler struct { pool *pgxpool.Pool moderationService *services.ModerationService appealService *services.AppealService emailService *services.EmailService openRouterService *services.OpenRouterService azureOpenAIService *services.AzureOpenAIService officialAccountsService *services.OfficialAccountsService linkPreviewService *services.LinkPreviewService localAIService *services.LocalAIService jwtSecret string s3Client *s3.Client mediaBucket string videoBucket string imgDomain string vidDomain string } func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, openRouterService *services.OpenRouterService, azureOpenAIService *services.AzureOpenAIService, officialAccountsService *services.OfficialAccountsService, linkPreviewService *services.LinkPreviewService, localAIService *services.LocalAIService, jwtSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler { return &AdminHandler{ pool: pool, moderationService: moderationService, appealService: appealService, emailService: emailService, openRouterService: openRouterService, azureOpenAIService: azureOpenAIService, officialAccountsService: officialAccountsService, linkPreviewService: linkPreviewService, localAIService: localAIService, jwtSecret: jwtSecret, s3Client: s3Client, mediaBucket: mediaBucket, videoBucket: videoBucket, imgDomain: imgDomain, vidDomain: vidDomain, } } // ────────────────────────────────────────────── // Admin Login (invisible ALTCHA verification) // ────────────────────────────────────────────── type AdminLoginRequest struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required"` AltchaToken string `json:"altcha_token"` } func (h *AdminHandler) AdminLogin(c *gin.Context) { ctx := c.Request.Context() var req AdminLoginRequest 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 ALTCHA token altchaService := services.NewAltchaService(h.jwtSecret) remoteIP := c.ClientIP() altchaResp, err := altchaService.VerifyToken(req.AltchaToken, remoteIP) if err != nil { log.Error().Err(err).Msg("Admin login: ALTCHA verification failed") c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"}) return } if !altchaResp.Verified { errorMsg := altchaService.GetErrorMessage(altchaResp.Error) log.Warn().Str("email", req.Email).Str("error", errorMsg).Msg("Admin login: ALTCHA validation failed") c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg}) 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 if req.Status == "banned" { _, err := h.pool.Exec(ctx, `UPDATE users SET status = 'banned' WHERE id = $1::uuid`, targetUserID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"}) return } // Revoke ALL refresh tokens immediately h.pool.Exec(ctx, `UPDATE refresh_tokens SET revoked = true WHERE user_id = $1::uuid`, targetUserID) // Jail all their content (hidden from feeds until restored) h.pool.Exec(ctx, `UPDATE posts SET status = 'jailed' WHERE author_id = $1::uuid AND status = 'active' AND deleted_at IS NULL`, targetUserID) h.pool.Exec(ctx, `UPDATE comments SET status = 'jailed' WHERE author_id = $1::uuid AND status = 'active' AND deleted_at IS NULL`, targetUserID) } else if req.Status == "suspended" { suspendUntil := time.Now().Add(7 * 24 * time.Hour) // Default 7 day suspension from admin _, err := h.pool.Exec(ctx, `UPDATE users SET status = 'suspended', suspended_until = $2 WHERE id = $1::uuid`, targetUserID, suspendUntil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"}) return } // Jail all their content during suspension h.pool.Exec(ctx, `UPDATE posts SET status = 'jailed' WHERE author_id = $1::uuid AND status = 'active' AND deleted_at IS NULL`, targetUserID) h.pool.Exec(ctx, `UPDATE comments SET status = 'jailed' WHERE author_id = $1::uuid AND status = 'active' AND deleted_at IS NULL`, targetUserID) } else { _, err := h.pool.Exec(ctx, `UPDATE users SET status = $1, suspended_until = NULL WHERE id = $2::uuid`, req.Status, targetUserID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"}) return } // If reactivating, restore any jailed content if req.Status == "active" { h.pool.Exec(ctx, `UPDATE posts SET status = 'active' WHERE author_id = $1::uuid AND status = 'jailed'`, targetUserID) h.pool.Exec(ctx, `UPDATE comments SET status = 'active' WHERE author_id = $1::uuid AND status = 'jailed'`, targetUserID) } } // 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) // Send notification email if h.emailService != nil { var userEmail, displayName string h.pool.QueryRow(ctx, `SELECT u.email, COALESCE(p.display_name, '') FROM users u LEFT JOIN profiles p ON p.id = u.id WHERE u.id = $1::uuid`, targetUserID).Scan(&userEmail, &displayName) if userEmail != "" { go func() { switch req.Status { case "banned": if err := h.emailService.SendBanNotificationEmail(userEmail, displayName, req.Reason); err != nil { log.Error().Err(err).Str("user", targetUserID).Msg("Failed to send ban notification email") } case "suspended": if err := h.emailService.SendSuspensionNotificationEmail(userEmail, displayName, req.Reason, "7 days"); err != nil { log.Error().Err(err).Str("user", targetUserID).Msg("Failed to send suspension notification email") } case "active": if oldStatus == "banned" || oldStatus == "suspended" { reason := req.Reason if reason == "" { reason = "Your account has been reviewed and restored." } if err := h.emailService.SendAccountRestoredEmail(userEmail, displayName, reason); err != nil { log.Error().Err(err).Str("user", targetUserID).Msg("Failed to send account restored email") } } } }() } } 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"}) } // AdminUpdateProfile allows full profile editing for official accounts. func (h *AdminHandler) AdminUpdateProfile(c *gin.Context) { ctx := c.Request.Context() targetUserID := c.Param("id") var req struct { Handle *string `json:"handle"` DisplayName *string `json:"display_name"` Bio *string `json:"bio"` AvatarURL *string `json:"avatar_url"` CoverURL *string `json:"cover_url"` Location *string `json:"location"` Website *string `json:"website"` Country *string `json:"origin_country"` BirthMonth *int `json:"birth_month"` BirthYear *int `json:"birth_year"` IsPrivate *bool `json:"is_private"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Build dynamic UPDATE sets := []string{} args := []interface{}{} idx := 1 addField := func(col string, val interface{}) { sets = append(sets, fmt.Sprintf("%s = $%d", col, idx)) args = append(args, val) idx++ } if req.Handle != nil { addField("handle", *req.Handle) } if req.DisplayName != nil { addField("display_name", *req.DisplayName) } if req.Bio != nil { addField("bio", *req.Bio) } if req.AvatarURL != nil { addField("avatar_url", *req.AvatarURL) } if req.CoverURL != nil { addField("cover_url", *req.CoverURL) } if req.Location != nil { addField("location", *req.Location) } if req.Website != nil { addField("website", *req.Website) } if req.Country != nil { addField("origin_country", *req.Country) } if req.BirthMonth != nil { addField("birth_month", *req.BirthMonth) } if req.BirthYear != nil { addField("birth_year", *req.BirthYear) } if req.IsPrivate != nil { addField("is_private", *req.IsPrivate) } if len(sets) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"}) return } sets = append(sets, fmt.Sprintf("updated_at = $%d", idx)) args = append(args, time.Now()) idx++ query := fmt.Sprintf("UPDATE profiles SET %s WHERE id = $%d::uuid", strings.Join(sets, ", "), idx) args = append(args, targetUserID) _, err := h.pool.Exec(ctx, query, args...) if err != nil { log.Error().Err(err).Str("user_id", targetUserID).Msg("Failed to update profile") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile: " + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "Profile updated"}) } // resolveUserID accepts either a UUID or a handle and returns the user's UUID. func (h *AdminHandler) resolveUserID(ctx context.Context, input string) (string, error) { // If it parses as a UUID, return as-is if _, err := uuid.Parse(input); err == nil { return input, nil } // Otherwise, treat as a handle and look it up handle := strings.TrimPrefix(input, "@") var id uuid.UUID err := h.pool.QueryRow(ctx, `SELECT id FROM profiles WHERE handle = $1`, handle).Scan(&id) if err != nil { return "", fmt.Errorf("user not found: %s", input) } return id.String(), nil } // AdminManageFollow adds or removes follow relationships for official accounts. func (h *AdminHandler) AdminManageFollow(c *gin.Context) { ctx := c.Request.Context() targetUserID := c.Param("id") var req struct { Action string `json:"action"` // "add" or "remove" UserID string `json:"user_id"` // UUID or handle of the other user Relation string `json:"relation"` // "follower" (user follows target) or "following" (target follows user) } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Action != "add" && req.Action != "remove" { c.JSON(http.StatusBadRequest, gin.H{"error": "action must be 'add' or 'remove'"}) return } if req.Relation != "follower" && req.Relation != "following" { c.JSON(http.StatusBadRequest, gin.H{"error": "relation must be 'follower' or 'following'"}) return } // Resolve handle or UUID to a UUID resolvedID, err := h.resolveUserID(ctx, req.UserID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Determine follower_id and following_id var followerID, followingID string if req.Relation == "follower" { followerID = resolvedID followingID = targetUserID } else { followerID = targetUserID followingID = resolvedID } if req.Action == "add" { _, err := h.pool.Exec(ctx, ` INSERT INTO follows (follower_id, following_id, status) VALUES ($1::uuid, $2::uuid, 'accepted') ON CONFLICT (follower_id, following_id) DO UPDATE SET status = 'accepted' `, followerID, followingID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add follow: " + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "Follow added"}) } else { _, err := h.pool.Exec(ctx, `DELETE FROM follows WHERE follower_id = $1::uuid AND following_id = $2::uuid`, followerID, followingID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove follow: " + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "Follow removed"}) } } // AdminListFollows lists followers or following for a user. func (h *AdminHandler) AdminListFollows(c *gin.Context) { ctx := c.Request.Context() targetUserID := c.Param("id") relation := c.DefaultQuery("relation", "followers") // "followers" or "following" limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) var query string if relation == "following" { query = ` SELECT p.id, p.handle, p.display_name, p.avatar_url, p.is_official, f.created_at FROM follows f JOIN profiles p ON p.id = f.following_id WHERE f.follower_id = $1::uuid AND f.status = 'accepted' ORDER BY f.created_at DESC LIMIT $2 ` } else { query = ` SELECT p.id, p.handle, p.display_name, p.avatar_url, p.is_official, f.created_at FROM follows f JOIN profiles p ON p.id = f.follower_id WHERE f.following_id = $1::uuid AND f.status = 'accepted' ORDER BY f.created_at DESC LIMIT $2 ` } rows, err := h.pool.Query(ctx, query, targetUserID, limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list follows"}) return } defer rows.Close() var users []gin.H for rows.Next() { var id uuid.UUID var handle, displayName *string var avatarURL *string var isOfficial *bool var createdAt time.Time if err := rows.Scan(&id, &handle, &displayName, &avatarURL, &isOfficial, &createdAt); err != nil { continue } users = append(users, gin.H{ "id": id, "handle": handle, "display_name": displayName, "avatar_url": avatarURL, "is_official": isOfficial, "followed_at": createdAt, }) } if users == nil { users = []gin.H{} } c.JSON(http.StatusOK, gin.H{"users": users, "relation": relation}) } // ────────────────────────────────────────────── // 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, COALESCE((SELECT COUNT(*) FROM post_likes pl WHERE pl.post_id = p.id), 0) AS like_count, COALESCE((SELECT COUNT(*) FROM comments c WHERE c.post_id = p.id AND c.deleted_at IS NULL), 0) AS 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, COALESCE((SELECT COUNT(*) FROM post_likes pl WHERE pl.post_id = p.id), 0), COALESCE((SELECT COUNT(*) FROM comments c WHERE c.post_id = p.id AND c.deleted_at IS NULL), 0), 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)) // If post was removed, record a strike and notify the author if req.Status == "removed" || req.Status == "flagged" { var authorID uuid.UUID var authorEmail, displayName string err := h.pool.QueryRow(ctx, ` SELECT p.author_id, u.email, COALESCE(pr.display_name, '') FROM posts p JOIN users u ON u.id = p.author_id LEFT JOIN profiles pr ON pr.id = p.author_id WHERE p.id = $1::uuid `, postID).Scan(&authorID, &authorEmail, &displayName) if err == nil && req.Status == "removed" { // Record a strike reason := req.Reason if reason == "" { reason = "Post removed by moderation team" } h.pool.Exec(ctx, ` INSERT INTO content_strikes (user_id, category, content_snippet, created_at) VALUES ($1, 'moderation', $2, NOW()) `, authorID, reason) // Count strikes var strikeCount int h.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM content_strikes WHERE user_id = $1 AND created_at > NOW() - INTERVAL '30 days' `, authorID).Scan(&strikeCount) // Send email notification if h.emailService != nil && authorEmail != "" { go func() { if err := h.emailService.SendContentRemovalEmail(authorEmail, displayName, "post", reason, strikeCount); err != nil { log.Error().Err(err).Str("user", authorID.String()).Msg("Failed to send post removal email") } }() } } } 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") // Get author info before deleting var authorID uuid.UUID var authorEmail, displayName string h.pool.QueryRow(ctx, ` SELECT p.author_id, u.email, COALESCE(pr.display_name, '') FROM posts p JOIN users u ON u.id = p.author_id LEFT JOIN profiles pr ON pr.id = p.author_id WHERE p.id = $1::uuid `, postID).Scan(&authorID, &authorEmail, &displayName) _, 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) // Record a strike on the author if authorID != uuid.Nil { reason := "Post deleted by moderation team" h.pool.Exec(ctx, ` INSERT INTO content_strikes (user_id, category, content_snippet, created_at) VALUES ($1, 'moderation', $2, NOW()) `, authorID, reason) var strikeCount int h.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM content_strikes WHERE user_id = $1 AND created_at > NOW() - INTERVAL '30 days' `, authorID).Scan(&strikeCount) if h.emailService != nil && authorEmail != "" { go func() { if err := h.emailService.SendContentRemovalEmail(authorEmail, displayName, "post", reason, strikeCount); err != nil { log.Error().Err(err).Str("user", authorID.String()).Msg("Failed to send post removal email") } }() } } c.JSON(http.StatusOK, gin.H{"message": "Post deleted"}) } // ────────────────────────────────────────────── // Bulk Actions // ────────────────────────────────────────────── func (h *AdminHandler) BulkUpdatePosts(c *gin.Context) { ctx := c.Request.Context() adminID, _ := c.Get("user_id") var req struct { IDs []string `json:"ids" binding:"required"` Action string `json:"action" binding:"required"` Reason string `json:"reason"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var affected int64 for _, id := range req.IDs { var err error switch req.Action { case "remove": tag, e := h.pool.Exec(ctx, `UPDATE posts SET status = 'removed' WHERE id = $1::uuid AND deleted_at IS NULL`, id) err = e affected += tag.RowsAffected() case "activate": tag, e := h.pool.Exec(ctx, `UPDATE posts SET status = 'active' WHERE id = $1::uuid AND deleted_at IS NULL`, id) err = e affected += tag.RowsAffected() case "delete": tag, e := h.pool.Exec(ctx, `UPDATE posts SET deleted_at = NOW(), status = 'removed' WHERE id = $1::uuid`, id) err = e affected += tag.RowsAffected() default: c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"}) return } if err != nil { log.Error().Err(err).Str("post_id", id).Msg("Bulk post action failed") } } adminUUID, _ := uuid.Parse(adminID.(string)) h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, details) VALUES ($1, $2, 'post', $3)`, adminUUID, "bulk_"+req.Action+"_posts", fmt.Sprintf(`{"count":%d,"reason":"%s"}`, affected, req.Reason)) c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d posts updated", affected), "affected": affected}) } func (h *AdminHandler) BulkUpdateUsers(c *gin.Context) { ctx := c.Request.Context() adminID, _ := c.Get("user_id") var req struct { IDs []string `json:"ids" binding:"required"` Action string `json:"action" binding:"required"` Reason string `json:"reason"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var affected int64 for _, id := range req.IDs { var err error switch req.Action { case "ban": tag, e := h.pool.Exec(ctx, `UPDATE users SET status = 'banned' WHERE id = $1::uuid`, id) err = e affected += tag.RowsAffected() case "suspend": tag, e := h.pool.Exec(ctx, `UPDATE users SET status = 'suspended' WHERE id = $1::uuid`, id) err = e affected += tag.RowsAffected() case "activate": tag, e := h.pool.Exec(ctx, `UPDATE users SET status = 'active' WHERE id = $1::uuid`, id) err = e affected += tag.RowsAffected() default: c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"}) return } if err != nil { log.Error().Err(err).Str("user_id", id).Msg("Bulk user action failed") } } adminUUID, _ := uuid.Parse(adminID.(string)) h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, details) VALUES ($1, $2, 'user', $3)`, adminUUID, "bulk_"+req.Action+"_users", fmt.Sprintf(`{"count":%d,"reason":"%s"}`, affected, req.Reason)) c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d users updated", affected), "affected": affected}) } func (h *AdminHandler) BulkReviewModeration(c *gin.Context) { ctx := c.Request.Context() adminID, _ := c.Get("user_id") var req struct { IDs []string `json:"ids" binding:"required"` Action string `json:"action" binding:"required"` 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)) var affected int64 for _, id := range req.IDs { var newStatus string switch req.Action { case "approve": newStatus = "approved" case "dismiss": newStatus = "dismissed" case "remove_content": newStatus = "actioned" default: c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"}) return } tag, err := h.pool.Exec(ctx, `UPDATE moderation_flags SET status = $1, reviewed_by = $2, reviewed_at = NOW() WHERE id = $3::uuid AND status = 'pending'`, newStatus, adminUUID, id) if err != nil { log.Error().Err(err).Str("flag_id", id).Msg("Bulk moderation action failed") } affected += tag.RowsAffected() if req.Action == "remove_content" { h.pool.Exec(ctx, `UPDATE posts SET status = 'removed' WHERE id = (SELECT post_id FROM moderation_flags WHERE id = $1::uuid)`, id) } } h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, details) VALUES ($1, $2, 'moderation', $3)`, adminUUID, "bulk_"+req.Action+"_moderation", fmt.Sprintf(`{"count":%d}`, affected)) c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d flags updated", affected), "affected": affected}) } func (h *AdminHandler) BulkUpdateReports(c *gin.Context) { ctx := c.Request.Context() adminID, _ := c.Get("user_id") var req struct { IDs []string `json:"ids" binding:"required"` Action string `json:"action" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Action != "actioned" && req.Action != "dismissed" { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action, must be actioned or dismissed"}) return } var affected int64 for _, id := range req.IDs { tag, err := h.pool.Exec(ctx, `UPDATE reports SET status = $1 WHERE id = $2::uuid AND status = 'pending'`, req.Action, id) if err != nil { log.Error().Err(err).Str("report_id", id).Msg("Bulk report action failed") } affected += tag.RowsAffected() } adminUUID, _ := uuid.Parse(adminID.(string)) h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, details) VALUES ($1, $2, 'report', $3)`, adminUUID, "bulk_"+req.Action+"_reports", fmt.Sprintf(`{"count":%d}`, affected)) c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d reports updated", affected), "affected": affected}) } // ────────────────────────────────────────────── // 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) // Remove the flagged content 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) } 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 && authorID == nil { h.pool.QueryRow(ctx, `SELECT author_id FROM comments WHERE id = $1`, commentID).Scan(&authorID) } if authorID != nil { // Ban the user h.pool.Exec(ctx, `UPDATE users SET status = 'banned' WHERE id = $1`, authorID) // Revoke all refresh tokens h.pool.Exec(ctx, `UPDATE refresh_tokens SET revoked = true WHERE user_id = $1`, authorID) // Jail all their content h.pool.Exec(ctx, `UPDATE posts SET status = 'jailed' WHERE author_id = $1 AND status = 'active' AND deleted_at IS NULL`, authorID) h.pool.Exec(ctx, `UPDATE comments SET status = 'jailed' WHERE author_id = $1 AND status = 'active' AND deleted_at IS NULL`, authorID) // Log status change h.pool.Exec(ctx, `INSERT INTO user_status_history (user_id, old_status, new_status, reason, changed_by) VALUES ($1, 'active', 'banned', $2, $3)`, authorID, req.Reason, adminUUID) // Send ban email if h.emailService != nil { var userEmail, displayName string h.pool.QueryRow(ctx, `SELECT u.email, COALESCE(p.display_name, '') FROM users u LEFT JOIN profiles p ON p.id = u.id WHERE u.id = $1`, authorID).Scan(&userEmail, &displayName) if userEmail != "" { go func() { h.emailService.SendBanNotificationEmail(userEmail, displayName, 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"}) } // ────────────────────────────────────────────── // Reserved Usernames Management // ────────────────────────────────────────────── func (h *AdminHandler) ListReservedUsernames(c *gin.Context) { ctx := c.Request.Context() category := c.Query("category") search := c.Query("search") limit := 50 offset := 0 if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 { limit = v } if v, err := strconv.Atoi(c.Query("offset")); err == nil && v >= 0 { offset = v } query := `SELECT id, username, category, reason, created_at FROM reserved_usernames WHERE 1=1` args := []interface{}{} argIdx := 1 if category != "" { query += fmt.Sprintf(` AND category = $%d`, argIdx) args = append(args, category) argIdx++ } if search != "" { query += fmt.Sprintf(` AND username ILIKE $%d`, argIdx) args = append(args, "%"+search+"%") argIdx++ } // Count countQuery := strings.Replace(query, "id, username, category, reason, created_at", "COUNT(*)", 1) var total int h.pool.QueryRow(ctx, countQuery, args...).Scan(&total) query += fmt.Sprintf(` ORDER BY username ASC LIMIT $%d OFFSET $%d`, argIdx, argIdx+1) args = append(args, limit, offset) rows, err := h.pool.Query(ctx, query, args...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list reserved usernames"}) return } defer rows.Close() type ReservedUsername struct { ID string `json:"id"` Username string `json:"username"` Category string `json:"category"` Reason *string `json:"reason"` CreatedAt time.Time `json:"created_at"` } var items []ReservedUsername for rows.Next() { var item ReservedUsername if err := rows.Scan(&item.ID, &item.Username, &item.Category, &item.Reason, &item.CreatedAt); err == nil { items = append(items, item) } } if items == nil { items = []ReservedUsername{} } c.JSON(http.StatusOK, gin.H{"reserved_usernames": items, "total": total}) } func (h *AdminHandler) AddReservedUsername(c *gin.Context) { ctx := c.Request.Context() adminID, _ := c.Get("user_id") var req struct { Username string `json:"username" binding:"required"` Category string `json:"category"` Reason string `json:"reason"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.Username = strings.ToLower(strings.TrimSpace(req.Username)) if req.Category == "" { req.Category = "custom" } adminUUID, _ := uuid.Parse(adminID.(string)) _, err := h.pool.Exec(ctx, ` INSERT INTO reserved_usernames (username, category, reason, added_by) VALUES ($1, $2, $3, $4) ON CONFLICT (username) DO UPDATE SET category = $2, reason = $3 `, req.Username, req.Category, req.Reason, adminUUID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add reserved username"}) return } // Audit h.pool.Exec(ctx, ` INSERT INTO audit_log (actor_id, action, target_type, details) VALUES ($1, 'admin_reserve_username', 'username', $2) `, adminUUID, fmt.Sprintf(`{"username":"%s","category":"%s"}`, req.Username, req.Category)) c.JSON(http.StatusOK, gin.H{"message": "Username reserved"}) } func (h *AdminHandler) RemoveReservedUsername(c *gin.Context) { ctx := c.Request.Context() adminID, _ := c.Get("user_id") id := c.Param("id") var username string h.pool.QueryRow(ctx, `SELECT username FROM reserved_usernames WHERE id = $1`, id).Scan(&username) _, err := h.pool.Exec(ctx, `DELETE FROM reserved_usernames WHERE id = $1`, id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove reserved username"}) return } adminUUID, _ := uuid.Parse(adminID.(string)) h.pool.Exec(ctx, ` INSERT INTO audit_log (actor_id, action, target_type, details) VALUES ($1, 'admin_unreserve_username', 'username', $2) `, adminUUID, fmt.Sprintf(`{"username":"%s"}`, username)) c.JSON(http.StatusOK, gin.H{"message": "Reserved username removed"}) } func (h *AdminHandler) BulkAddReservedUsernames(c *gin.Context) { ctx := c.Request.Context() adminID, _ := c.Get("user_id") var req struct { Usernames []string `json:"usernames" binding:"required"` Category string `json:"category"` Reason string `json:"reason"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Category == "" { req.Category = "custom" } adminUUID, _ := uuid.Parse(adminID.(string)) added := 0 for _, u := range req.Usernames { u = strings.ToLower(strings.TrimSpace(u)) if u == "" { continue } _, err := h.pool.Exec(ctx, ` INSERT INTO reserved_usernames (username, category, reason, added_by) VALUES ($1, $2, $3, $4) ON CONFLICT (username) DO NOTHING `, u, req.Category, req.Reason, adminUUID) if err == nil { added++ } } c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Added %d reserved usernames", added), "added": added}) } // ────────────────────────────────────────────── // Username Claim Requests // ────────────────────────────────────────────── func (h *AdminHandler) ListClaimRequests(c *gin.Context) { ctx := c.Request.Context() status := c.DefaultQuery("status", "pending") limit := 50 offset := 0 if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 { limit = v } if v, err := strconv.Atoi(c.Query("offset")); err == nil && v >= 0 { offset = v } var total int h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM username_claim_requests WHERE status = $1`, status).Scan(&total) rows, err := h.pool.Query(ctx, ` SELECT id, requested_username, requester_email, requester_name, requester_user_id, organization, justification, proof_url, status, review_notes, reviewed_at, created_at FROM username_claim_requests WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 `, status, limit, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list claim requests"}) return } defer rows.Close() type ClaimRequest struct { ID string `json:"id"` RequestedUsername string `json:"requested_username"` RequesterEmail string `json:"requester_email"` RequesterName *string `json:"requester_name"` RequesterUserID *string `json:"requester_user_id"` Organization *string `json:"organization"` Justification string `json:"justification"` ProofURL *string `json:"proof_url"` Status string `json:"status"` ReviewNotes *string `json:"review_notes"` ReviewedAt *time.Time `json:"reviewed_at"` CreatedAt time.Time `json:"created_at"` } var items []ClaimRequest for rows.Next() { var item ClaimRequest if err := rows.Scan( &item.ID, &item.RequestedUsername, &item.RequesterEmail, &item.RequesterName, &item.RequesterUserID, &item.Organization, &item.Justification, &item.ProofURL, &item.Status, &item.ReviewNotes, &item.ReviewedAt, &item.CreatedAt, ); err == nil { items = append(items, item) } } if items == nil { items = []ClaimRequest{} } c.JSON(http.StatusOK, gin.H{"claim_requests": items, "total": total}) } func (h *AdminHandler) ReviewClaimRequest(c *gin.Context) { ctx := c.Request.Context() adminID, _ := c.Get("user_id") id := c.Param("id") var req struct { Decision string `json:"decision" binding:"required"` // approved, denied Notes string `json:"notes"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Decision != "approved" && req.Decision != "denied" { c.JSON(http.StatusBadRequest, gin.H{"error": "Decision must be 'approved' or 'denied'"}) return } adminUUID, _ := uuid.Parse(adminID.(string)) _, err := h.pool.Exec(ctx, ` UPDATE username_claim_requests SET status = $1, reviewer_id = $2, review_notes = $3, reviewed_at = NOW(), updated_at = NOW() WHERE id = $4 `, req.Decision, adminUUID, req.Notes, id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update claim request"}) return } // If approved, remove from reserved list so user can register it if req.Decision == "approved" { var username string h.pool.QueryRow(ctx, `SELECT requested_username FROM username_claim_requests WHERE id = $1`, id).Scan(&username) if username != "" { h.pool.Exec(ctx, `DELETE FROM reserved_usernames WHERE username = $1`, strings.ToLower(username)) } } // Audit h.pool.Exec(ctx, ` INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'admin_review_claim', 'claim_request', $2, $3) `, adminUUID, id, fmt.Sprintf(`{"decision":"%s"}`, req.Decision)) c.JSON(http.StatusOK, gin.H{"message": "Claim request " + req.Decision}) } // ────────────────────────────────────────────── // Public: Submit a claim request (no auth required) // ────────────────────────────────────────────── func (h *AdminHandler) SubmitClaimRequest(c *gin.Context) { ctx := c.Request.Context() var req struct { RequestedUsername string `json:"requested_username" binding:"required"` Email string `json:"email" binding:"required,email"` Name string `json:"name"` Organization string `json:"organization"` Justification string `json:"justification" binding:"required"` ProofURL string `json:"proof_url"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Check for duplicate pending request var existing int h.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM username_claim_requests WHERE requested_username = $1 AND requester_email = $2 AND status = 'pending' `, strings.ToLower(req.RequestedUsername), strings.ToLower(req.Email)).Scan(&existing) if existing > 0 { c.JSON(http.StatusConflict, gin.H{"error": "You already have a pending claim for this username"}) return } _, err := h.pool.Exec(ctx, ` INSERT INTO username_claim_requests (requested_username, requester_email, requester_name, organization, justification, proof_url) VALUES ($1, $2, $3, $4, $5, $6) `, strings.ToLower(req.RequestedUsername), strings.ToLower(req.Email), req.Name, req.Organization, req.Justification, req.ProofURL) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to submit claim request"}) return } c.JSON(http.StatusOK, gin.H{"message": "Your claim request has been submitted and will be reviewed by our team."}) } // ────────────────────────────────────────────── // AI Moderation Config // ────────────────────────────────────────────── func (h *AdminHandler) ListOpenRouterModels(c *gin.Context) { if h.openRouterService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"}) return } models, err := h.openRouterService.ListModels(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to fetch models: %v", err)}) return } // Optional filter by capability capability := c.Query("capability") // "text", "image", "vision", "free" if capability != "" { var filtered []services.OpenRouterModel for _, m := range models { switch capability { case "free": if m.Pricing.Prompt == "0" || m.Pricing.Prompt == "0.0" { filtered = append(filtered, m) } case "vision", "image": if arch, ok := m.Architecture["modality"].(string); ok { if strings.Contains(arch, "image") { filtered = append(filtered, m) } } default: filtered = append(filtered, m) } } models = filtered } // Search filter search := strings.ToLower(c.Query("search")) if search != "" { var filtered []services.OpenRouterModel for _, m := range models { if strings.Contains(strings.ToLower(m.ID), search) || strings.Contains(strings.ToLower(m.Name), search) { filtered = append(filtered, m) } } models = filtered } c.JSON(http.StatusOK, gin.H{"models": models, "total": len(models)}) } // ListLocalModels returns models available on the local Ollama instance. func (h *AdminHandler) ListLocalModels(c *gin.Context) { ctx := c.Request.Context() // Query Ollama's /api/tags endpoint for locally available models req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost:11434/api/tags", nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create request"}) return } client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) if err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Ollama not reachable", "models": []any{}}) return } defer resp.Body.Close() var result struct { Models []struct { Name string `json:"name"` Model string `json:"model"` ModifiedAt string `json:"modified_at"` Size int64 `json:"size"` } `json:"models"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse Ollama response"}) return } type localModel struct { ID string `json:"id"` Name string `json:"name"` Size int64 `json:"size"` } models := make([]localModel, 0, len(result.Models)) for _, m := range result.Models { models = append(models, localModel{ ID: m.Name, Name: m.Name, Size: m.Size, }) } c.JSON(http.StatusOK, gin.H{"models": models, "total": len(models)}) } func (h *AdminHandler) GetAIModerationConfigs(c *gin.Context) { if h.openRouterService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"}) return } configs, err := h.openRouterService.GetModerationConfigs(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to fetch configs: %v", err)}) return } c.JSON(http.StatusOK, gin.H{"configs": configs}) } func (h *AdminHandler) SetAIModerationConfig(c *gin.Context) { if h.openRouterService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"}) return } var req struct { ModerationType string `json:"moderation_type" binding:"required"` ModelID string `json:"model_id"` ModelName string `json:"model_name"` SystemPrompt string `json:"system_prompt"` Enabled bool `json:"enabled"` Engines []string `json:"engines"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } allowedTypes := map[string]bool{"text": true, "image": true, "video": true, "group_text": true, "group_image": true, "beacon_text": true, "beacon_image": true} if !allowedTypes[req.ModerationType] { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid moderation_type"}) return } adminID := c.GetString("user_id") err := h.openRouterService.SetModerationConfig(c.Request.Context(), req.ModerationType, req.ModelID, req.ModelName, req.SystemPrompt, req.Enabled, req.Engines, adminID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save config: %v", err)}) return } // Audit log h.pool.Exec(c.Request.Context(), ` INSERT INTO audit_log (admin_id, action, target_type, details) VALUES ($1, 'update_ai_moderation', 'ai_config', $2) `, adminID, fmt.Sprintf("Set %s moderation model to %s (enabled=%v)", req.ModerationType, req.ModelID, req.Enabled)) c.JSON(http.StatusOK, gin.H{"message": "Configuration updated"}) } func (h *AdminHandler) TestAIModeration(c *gin.Context) { var req struct { ModerationType string `json:"moderation_type" binding:"required"` Content string `json:"content"` ImageURL string `json:"image_url"` Engine string `json:"engine"` // "local_ai", "openrouter", "openai" — empty = openrouter } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } engine := req.Engine if engine == "" { engine = "openrouter" } ctx := c.Request.Context() // Determine the input text for display inputDisplay := req.Content if inputDisplay == "" && req.ImageURL != "" { inputDisplay = req.ImageURL } response := gin.H{ "engine": engine, "moderation_type": req.ModerationType, "input": inputDisplay, } switch engine { case "local_ai": if h.localAIService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Local AI not configured"}) return } // Local AI only does text moderation content := req.Content if content == "" { content = req.ImageURL // fallback } localResult, err := h.localAIService.ModerateText(ctx, content) if err != nil { response["error"] = err.Error() c.JSON(http.StatusOK, response) return } action := "clean" flagged := false if !localResult.Allowed { action = "flag" flagged = true } explanation := "Local AI (llama-guard) determined this content is safe." if flagged { explanation = fmt.Sprintf("Local AI (llama-guard) flagged this content. Categories: %v, Severity: %s", localResult.Categories, localResult.Severity) } response["result"] = gin.H{ "action": action, "flagged": flagged, "reason": localResult.Reason, "categories": localResult.Categories, "explanation": explanation, "raw_content": fmt.Sprintf("allowed=%v cached=%v categories=%v severity=%s reason=%s", localResult.Allowed, localResult.Cached, localResult.Categories, localResult.Severity, localResult.Reason), } case "openai": if h.moderationService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenAI moderation service not configured"}) return } content := req.Content if content == "" { content = req.ImageURL } mediaURLs := []string{} if req.ImageURL != "" { mediaURLs = append(mediaURLs, req.ImageURL) } scores, reason, err := h.moderationService.AnalyzeContent(ctx, content, mediaURLs) if err != nil { response["error"] = err.Error() c.JSON(http.StatusOK, response) return } action := "clean" flagged := false if reason != "" { action = "flag" flagged = true } response["result"] = gin.H{ "action": action, "flagged": flagged, "reason": reason, "hate": scores.Hate, "greed": scores.Greed, "delusion": scores.Delusion, "hate_detail": fmt.Sprintf("Hate=%.3f", scores.Hate), "greed_detail": fmt.Sprintf("Greed=%.3f", scores.Greed), "delusion_detail": fmt.Sprintf("Delusion=%.3f", scores.Delusion), "explanation": fmt.Sprintf("OpenAI Three Poisons analysis. Hate=%.3f, Greed=%.3f, Delusion=%.3f. %s", scores.Hate, scores.Greed, scores.Delusion, reason), "raw_content": fmt.Sprintf("Hate=%.4f Greed=%.4f Delusion=%.4f", scores.Hate, scores.Greed, scores.Delusion), } case "google": if h.moderationService == nil || !h.moderationService.HasGoogleVision() { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Google Vision not configured"}) return } imageURL := req.ImageURL if imageURL == "" { // If they passed text content as a URL imageURL = req.Content } if imageURL == "" { response["error"] = "Image URL required for Google Vision test" c.JSON(http.StatusOK, response) return } gResult, err := h.moderationService.AnalyzeImageWithGoogleVision(ctx, imageURL) if err != nil { response["error"] = err.Error() c.JSON(http.StatusOK, response) return } response["result"] = gResult case "openrouter": if h.openRouterService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"}) return } var result *services.ModerationResult var err error isImage := strings.Contains(req.ModerationType, "image") || req.ModerationType == "video" if isImage && req.ImageURL != "" { if req.ModerationType == "video" { urls := strings.Split(req.ImageURL, ",") result, err = h.openRouterService.ModerateVideo(ctx, urls) } else { result, err = h.openRouterService.ModerateImage(ctx, req.ImageURL) } } else { // Use type-specific config if available, fall back to generic text result, err = h.openRouterService.ModerateWithType(ctx, req.ModerationType, req.Content, nil) if err != nil { // Fall back to generic text moderation result, err = h.openRouterService.ModerateText(ctx, req.Content) } } if err != nil { response["error"] = err.Error() c.JSON(http.StatusOK, response) return } response["result"] = result case "azure": if h.azureOpenAIService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Azure OpenAI not configured"}) return } var result *services.ModerationResult var err error isImage := strings.Contains(req.ModerationType, "image") || req.ModerationType == "video" if isImage && req.ImageURL != "" { if req.ModerationType == "video" { urls := strings.Split(req.ImageURL, ",") result, err = h.azureOpenAIService.ModerateVideo(ctx, urls) } else { result, err = h.azureOpenAIService.ModerateImage(ctx, req.ImageURL) } } else { // Use type-specific config if available, fall back to generic text result, err = h.azureOpenAIService.ModerateWithType(ctx, req.ModerationType, req.Content, nil) if err != nil { // Fall back to generic text moderation result, err = h.azureOpenAIService.ModerateText(ctx, req.Content) } } if err != nil { response["error"] = err.Error() c.JSON(http.StatusOK, response) return } response["result"] = result default: c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid engine: " + engine}) return } c.JSON(http.StatusOK, response) } // ────────────────────────────────────────────── // AI Moderation Audit Log // ────────────────────────────────────────────── func (h *AdminHandler) GetAIModerationLog(c *gin.Context) { ctx := c.Request.Context() limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) decision := c.Query("decision") contentType := c.Query("content_type") search := c.Query("search") feedbackFilter := c.Query("feedback") items, total, err := h.moderationService.GetAIModerationLog(ctx, limit, offset, decision, contentType, search, feedbackFilter) if err != nil { log.Error().Err(err).Msg("Failed to fetch AI moderation log") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch AI moderation log"}) return } c.JSON(http.StatusOK, gin.H{ "items": items, "total": total, "limit": limit, "offset": offset, }) } func (h *AdminHandler) SubmitAIModerationFeedback(c *gin.Context) { ctx := c.Request.Context() adminID, _ := c.Get("user_id") logID := c.Param("id") var req struct { Correct bool `json:"correct"` Reason string `json:"reason" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } logUUID, err := uuid.Parse(logID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid log ID"}) return } adminUUID, _ := uuid.Parse(adminID.(string)) if err := h.moderationService.SubmitAIFeedback(ctx, logUUID, req.Correct, req.Reason, adminUUID); err != nil { log.Error().Err(err).Msg("Failed to submit AI feedback") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to submit feedback"}) return } // Audit log h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'ai_moderation_feedback', 'ai_moderation_log', $2, $3)`, adminUUID, logID, fmt.Sprintf(`{"correct":%v,"reason":"%s"}`, req.Correct, req.Reason)) c.JSON(http.StatusOK, gin.H{"message": "Feedback submitted"}) } func (h *AdminHandler) ExportAITrainingData(c *gin.Context) { ctx := c.Request.Context() data, err := h.moderationService.GetAITrainingData(ctx) if err != nil { log.Error().Err(err).Msg("Failed to export AI training data") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to export training data"}) return } c.JSON(http.StatusOK, gin.H{ "training_data": data, "count": len(data), }) } // ────────────────────────────────────────────── // Admin Create User // ────────────────────────────────────────────── func (h *AdminHandler) AdminCreateUser(c *gin.Context) { ctx := c.Request.Context() adminID, _ := c.Get("user_id") var req struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=8"` Handle string `json:"handle" binding:"required"` DisplayName string `json:"display_name" binding:"required"` Bio string `json:"bio"` Role string `json:"role"` Verified bool `json:"verified"` Official bool `json:"official"` SkipEmail bool `json:"skip_email"` } 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)) req.Handle = strings.ToLower(strings.TrimSpace(req.Handle)) // Check for existing email var existsCount int h.pool.QueryRow(ctx, "SELECT COUNT(*) FROM public.users WHERE email = $1", req.Email).Scan(&existsCount) if existsCount > 0 { c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"}) return } // Check for existing handle h.pool.QueryRow(ctx, "SELECT COUNT(*) FROM public.profiles WHERE handle = $1", req.Handle).Scan(&existsCount) if existsCount > 0 { c.JSON(http.StatusConflict, gin.H{"error": "Handle already taken"}) return } hashedBytes, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) return } userID := uuid.New() now := time.Now() tx, err := h.pool.Begin(ctx) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start transaction"}) return } defer tx.Rollback(ctx) // Create user (already active + verified, admin-created) _, err = tx.Exec(ctx, ` INSERT INTO public.users (id, email, encrypted_password, status, mfa_enabled, created_at, updated_at) VALUES ($1, $2, $3, 'active', false, $4, $4) `, userID, req.Email, string(hashedBytes), now) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()}) return } role := "user" if req.Role != "" { role = req.Role } // Create profile _, err = tx.Exec(ctx, ` INSERT INTO public.profiles (id, handle, display_name, bio, is_verified, is_official, role, has_completed_onboarding) VALUES ($1, $2, $3, $4, $5, $6, $7, true) `, userID, req.Handle, req.DisplayName, req.Bio, req.Verified, req.Official, role) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create profile: " + err.Error()}) return } // Initialize trust state _, err = tx.Exec(ctx, ` INSERT INTO public.trust_state (user_id, harmony_score, tier) VALUES ($1, 50, 'new') `, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to init trust state: " + err.Error()}) return } if err := tx.Commit(ctx); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit: " + err.Error()}) return } // Audit log h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'admin_create_user', 'user', $2, $3)`, adminID, userID.String(), fmt.Sprintf(`{"email":"%s","handle":"%s"}`, req.Email, req.Handle)) log.Info().Str("admin", adminID.(string)).Str("new_user", userID.String()).Str("handle", req.Handle).Msg("Admin created user") c.JSON(http.StatusCreated, gin.H{ "message": "User created successfully", "user_id": userID.String(), "email": req.Email, "handle": req.Handle, }) } // ────────────────────────────────────────────── // Admin Import Content (Posts / Quips / Beacons) // ────────────────────────────────────────────── func (h *AdminHandler) AdminImportContent(c *gin.Context) { ctx := c.Request.Context() adminID, _ := c.Get("user_id") var req struct { AuthorID string `json:"author_id" binding:"required"` ContentType string `json:"content_type" binding:"required"` // post, quip, beacon Items []struct { Body string `json:"body"` MediaURL string `json:"media_url"` ThumbnailURL string `json:"thumbnail_url"` DurationMS int `json:"duration_ms"` Tags []string `json:"tags"` CategoryID string `json:"category_id"` IsNSFW bool `json:"is_nsfw"` NSFWReason string `json:"nsfw_reason"` Visibility string `json:"visibility"` BeaconType string `json:"beacon_type"` Lat float64 `json:"lat"` Long float64 `json:"long"` } `json:"items" binding:"required,min=1"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } authorUUID, err := uuid.Parse(req.AuthorID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid author_id"}) return } // Verify author exists var authorExists int h.pool.QueryRow(ctx, "SELECT COUNT(*) FROM public.profiles WHERE id = $1", authorUUID).Scan(&authorExists) if authorExists == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "Author not found"}) return } validTypes := map[string]bool{"post": true, "quip": true, "beacon": true} if !validTypes[req.ContentType] { c.JSON(http.StatusBadRequest, gin.H{"error": "content_type must be post, quip, or beacon"}) return } var created []string var errors []string for i, item := range req.Items { postID := uuid.New() visibility := item.Visibility if visibility == "" { visibility = "public" } var imageURL, videoURL, thumbnailURL *string var categoryID *uuid.UUID var beaconType *string var lat, long *float64 isBeacon := false durationMS := item.DurationMS // Determine media type from URL mediaURL := strings.TrimSpace(item.MediaURL) if mediaURL != "" { lower := strings.ToLower(mediaURL) if strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".mov") || strings.HasSuffix(lower, ".webm") || req.ContentType == "quip" { videoURL = &mediaURL } else { imageURL = &mediaURL } } if item.ThumbnailURL != "" { thumbnailURL = &item.ThumbnailURL } if item.CategoryID != "" { if catUUID, err := uuid.Parse(item.CategoryID); err == nil { categoryID = &catUUID } } if req.ContentType == "beacon" { isBeacon = true if item.BeaconType != "" { beaconType = &item.BeaconType } else { bt := "general" beaconType = &bt } if item.Lat != 0 || item.Long != 0 { lat = &item.Lat long = &item.Long } } tx, err := h.pool.Begin(ctx) if err != nil { errors = append(errors, fmt.Sprintf("item %d: tx start failed", i)) continue } _, err = tx.Exec(ctx, ` INSERT INTO public.posts ( id, author_id, category_id, body, status, tone_label, cis_score, image_url, video_url, thumbnail_url, duration_ms, body_format, tags, is_beacon, beacon_type, location, confidence_score, is_active_beacon, allow_chain, visibility, is_nsfw, nsfw_reason ) VALUES ( $1, $2, $3, $4, 'active', 'neutral', 0.8, $5, $6, $7, $8, 'plain', $9, $10, $11, CASE WHEN ($12::double precision) IS NOT NULL AND ($13::double precision) IS NOT NULL THEN ST_SetSRID(ST_MakePoint(($13::double precision), ($12::double precision)), 4326)::geography ELSE NULL END, 0.5, $10, true, $14, $15, $16 ) RETURNING id `, postID, authorUUID, categoryID, item.Body, imageURL, videoURL, thumbnailURL, durationMS, item.Tags, isBeacon, beaconType, lat, long, visibility, item.IsNSFW, item.NSFWReason, ) if err != nil { tx.Rollback(ctx) errors = append(errors, fmt.Sprintf("item %d: %s", i, err.Error())) continue } // Initialize metrics _, err = tx.Exec(ctx, "INSERT INTO public.post_metrics (post_id) VALUES ($1)", postID) if err != nil { tx.Rollback(ctx) errors = append(errors, fmt.Sprintf("item %d: metrics init failed", i)) continue } if err := tx.Commit(ctx); err != nil { errors = append(errors, fmt.Sprintf("item %d: commit failed", i)) continue } created = append(created, postID.String()) } // Audit log h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'admin_import_content', 'post', $2, $3)`, adminID, req.AuthorID, fmt.Sprintf(`{"type":"%s","count":%d,"errors":%d}`, req.ContentType, len(created), len(errors))) log.Info().Str("admin", adminID.(string)).Str("type", req.ContentType).Int("created", len(created)).Int("errors", len(errors)).Msg("Admin import content") c.JSON(http.StatusOK, gin.H{ "message": fmt.Sprintf("Imported %d/%d items", len(created), len(req.Items)), "created": created, "errors": errors, "total": len(req.Items), "success": len(created), "failures": len(errors), }) } // ────────────────────────────────────────────────────── // Official Accounts Management // ────────────────────────────────────────────────────── func (h *AdminHandler) ListOfficialAccounts(c *gin.Context) { configs, err := h.officialAccountsService.ListConfigs(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if configs == nil { configs = []services.OfficialAccountConfig{} } c.JSON(http.StatusOK, gin.H{"configs": configs}) } func (h *AdminHandler) ListOfficialProfiles(c *gin.Context) { profiles, err := h.officialAccountsService.ListOfficialProfiles(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if profiles == nil { profiles = []services.OfficialProfile{} } c.JSON(http.StatusOK, gin.H{"profiles": profiles}) } func (h *AdminHandler) GetOfficialAccount(c *gin.Context) { id := c.Param("id") cfg, err := h.officialAccountsService.GetConfig(c.Request.Context(), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"}) return } c.JSON(http.StatusOK, cfg) } func (h *AdminHandler) UpsertOfficialAccount(c *gin.Context) { var req struct { ProfileID string `json:"profile_id"` Handle string `json:"handle"` AccountType string `json:"account_type"` Enabled bool `json:"enabled"` ModelID string `json:"model_id"` SystemPrompt string `json:"system_prompt"` Temperature float64 `json:"temperature"` MaxTokens int `json:"max_tokens"` PostIntervalMinutes int `json:"post_interval_minutes"` MaxPostsPerDay int `json:"max_posts_per_day"` NewsSources []services.NewsSource `json:"news_sources"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx := c.Request.Context() // Resolve handle to profile_id if provided profileID := req.ProfileID if profileID == "" && req.Handle != "" { pid, err := h.officialAccountsService.LookupProfileID(ctx, req.Handle) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } profileID = pid } if profileID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "profile_id or handle required"}) return } // Defaults if req.ModelID == "" { req.ModelID = "google/gemini-2.0-flash-001" } if req.MaxTokens == 0 { req.MaxTokens = 500 } if req.PostIntervalMinutes == 0 { req.PostIntervalMinutes = 60 } if req.MaxPostsPerDay == 0 { req.MaxPostsPerDay = 24 } if req.AccountType == "" { req.AccountType = "general" } newsJSON, _ := json.Marshal(req.NewsSources) cfg := services.OfficialAccountConfig{ ProfileID: profileID, AccountType: req.AccountType, Enabled: req.Enabled, ModelID: req.ModelID, SystemPrompt: req.SystemPrompt, Temperature: req.Temperature, MaxTokens: req.MaxTokens, PostIntervalMinutes: req.PostIntervalMinutes, MaxPostsPerDay: req.MaxPostsPerDay, NewsSources: newsJSON, } result, err := h.officialAccountsService.UpsertConfig(ctx, cfg) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } adminID, _ := c.Get("user_id") h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'admin_upsert_official_account', 'official_account', $2, $3)`, adminID, result.ID, fmt.Sprintf(`{"profile_id":"%s","type":"%s"}`, profileID, req.AccountType)) c.JSON(http.StatusOK, result) } func (h *AdminHandler) DeleteOfficialAccount(c *gin.Context) { id := c.Param("id") ctx := c.Request.Context() if err := h.officialAccountsService.DeleteConfig(ctx, id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } adminID, _ := c.Get("user_id") h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id) VALUES ($1, 'admin_delete_official_account', 'official_account', $2)`, adminID, id) c.JSON(http.StatusOK, gin.H{"message": "Deleted"}) } func (h *AdminHandler) ToggleOfficialAccount(c *gin.Context) { id := c.Param("id") var req struct { Enabled bool `json:"enabled"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.officialAccountsService.ToggleEnabled(c.Request.Context(), id, req.Enabled); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"enabled": req.Enabled}) } // Manually trigger a post for an official account. // Accepts query param ?count=N to post multiple articles at once. // count=0 or count=all posts ALL pending articles. func (h *AdminHandler) TriggerOfficialPost(c *gin.Context) { id := c.Param("id") ctx := c.Request.Context() cfg, err := h.officialAccountsService.GetConfig(ctx, id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"}) return } // Parse count param: default 1, "all" or "0" = post everything countStr := c.DefaultQuery("count", "1") postAll := countStr == "all" || countStr == "0" count := 1 if !postAll { if n, err := strconv.Atoi(countStr); err == nil && n > 0 { count = n } } switch cfg.AccountType { case "news", "rss": // Phase 1: Discover new articles discovered, discErr := h.officialAccountsService.DiscoverArticles(ctx, id) if discErr != nil { // Log but continue — there may be previously discovered articles log.Warn().Err(discErr).Str("config", id).Msg("Discover error during trigger") } // If posting all, get the pending count if postAll { stats, _ := h.officialAccountsService.GetArticleStats(ctx, id) if stats != nil { count = stats.Discovered } } // Phase 2: Post N articles from the queue var posted []gin.H var errors []string for i := 0; i < count; i++ { article, postID, err := h.officialAccountsService.PostNextArticle(ctx, id) if err != nil { errors = append(errors, err.Error()) continue } if article == nil { break // no more articles } posted = append(posted, gin.H{ "post_id": postID, "title": article.Title, "link": article.Link, "source": article.SourceName, }) } if len(posted) == 0 && len(errors) == 0 { msg := "No new articles found" if discErr != nil { msg += " (discover error: " + discErr.Error() + ")" } c.JSON(http.StatusOK, gin.H{"message": msg, "post_id": nil, "discovered": discovered}) return } stats, _ := h.officialAccountsService.GetArticleStats(ctx, id) c.JSON(http.StatusOK, gin.H{ "message": fmt.Sprintf("Posted %d article(s)", len(posted)), "posted": posted, "errors": errors, "discovered": discovered, "stats": stats, }) default: postID, body, err := h.officialAccountsService.GenerateAndPost(ctx, id, nil, "") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "message": "Post created", "post_id": postID, "body": body, }) } } // Preview what AI would generate without actually posting func (h *AdminHandler) PreviewOfficialPost(c *gin.Context) { id := c.Param("id") ctx := c.Request.Context() cfg, err := h.officialAccountsService.GetConfig(ctx, id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"}) return } switch cfg.AccountType { case "news", "rss": // Discover then show the next article that would be posted _, _ = h.officialAccountsService.DiscoverArticles(ctx, id) pending, err := h.officialAccountsService.GetArticleQueue(ctx, id, "discovered", 10) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if len(pending) == 0 { c.JSON(http.StatusOK, gin.H{"message": "No pending articles", "preview": nil}) return } next := pending[0] stats, _ := h.officialAccountsService.GetArticleStats(ctx, id) c.JSON(http.StatusOK, gin.H{ "preview": next.Link, "source": next.SourceName, "article_title": next.Title, "article_link": next.Link, "pending_count": len(pending), "stats": stats, }) default: body, err := h.officialAccountsService.GeneratePost(ctx, id, nil, "") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"preview": body}) } } // Fetch available news articles (without posting) func (h *AdminHandler) FetchNewsArticles(c *gin.Context) { id := c.Param("id") ctx := c.Request.Context() // Discover new articles first _, _ = h.officialAccountsService.DiscoverArticles(ctx, id) // Return the full CachedArticle objects so frontend has IDs for per-article actions articles, err := h.officialAccountsService.GetArticleQueue(ctx, id, "discovered", 100) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if articles == nil { articles = []services.CachedArticle{} } stats, _ := h.officialAccountsService.GetArticleStats(ctx, id) c.JSON(http.StatusOK, gin.H{"articles": articles, "count": len(articles), "stats": stats}) } // Get articles by status for an account (defaults to "posted") func (h *AdminHandler) GetPostedArticles(c *gin.Context) { id := c.Param("id") status := c.DefaultQuery("status", "posted") limit := 50 if l := c.Query("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed } } articles, err := h.officialAccountsService.GetArticleQueue(c.Request.Context(), id, status, limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if articles == nil { articles = []services.CachedArticle{} } stats, _ := h.officialAccountsService.GetArticleStats(c.Request.Context(), id) c.JSON(http.StatusOK, gin.H{"articles": articles, "stats": stats}) } // ── Article Pipeline Management ────────────────────── // SkipArticle marks a pending article as skipped. func (h *AdminHandler) SkipArticle(c *gin.Context) { articleID := c.Param("article_id") if err := h.officialAccountsService.SkipArticle(c.Request.Context(), articleID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "Article skipped"}) } // DeleteArticle permanently removes an article from the pipeline. func (h *AdminHandler) DeleteArticle(c *gin.Context) { articleID := c.Param("article_id") if err := h.officialAccountsService.DeleteArticle(c.Request.Context(), articleID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "Article deleted"}) } // PostSpecificArticle posts a single pending article by its ID. func (h *AdminHandler) PostSpecificArticle(c *gin.Context) { articleID := c.Param("article_id") article, postID, err := h.officialAccountsService.PostSpecificArticle(c.Request.Context(), articleID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "message": "Article posted", "post_id": postID, "title": article.Title, "link": article.Link, }) } // CleanupPendingArticles skips or deletes all pending articles older than a date. func (h *AdminHandler) CleanupPendingArticles(c *gin.Context) { configID := c.Param("id") var req struct { Before string `json:"before" binding:"required"` // ISO date: 2026-02-10 Action string `json:"action" binding:"required"` // "skip" or "delete" } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } before, err := time.Parse("2006-01-02", req.Before) if err != nil { before, err = time.Parse(time.RFC3339, req.Before) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid date format. Use YYYY-MM-DD or RFC3339."}) return } } affected, err := h.officialAccountsService.CleanupPendingByDate(c.Request.Context(), configID, before, req.Action) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } stats, _ := h.officialAccountsService.GetArticleStats(c.Request.Context(), configID) c.JSON(http.StatusOK, gin.H{ "message": fmt.Sprintf("%d article(s) %sed", affected, req.Action), "affected": affected, "stats": stats, }) } // ── Safe Domains Management ───────────────────────── func (h *AdminHandler) ListSafeDomains(c *gin.Context) { category := c.Query("category") approvedOnly := c.Query("approved_only") == "true" domains, err := h.linkPreviewService.ListSafeDomains(c.Request.Context(), category, approvedOnly) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"domains": domains}) } func (h *AdminHandler) UpsertSafeDomain(c *gin.Context) { var req struct { Domain string `json:"domain" binding:"required"` Category string `json:"category"` IsApproved *bool `json:"is_approved"` Notes string `json:"notes"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } cat := req.Category if cat == "" { cat = "general" } approved := true if req.IsApproved != nil { approved = *req.IsApproved } domain, err := h.linkPreviewService.UpsertSafeDomain(c.Request.Context(), req.Domain, cat, approved, req.Notes) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"domain": domain}) } func (h *AdminHandler) DeleteSafeDomain(c *gin.Context) { id := c.Param("id") if err := h.linkPreviewService.DeleteSafeDomain(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"deleted": true}) } // GetAIEngines returns the status of all moderation engines (local AI, OpenRouter, OpenAI). func (h *AdminHandler) GetAIEngines(c *gin.Context) { type EngineStatus struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Status string `json:"status"` Configured bool `json:"configured"` Details any `json:"details,omitempty"` } engines := []EngineStatus{} // 1. Local AI (Ollama via AI Gateway) localEngine := EngineStatus{ ID: "local_ai", Name: "Local AI (Ollama)", Description: "On-server moderation via llama-guard3:1b + content generation via qwen2.5:7b. Free, private, ~2s latency.", Configured: h.localAIService != nil, } if h.localAIService != nil { health, err := h.localAIService.Healthz(c.Request.Context()) if err != nil { localEngine.Status = "down" localEngine.Details = map[string]string{"error": err.Error()} } else { localEngine.Status = health.Status localEngine.Details = health } } else { localEngine.Status = "not_configured" } engines = append(engines, localEngine) // 2. OpenRouter openRouterEngine := EngineStatus{ ID: "openrouter", Name: "OpenRouter", Description: "Cloud AI moderation via configurable models. Supports text, image, and video moderation with customizable system prompts.", Configured: h.openRouterService != nil, } if h.openRouterService != nil { configs, err := h.openRouterService.GetModerationConfigs(c.Request.Context()) if err != nil { openRouterEngine.Status = "error" } else { enabledCount := 0 for _, cfg := range configs { if cfg.Enabled { enabledCount++ } } openRouterEngine.Status = "ready" openRouterEngine.Details = map[string]any{ "total_configs": len(configs), "enabled_configs": enabledCount, } } } else { openRouterEngine.Status = "not_configured" } engines = append(engines, openRouterEngine) // 3. OpenAI Moderation (Three Poisons) openAIEngine := EngineStatus{ ID: "openai", Name: "OpenAI Moderation (Three Poisons)", Description: "OpenAI content moderation API with Three Poisons scoring (Hate, Greed, Delusion). Used for posts and comments.", Configured: h.moderationService != nil, } if h.moderationService != nil { openAIEngine.Status = "ready" } else { openAIEngine.Status = "not_configured" } engines = append(engines, openAIEngine) // 4. Google Vision (SafeSearch) googleEngine := EngineStatus{ ID: "google", Name: "Google Vision (SafeSearch)", Description: "Google Cloud Vision SafeSearch detection for images. Returns Adult, Violence, Racy, Spoof, Medical likelihoods.", Configured: h.moderationService != nil && h.moderationService.HasGoogleVision(), } if h.moderationService != nil && h.moderationService.HasGoogleVision() { googleEngine.Status = "ready" } else { googleEngine.Status = "not_configured" } engines = append(engines, googleEngine) // 5. Azure OpenAI azureEngine := EngineStatus{ ID: "azure", Name: "Azure OpenAI", Description: "Microsoft Azure OpenAI service with vision models. Uses your Azure credits. Supports text and image moderation with customizable prompts.", Configured: h.azureOpenAIService != nil, } if h.azureOpenAIService != nil { azureEngine.Status = "ready" azureEngine.Details = map[string]any{ "uses_azure_credits": true, } } else { azureEngine.Status = "not_configured" } engines = append(engines, azureEngine) c.JSON(http.StatusOK, gin.H{"engines": engines}) } func (h *AdminHandler) UploadTestImage(c *gin.Context) { file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "No file uploaded"}) return } defer file.Close() // Validate file type if !strings.HasPrefix(header.Header.Get("Content-Type"), "image/") { c.JSON(http.StatusBadRequest, gin.H{"error": "Only image files are allowed"}) return } // Validate file size (5MB limit) if header.Size > 5*1024*1024 { c.JSON(http.StatusBadRequest, gin.H{"error": "File too large (max 5MB)"}) return } // Generate unique filename ext := filepath.Ext(header.Filename) filename := fmt.Sprintf("test-%s%s", uuid.New().String()[:8], ext) // Upload to R2 key := fmt.Sprintf("test-images/%s", filename) // Read file content fileData, err := io.ReadAll(file) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file"}) return } // Upload to R2 contentType := header.Header.Get("Content-Type") _, err = h.s3Client.PutObject(c.Request.Context(), &s3.PutObjectInput{ Bucket: aws.String(h.mediaBucket), Key: aws.String(key), Body: bytes.NewReader(fileData), ContentType: aws.String(contentType), }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Upload failed"}) return } // Return the URL url := fmt.Sprintf("https://%s/%s", h.imgDomain, key) c.JSON(http.StatusOK, gin.H{"url": url, "filename": filename}) } func (h *AdminHandler) CheckURLSafety(c *gin.Context) { urlStr := c.Query("url") if urlStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "url parameter required"}) return } result := h.linkPreviewService.CheckURLSafety(c.Request.Context(), urlStr) c.JSON(http.StatusOK, result) } // ────────────────────────────────────────────── // Email Template Management // ────────────────────────────────────────────── func (h *AdminHandler) ListEmailTemplates(c *gin.Context) { rows, err := h.pool.Query(c.Request.Context(), `SELECT id, slug, name, description, subject, title, header, content, button_text, button_url, button_color, footer, text_body, enabled, updated_at, created_at FROM email_templates ORDER BY name ASC`) if err != nil { log.Error().Err(err).Msg("Failed to list email templates") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list templates"}) return } defer rows.Close() type EmailTemplate struct { ID string `json:"id"` Slug string `json:"slug"` Name string `json:"name"` Description string `json:"description"` Subject string `json:"subject"` Title string `json:"title"` Header string `json:"header"` Content string `json:"content"` ButtonText string `json:"button_text"` ButtonURL string `json:"button_url"` ButtonColor string `json:"button_color"` Footer string `json:"footer"` TextBody string `json:"text_body"` Enabled bool `json:"enabled"` UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` } var templates []EmailTemplate for rows.Next() { var t EmailTemplate if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.Description, &t.Subject, &t.Title, &t.Header, &t.Content, &t.ButtonText, &t.ButtonURL, &t.ButtonColor, &t.Footer, &t.TextBody, &t.Enabled, &t.UpdatedAt, &t.CreatedAt); err != nil { log.Error().Err(err).Msg("Failed to scan email template") continue } templates = append(templates, t) } c.JSON(http.StatusOK, gin.H{"templates": templates}) } func (h *AdminHandler) GetEmailTemplate(c *gin.Context) { id := c.Param("id") type EmailTemplate struct { ID string `json:"id"` Slug string `json:"slug"` Name string `json:"name"` Description string `json:"description"` Subject string `json:"subject"` Title string `json:"title"` Header string `json:"header"` Content string `json:"content"` ButtonText string `json:"button_text"` ButtonURL string `json:"button_url"` ButtonColor string `json:"button_color"` Footer string `json:"footer"` TextBody string `json:"text_body"` Enabled bool `json:"enabled"` UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` } var t EmailTemplate err := h.pool.QueryRow(c.Request.Context(), `SELECT id, slug, name, description, subject, title, header, content, button_text, button_url, button_color, footer, text_body, enabled, updated_at, created_at FROM email_templates WHERE id = $1`, id). Scan(&t.ID, &t.Slug, &t.Name, &t.Description, &t.Subject, &t.Title, &t.Header, &t.Content, &t.ButtonText, &t.ButtonURL, &t.ButtonColor, &t.Footer, &t.TextBody, &t.Enabled, &t.UpdatedAt, &t.CreatedAt) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"}) return } c.JSON(http.StatusOK, t) } func (h *AdminHandler) UpdateEmailTemplate(c *gin.Context) { id := c.Param("id") var req struct { Subject *string `json:"subject"` Title *string `json:"title"` Header *string `json:"header"` Content *string `json:"content"` ButtonText *string `json:"button_text"` ButtonURL *string `json:"button_url"` ButtonColor *string `json:"button_color"` Footer *string `json:"footer"` TextBody *string `json:"text_body"` Enabled *bool `json:"enabled"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } // Build dynamic UPDATE sets := []string{} args := []interface{}{} argIdx := 1 if req.Subject != nil { sets = append(sets, fmt.Sprintf("subject = $%d", argIdx)) args = append(args, *req.Subject) argIdx++ } if req.Title != nil { sets = append(sets, fmt.Sprintf("title = $%d", argIdx)) args = append(args, *req.Title) argIdx++ } if req.Header != nil { sets = append(sets, fmt.Sprintf("header = $%d", argIdx)) args = append(args, *req.Header) argIdx++ } if req.Content != nil { sets = append(sets, fmt.Sprintf("content = $%d", argIdx)) args = append(args, *req.Content) argIdx++ } if req.ButtonText != nil { sets = append(sets, fmt.Sprintf("button_text = $%d", argIdx)) args = append(args, *req.ButtonText) argIdx++ } if req.ButtonURL != nil { sets = append(sets, fmt.Sprintf("button_url = $%d", argIdx)) args = append(args, *req.ButtonURL) argIdx++ } if req.ButtonColor != nil { sets = append(sets, fmt.Sprintf("button_color = $%d", argIdx)) args = append(args, *req.ButtonColor) argIdx++ } if req.Footer != nil { sets = append(sets, fmt.Sprintf("footer = $%d", argIdx)) args = append(args, *req.Footer) argIdx++ } if req.TextBody != nil { sets = append(sets, fmt.Sprintf("text_body = $%d", argIdx)) args = append(args, *req.TextBody) argIdx++ } if req.Enabled != nil { sets = append(sets, fmt.Sprintf("enabled = $%d", argIdx)) args = append(args, *req.Enabled) argIdx++ } if len(sets) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"}) return } sets = append(sets, "updated_at = NOW()") args = append(args, id) query := fmt.Sprintf("UPDATE email_templates SET %s WHERE id = $%d RETURNING id", strings.Join(sets, ", "), argIdx) var returnedID string err := h.pool.QueryRow(c.Request.Context(), query, args...).Scan(&returnedID) if err != nil { log.Error().Err(err).Str("template_id", id).Msg("Failed to update email template") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"}) return } // Log to audit adminID, _ := c.Get("user_id") _, _ = h.pool.Exec(c.Request.Context(), `INSERT INTO audit_log (admin_id, action, target_type, target_id, details) VALUES ($1, 'update_email_template', 'email_template', $2, $3)`, adminID, id, fmt.Sprintf("Updated email template %s", id)) c.JSON(http.StatusOK, gin.H{"message": "Template updated", "id": returnedID}) } func (h *AdminHandler) SendTestEmail(c *gin.Context) { var req struct { TemplateID string `json:"template_id"` ToEmail string `json:"to_email"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } if req.TemplateID == "" || req.ToEmail == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "template_id and to_email are required"}) return } var subject, title, header, content, buttonText, buttonURL, buttonColor, footer, textBody string err := h.pool.QueryRow(c.Request.Context(), `SELECT subject, title, header, content, button_text, button_url, button_color, footer, text_body FROM email_templates WHERE id = $1`, req.TemplateID). Scan(&subject, &title, &header, &content, &buttonText, &buttonURL, &buttonColor, &footer, &textBody) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"}) return } // Replace placeholders with sample data for test replacer := strings.NewReplacer( "{{name}}", "Test User", "{{verify_url}}", "https://mp.ls/verified", "{{reset_url}}", "https://mp.ls/sojorn", "{{reason}}", "This is a test reason", "{{duration}}", "7 days", "{{deletion_date}}", time.Now().AddDate(0, 0, 14).Format("January 2, 2006"), "{{confirm_url}}", "https://mp.ls/destroyed", "{{content_type}}", "post", "{{strike_count}}", "1", "{{strike_warning}}", "", ) subject = replacer.Replace(subject) header = replacer.Replace(header) content = replacer.Replace(content) buttonText = replacer.Replace(buttonText) buttonURL = replacer.Replace(buttonURL) textBody = replacer.Replace(textBody) htmlBody := h.emailService.BuildHTMLEmailWithColor(title, header, content, buttonURL, buttonText, footer, buttonColor) if err := h.emailService.SendGenericEmail(req.ToEmail, "Test User", subject, htmlBody, textBody); err != nil { log.Error().Err(err).Msg("Failed to send test email") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test email: " + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "Test email sent to " + req.ToEmail}) } func (h *AdminHandler) GetAltchaChallenge(c *gin.Context) { altchaService := services.NewAltchaService(h.jwtSecret) challenge, err := altchaService.GenerateChallenge() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate challenge"}) return } c.JSON(http.StatusOK, challenge) }