diff --git a/admin/src/app/storage/page.tsx b/admin/src/app/storage/page.tsx new file mode 100644 index 0000000..c72237f --- /dev/null +++ b/admin/src/app/storage/page.tsx @@ -0,0 +1,383 @@ +'use client'; + +import AdminShell from '@/components/AdminShell'; +import { api } from '@/lib/api'; +import { useEffect, useState } from 'react'; +import { HardDrive, Folder, FileImage, Film, Trash2, ExternalLink, ChevronRight, ArrowLeft, RefreshCw, Image, Copy, Check } from 'lucide-react'; + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +function isImageKey(key: string): boolean { + return /\.(jpg|jpeg|png|gif|webp|svg|avif|bmp)$/i.test(key); +} + +function isVideoKey(key: string): boolean { + return /\.(mp4|webm|mov|avi|mkv)$/i.test(key); +} + +export default function StoragePage() { + const [stats, setStats] = useState(null); + const [objects, setObjects] = useState([]); + const [folders, setFolders] = useState([]); + const [selectedBucket, setSelectedBucket] = useState(''); + const [prefix, setPrefix] = useState(''); + const [nextCursor, setNextCursor] = useState(null); + const [loading, setLoading] = useState(true); + const [browsing, setBrowsing] = useState(false); + const [selectedObject, setSelectedObject] = useState(null); + const [deleting, setDeleting] = useState(''); + const [copiedUrl, setCopiedUrl] = useState(''); + const [statsLoading, setStatsLoading] = useState(true); + + useEffect(() => { + loadStats(); + }, []); + + const loadStats = async () => { + setStatsLoading(true); + try { + const data = await api.getStorageStats(); + setStats(data); + if (data.buckets?.length > 0 && !selectedBucket) { + setSelectedBucket(data.buckets[0].name); + } + } catch {} + setStatsLoading(false); + setLoading(false); + }; + + const browse = async (bucket?: string, pfx?: string, cursor?: string) => { + setBrowsing(true); + const b = bucket || selectedBucket; + const p = pfx ?? prefix; + try { + const data = await api.listStorageObjects({ bucket: b, prefix: p || undefined, cursor: cursor || undefined, limit: 50 }); + if (cursor) { + setObjects((prev) => [...prev, ...data.objects]); + } else { + setObjects(data.objects); + } + setFolders(data.folders || []); + setNextCursor(data.next_cursor || null); + setSelectedBucket(b); + setPrefix(p); + } catch {} + setBrowsing(false); + }; + + const navigateToFolder = (folder: string) => { + setObjects([]); + setFolders([]); + browse(selectedBucket, folder); + }; + + const navigateUp = () => { + if (!prefix) return; + const parts = prefix.replace(/\/$/, '').split('/'); + parts.pop(); + const newPrefix = parts.length > 0 ? parts.join('/') + '/' : ''; + setObjects([]); + setFolders([]); + browse(selectedBucket, newPrefix); + }; + + const handleDelete = async (bucket: string, key: string) => { + if (!confirm(`Delete ${key}? This cannot be undone.`)) return; + setDeleting(key); + try { + await api.deleteStorageObject(bucket, key); + setObjects((prev) => prev.filter((o) => o.key !== key)); + if (selectedObject?.key === key) setSelectedObject(null); + } catch {} + setDeleting(''); + }; + + const copyUrl = (url: string) => { + navigator.clipboard.writeText(url); + setCopiedUrl(url); + setTimeout(() => setCopiedUrl(''), 2000); + }; + + const switchBucket = (bucket: string) => { + setSelectedBucket(bucket); + setPrefix(''); + setObjects([]); + setFolders([]); + setSelectedObject(null); + browse(bucket, ''); + }; + + return ( + +
+
+

R2 Storage

+

Browse and manage Cloudflare R2 media buckets

+
+ +
+ + {/* Bucket Stats */} + {loading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+ ))} +
+ ) : stats ? ( +
+ {stats.buckets?.map((b: any) => ( + + ))} +
+
+
+ +
+
+

Total Storage

+

All buckets combined

+
+
+
+ {stats.total_objects?.toLocaleString()} objects + {formatBytes(stats.total_size || 0)} +
+
+
+ ) : null} + + {/* Browser Controls */} + {selectedBucket && ( +
+
+ {prefix && ( + + )} +
+ + {selectedBucket} + {prefix && ( + <> + + {prefix} + + )} +
+ +
+
+ )} + + {/* Object List */} + {(folders.length > 0 || objects.length > 0) && ( +
+
+
+ {/* Folders */} + {folders.map((folder) => ( + + ))} + + {/* Files */} + {objects.map((obj) => ( +
setSelectedObject(obj)} + > + {isImageKey(obj.key) ? ( + + ) : isVideoKey(obj.key) ? ( + + ) : ( + + )} +
+

+ {obj.key.split('/').pop() || obj.key} +

+

+ {formatBytes(obj.size)} · {new Date(obj.last_modified).toLocaleDateString()} +

+
+
+ + e.stopPropagation()} + className="p-1.5 rounded hover:bg-warm-200 text-gray-400 hover:text-gray-600" + title="Open in new tab" + > + + + +
+
+ ))} + + {objects.length === 0 && folders.length === 0 && !browsing && ( +
No objects found in this location.
+ )} + + {browsing && ( +
+ Loading... +
+ )} +
+ + {/* Load More */} + {nextCursor && ( + + )} +
+ + {/* Detail / Preview Panel */} +
+ {selectedObject ? ( +
+ {/* Preview */} + {isImageKey(selectedObject.key) && ( +
+ {selectedObject.key} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+ )} + {isVideoKey(selectedObject.key) && ( +
+
+ )} + +

+ {selectedObject.key.split('/').pop() || selectedObject.key} +

+ +
+
+ Key + {selectedObject.key} +
+
+ Size + {formatBytes(selectedObject.size)} +
+
+ Modified + {new Date(selectedObject.last_modified).toLocaleString()} +
+
+ ETag + {selectedObject.etag?.slice(0, 12)}... +
+
+ +
+ + + Open in Browser + + +
+
+ ) : ( +
+ + Select an object to view details +
+ )} +
+
+ )} + + ); +} diff --git a/admin/src/components/Sidebar.tsx b/admin/src/components/Sidebar.tsx index f2af031..2e7ecb6 100644 --- a/admin/src/components/Sidebar.tsx +++ b/admin/src/components/Sidebar.tsx @@ -6,7 +6,7 @@ import { useAuth } from '@/lib/auth'; import { cn } from '@/lib/utils'; import { LayoutDashboard, Users, FileText, Shield, Scale, Flag, - Settings, Activity, LogOut, ChevronLeft, ChevronRight, Sliders, FolderTree, + Settings, Activity, LogOut, ChevronLeft, ChevronRight, Sliders, FolderTree, HardDrive, } from 'lucide-react'; import { useState } from 'react'; @@ -19,6 +19,7 @@ const navItems = [ { href: '/reports', label: 'Reports', icon: Flag }, { href: '/algorithm', label: 'Algorithm', icon: Sliders }, { href: '/categories', label: 'Categories', icon: FolderTree }, + { href: '/storage', label: 'Storage', icon: HardDrive }, { href: '/system', label: 'System Health', icon: Activity }, { href: '/settings', label: 'Settings', icon: Settings }, ]; diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index c7a69c1..fe4ff63 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -228,6 +228,32 @@ class ApiClient { if (params.offset) qs.set('offset', String(params.offset)); return this.request(`/api/v1/admin/audit-log?${qs}`); } + + // R2 Storage + async getStorageStats() { + return this.request('/api/v1/admin/storage/stats'); + } + + async listStorageObjects(params: { bucket?: string; prefix?: string; cursor?: string; limit?: number } = {}) { + const qs = new URLSearchParams(); + if (params.bucket) qs.set('bucket', params.bucket); + if (params.prefix) qs.set('prefix', params.prefix); + if (params.cursor) qs.set('cursor', params.cursor); + if (params.limit) qs.set('limit', String(params.limit)); + return this.request(`/api/v1/admin/storage/objects?${qs}`); + } + + async getStorageObject(bucket: string, key: string) { + const qs = new URLSearchParams({ bucket, key }); + return this.request(`/api/v1/admin/storage/object?${qs}`); + } + + async deleteStorageObject(bucket: string, key: string) { + return this.request('/api/v1/admin/storage/object', { + method: 'DELETE', + body: JSON.stringify({ bucket, key }), + }); + } } export const api = new ApiClient(); diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index b859b93..5a9ca61 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -133,8 +133,6 @@ func main() { settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo) analysisHandler := handlers.NewAnalysisHandler() appealHandler := handlers.NewAppealHandler(appealService) - adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, cfg.JWTSecret, cfg.TurnstileSecretKey) - var s3Client *s3.Client if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" { resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) { @@ -153,6 +151,8 @@ func main() { } } + adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain) + mediaHandler := handlers.NewMediaHandler( s3Client, cfg.R2AccountID, @@ -392,6 +392,12 @@ func main() { // System admin.GET("/health", adminHandler.GetSystemHealth) admin.GET("/audit-log", adminHandler.GetAuditLog) + + // R2 Storage + admin.GET("/storage/stats", adminHandler.GetStorageStats) + admin.GET("/storage/objects", adminHandler.ListStorageObjects) + admin.GET("/storage/object", adminHandler.GetStorageObject) + admin.DELETE("/storage/object", adminHandler.DeleteStorageObject) } srv := &http.Server{ diff --git a/go-backend/internal/handlers/admin_handler.go b/go-backend/internal/handlers/admin_handler.go index 1e0bc90..76ab5d2 100644 --- a/go-backend/internal/handlers/admin_handler.go +++ b/go-backend/internal/handlers/admin_handler.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "encoding/json" "fmt" "net/http" @@ -8,6 +9,8 @@ import ( "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" @@ -23,15 +26,25 @@ type AdminHandler struct { appealService *services.AppealService jwtSecret string turnstileSecret string + s3Client *s3.Client + mediaBucket string + videoBucket string + imgDomain string + vidDomain string } -func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, jwtSecret string, turnstileSecret string) *AdminHandler { +func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, jwtSecret string, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler { return &AdminHandler{ pool: pool, moderationService: moderationService, appealService: appealService, jwtSecret: jwtSecret, turnstileSecret: turnstileSecret, + s3Client: s3Client, + mediaBucket: mediaBucket, + videoBucket: videoBucket, + imgDomain: imgDomain, + vidDomain: vidDomain, } } @@ -1386,3 +1399,243 @@ func (h *AdminHandler) GetAuditLog(c *gin.Context) { 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"}) +}