Add R2 storage browser to admin panel
This commit is contained in:
parent
14d8ca9ac0
commit
29772fa1e4
383
admin/src/app/storage/page.tsx
Normal file
383
admin/src/app/storage/page.tsx
Normal file
|
|
@ -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<any>(null);
|
||||
const [objects, setObjects] = useState<any[]>([]);
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [selectedBucket, setSelectedBucket] = useState('');
|
||||
const [prefix, setPrefix] = useState('');
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [browsing, setBrowsing] = useState(false);
|
||||
const [selectedObject, setSelectedObject] = useState<any>(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 (
|
||||
<AdminShell>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">R2 Storage</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Browse and manage Cloudflare R2 media buckets</p>
|
||||
</div>
|
||||
<button onClick={loadStats} className="btn-secondary text-sm flex items-center gap-2" disabled={statsLoading}>
|
||||
<RefreshCw className={`w-4 h-4 ${statsLoading ? 'animate-spin' : ''}`} /> Refresh Stats
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bucket Stats */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="card p-5 animate-pulse">
|
||||
<div className="h-4 bg-warm-300 rounded w-24 mb-3" />
|
||||
<div className="h-8 bg-warm-300 rounded w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : stats ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
{stats.buckets?.map((b: any) => (
|
||||
<button
|
||||
key={b.name}
|
||||
onClick={() => switchBucket(b.name)}
|
||||
className={`card p-5 text-left transition-all ${selectedBucket === b.name ? 'ring-2 ring-brand-500' : 'hover:shadow-md'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${b.name.includes('video') ? 'bg-purple-100 text-purple-600' : 'bg-blue-100 text-blue-600'}`}>
|
||||
{b.name.includes('video') ? <Film className="w-5 h-5" /> : <FileImage className="w-5 h-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 text-sm">{b.name}</p>
|
||||
<p className="text-xs text-gray-400">{b.domain}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">{b.object_count.toLocaleString()} objects</span>
|
||||
<span className="font-medium text-gray-700">{formatBytes(b.total_size)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center bg-green-100 text-green-600">
|
||||
<HardDrive className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 text-sm">Total Storage</p>
|
||||
<p className="text-xs text-gray-400">All buckets combined</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">{stats.total_objects?.toLocaleString()} objects</span>
|
||||
<span className="font-medium text-gray-700">{formatBytes(stats.total_size || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Browser Controls */}
|
||||
{selectedBucket && (
|
||||
<div className="card p-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{prefix && (
|
||||
<button onClick={navigateUp} className="btn-secondary text-sm flex items-center gap-1">
|
||||
<ArrowLeft className="w-4 h-4" /> Up
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500 flex-1 min-w-0">
|
||||
<HardDrive className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="font-medium text-gray-700">{selectedBucket}</span>
|
||||
{prefix && (
|
||||
<>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
<span className="truncate">{prefix}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => browse()}
|
||||
className="btn-primary text-sm flex items-center gap-2"
|
||||
disabled={browsing}
|
||||
>
|
||||
{browsing ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Folder className="w-4 h-4" />}
|
||||
{objects.length > 0 ? 'Refresh' : 'Browse'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Object List */}
|
||||
{(folders.length > 0 || objects.length > 0) && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="card overflow-hidden">
|
||||
{/* Folders */}
|
||||
{folders.map((folder) => (
|
||||
<button
|
||||
key={folder}
|
||||
onClick={() => navigateToFolder(folder)}
|
||||
className="flex items-center gap-3 px-4 py-3 w-full text-left hover:bg-warm-100 border-b border-warm-200 transition-colors"
|
||||
>
|
||||
<Folder className="w-5 h-5 text-yellow-500 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-700 truncate">{folder}</span>
|
||||
<ChevronRight className="w-4 h-4 text-gray-400 ml-auto flex-shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Files */}
|
||||
{objects.map((obj) => (
|
||||
<div
|
||||
key={obj.key}
|
||||
className={`flex items-center gap-3 px-4 py-3 border-b border-warm-200 hover:bg-warm-100 cursor-pointer transition-colors ${selectedObject?.key === obj.key ? 'bg-brand-50' : ''}`}
|
||||
onClick={() => setSelectedObject(obj)}
|
||||
>
|
||||
{isImageKey(obj.key) ? (
|
||||
<Image className="w-5 h-5 text-blue-500 flex-shrink-0" />
|
||||
) : isVideoKey(obj.key) ? (
|
||||
<Film className="w-5 h-5 text-purple-500 flex-shrink-0" />
|
||||
) : (
|
||||
<FileImage className="w-5 h-5 text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-700 truncate">
|
||||
{obj.key.split('/').pop() || obj.key}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{formatBytes(obj.size)} · {new Date(obj.last_modified).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); copyUrl(obj.url); }}
|
||||
className="p-1.5 rounded hover:bg-warm-200 text-gray-400 hover:text-gray-600"
|
||||
title="Copy URL"
|
||||
>
|
||||
{copiedUrl === obj.url ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<a
|
||||
href={obj.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-1.5 rounded hover:bg-warm-200 text-gray-400 hover:text-gray-600"
|
||||
title="Open in new tab"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(selectedBucket, obj.key); }}
|
||||
className="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-red-600"
|
||||
title="Delete"
|
||||
disabled={deleting === obj.key}
|
||||
>
|
||||
<Trash2 className={`w-4 h-4 ${deleting === obj.key ? 'animate-pulse' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{objects.length === 0 && folders.length === 0 && !browsing && (
|
||||
<div className="p-8 text-center text-gray-400 text-sm">No objects found in this location.</div>
|
||||
)}
|
||||
|
||||
{browsing && (
|
||||
<div className="p-4 text-center text-gray-400 text-sm">
|
||||
<RefreshCw className="w-5 h-5 animate-spin inline-block mr-2" />Loading...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Load More */}
|
||||
{nextCursor && (
|
||||
<button
|
||||
onClick={() => browse(selectedBucket, prefix, nextCursor)}
|
||||
className="mt-3 btn-secondary text-sm w-full"
|
||||
disabled={browsing}
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail / Preview Panel */}
|
||||
<div className="lg:col-span-1">
|
||||
{selectedObject ? (
|
||||
<div className="card p-4 sticky top-6">
|
||||
{/* Preview */}
|
||||
{isImageKey(selectedObject.key) && (
|
||||
<div className="mb-4 rounded-lg overflow-hidden bg-warm-200 aspect-square flex items-center justify-center">
|
||||
<img
|
||||
src={selectedObject.url}
|
||||
alt={selectedObject.key}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isVideoKey(selectedObject.key) && (
|
||||
<div className="mb-4 rounded-lg overflow-hidden bg-black aspect-video">
|
||||
<video src={selectedObject.url} controls className="w-full h-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-sm font-semibold text-gray-900 break-all mb-3">
|
||||
{selectedObject.key.split('/').pop() || selectedObject.key}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Key</span>
|
||||
<span className="text-gray-700 text-right break-all ml-4 max-w-[60%]">{selectedObject.key}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Size</span>
|
||||
<span className="text-gray-700">{formatBytes(selectedObject.size)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Modified</span>
|
||||
<span className="text-gray-700">{new Date(selectedObject.last_modified).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">ETag</span>
|
||||
<span className="text-gray-700 font-mono text-xs">{selectedObject.etag?.slice(0, 12)}...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<button
|
||||
onClick={() => copyUrl(selectedObject.url)}
|
||||
className="btn-secondary text-sm w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{copiedUrl === selectedObject.url ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||
{copiedUrl === selectedObject.url ? 'Copied!' : 'Copy URL'}
|
||||
</button>
|
||||
<a
|
||||
href={selectedObject.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary text-sm w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" /> Open in Browser
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleDelete(selectedBucket, selectedObject.key)}
|
||||
className="w-full py-2 px-4 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 transition-colors flex items-center justify-center gap-2"
|
||||
disabled={deleting === selectedObject.key}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> Delete Object
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card p-8 text-center text-gray-400 text-sm">
|
||||
<FileImage className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
Select an object to view details
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -228,6 +228,32 @@ class ApiClient {
|
|||
if (params.offset) qs.set('offset', String(params.offset));
|
||||
return this.request<any>(`/api/v1/admin/audit-log?${qs}`);
|
||||
}
|
||||
|
||||
// R2 Storage
|
||||
async getStorageStats() {
|
||||
return this.request<any>('/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<any>(`/api/v1/admin/storage/objects?${qs}`);
|
||||
}
|
||||
|
||||
async getStorageObject(bucket: string, key: string) {
|
||||
const qs = new URLSearchParams({ bucket, key });
|
||||
return this.request<any>(`/api/v1/admin/storage/object?${qs}`);
|
||||
}
|
||||
|
||||
async deleteStorageObject(bucket: string, key: string) {
|
||||
return this.request<any>('/api/v1/admin/storage/object', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ bucket, key }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue