enki b3204ea07a
Some checks are pending
CI Pipeline / Run Tests (push) Waiting to run
CI Pipeline / Lint Code (push) Waiting to run
CI Pipeline / Security Scan (push) Waiting to run
CI Pipeline / Build Docker Images (push) Blocked by required conditions
CI Pipeline / E2E Tests (push) Blocked by required conditions
first commit
2025-08-18 00:40:15 -07:00

674 lines
19 KiB
Go

package admin
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"git.sovbit.dev/enki/torrentGateway/internal/profile"
"git.sovbit.dev/enki/torrentGateway/internal/storage"
"github.com/gorilla/mux"
)
// GatewayInterface defines the methods needed from the gateway
type GatewayInterface interface {
GetDB() *sql.DB
GetStorage() *storage.Backend
CleanupOldFiles(olderThan time.Duration) (map[string]interface{}, error)
CleanupOrphanedChunks() (map[string]interface{}, error)
CleanupInactiveUsers(days int) (map[string]interface{}, error)
}
// AdminHandlers provides admin-related HTTP handlers
type AdminHandlers struct {
adminAuth *AdminAuth
gateway GatewayInterface
profileFetcher *profile.ProfileFetcher
}
// NewAdminHandlers creates new admin handlers
func NewAdminHandlers(adminAuth *AdminAuth, gateway GatewayInterface, defaultRelays []string) *AdminHandlers {
return &AdminHandlers{
adminAuth: adminAuth,
gateway: gateway,
profileFetcher: profile.NewProfileFetcher(defaultRelays),
}
}
// AdminStatsResponse represents admin statistics
type AdminStatsResponse struct {
TotalFiles int `json:"total_files"`
TotalUsers int `json:"total_users"`
TotalStorage int64 `json:"total_storage"`
BannedUsers int `json:"banned_users"`
PendingReports int `json:"pending_reports"`
RecentUploads int `json:"recent_uploads_24h"`
ErrorRate float64 `json:"error_rate"`
}
// AdminUser represents a user in admin view
type AdminUser struct {
Pubkey string `json:"pubkey"`
DisplayName string `json:"display_name"`
FileCount int `json:"file_count"`
StorageUsed int64 `json:"storage_used"`
LastLogin time.Time `json:"last_login"`
CreatedAt time.Time `json:"created_at"`
IsBanned bool `json:"is_banned"`
Profile *profile.ProfileMetadata `json:"profile,omitempty"`
}
// AdminFile represents a file in admin view
type AdminFile struct {
Hash string `json:"hash"`
Name string `json:"name"`
Size int64 `json:"size"`
StorageType string `json:"storage_type"`
AccessLevel string `json:"access_level"`
OwnerPubkey string `json:"owner_pubkey"`
CreatedAt time.Time `json:"created_at"`
AccessCount int `json:"access_count"`
ReportCount int `json:"report_count"`
OwnerProfile *profile.ProfileMetadata `json:"owner_profile,omitempty"`
}
// AdminStatsHandler returns system statistics for admins
func (ah *AdminHandlers) AdminStatsHandler(w http.ResponseWriter, r *http.Request) {
adminPubkey, err := ah.adminAuth.ValidateAdminRequest(r)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "Unauthorized",
})
return
}
// Get total files
var totalFiles int
err = ah.gateway.GetDB().QueryRow("SELECT COUNT(*) FROM files").Scan(&totalFiles)
if err != nil {
http.Error(w, "Failed to get file count", http.StatusInternalServerError)
return
}
// Get total users
var totalUsers int
err = ah.gateway.GetDB().QueryRow("SELECT COUNT(*) FROM users").Scan(&totalUsers)
if err != nil {
http.Error(w, "Failed to get user count", http.StatusInternalServerError)
return
}
// Get total storage
var totalStorage int64
err = ah.gateway.GetDB().QueryRow("SELECT COALESCE(SUM(size), 0) FROM files").Scan(&totalStorage)
if err != nil {
http.Error(w, "Failed to get storage total", http.StatusInternalServerError)
return
}
// Get banned users count
var bannedUsers int
err = ah.gateway.GetDB().QueryRow("SELECT COUNT(*) FROM banned_users").Scan(&bannedUsers)
if err != nil {
http.Error(w, "Failed to get banned users count", http.StatusInternalServerError)
return
}
// Get pending reports
var pendingReports int
err = ah.gateway.GetDB().QueryRow("SELECT COUNT(*) FROM content_reports WHERE status = 'pending'").Scan(&pendingReports)
if err != nil {
http.Error(w, "Failed to get pending reports count", http.StatusInternalServerError)
return
}
// Get recent uploads (24h)
var recentUploads int
err = ah.gateway.GetDB().QueryRow("SELECT COUNT(*) FROM files WHERE created_at > datetime('now', '-1 day')").Scan(&recentUploads)
if err != nil {
http.Error(w, "Failed to get recent uploads count", http.StatusInternalServerError)
return
}
// Log admin action
ah.adminAuth.LogAdminAction(adminPubkey, "view_stats", "", "Admin viewed system statistics")
response := AdminStatsResponse{
TotalFiles: totalFiles,
TotalUsers: totalUsers,
TotalStorage: totalStorage,
BannedUsers: bannedUsers,
PendingReports: pendingReports,
RecentUploads: recentUploads,
ErrorRate: 0.0, // TODO: Implement error rate tracking
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// AdminUsersHandler returns list of users for admin management
func (ah *AdminHandlers) AdminUsersHandler(w http.ResponseWriter, r *http.Request) {
adminPubkey, err := ah.adminAuth.ValidateAdminRequest(r)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "Unauthorized",
})
return
}
// Parse query parameters
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
query := `
SELECT u.pubkey, COALESCE(u.display_name, '') as display_name, u.file_count, u.storage_used, u.last_login, u.created_at,
EXISTS(SELECT 1 FROM banned_users WHERE pubkey = u.pubkey) as is_banned
FROM users u
ORDER BY u.created_at DESC
LIMIT ? OFFSET ?
`
rows, err := ah.gateway.GetDB().Query(query, limit, offset)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "Failed to query users",
})
return
}
defer rows.Close()
var users []AdminUser
for rows.Next() {
var user AdminUser
err := rows.Scan(&user.Pubkey, &user.DisplayName, &user.FileCount,
&user.StorageUsed, &user.LastLogin, &user.CreatedAt, &user.IsBanned)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "Failed to scan user",
})
return
}
users = append(users, user)
}
// Fetch profile metadata for all users
pubkeys := make([]string, len(users))
for i, user := range users {
pubkeys[i] = user.Pubkey
}
profiles := ah.profileFetcher.GetBatchProfiles(pubkeys)
for i := range users {
if profile, exists := profiles[users[i].Pubkey]; exists {
users[i].Profile = profile
}
}
// Log admin action
ah.adminAuth.LogAdminAction(adminPubkey, "view_users", "", "Admin viewed user list")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
// AdminFilesHandler returns list of files for admin management
func (ah *AdminHandlers) AdminFilesHandler(w http.ResponseWriter, r *http.Request) {
adminPubkey, err := ah.adminAuth.ValidateAdminRequest(r)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "Unauthorized",
})
return
}
// Parse query parameters
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
storageType := r.URL.Query().Get("storage_type")
accessLevel := r.URL.Query().Get("access_level")
// Build query with filters
query := `
SELECT f.hash, f.original_name, f.size, f.storage_type, f.access_level,
COALESCE(f.owner_pubkey, '') as owner_pubkey, f.created_at, f.access_count,
COALESCE((SELECT COUNT(*) FROM content_reports WHERE file_hash = f.hash), 0) as report_count
FROM files f
WHERE 1=1
`
args := []interface{}{}
if storageType != "" {
query += " AND f.storage_type = ?"
args = append(args, storageType)
}
if accessLevel != "" {
query += " AND f.access_level = ?"
args = append(args, accessLevel)
}
query += " ORDER BY f.created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := ah.gateway.GetDB().Query(query, args...)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "Failed to query files",
})
return
}
defer rows.Close()
var files []AdminFile
for rows.Next() {
var file AdminFile
err := rows.Scan(&file.Hash, &file.Name, &file.Size, &file.StorageType,
&file.AccessLevel, &file.OwnerPubkey, &file.CreatedAt,
&file.AccessCount, &file.ReportCount)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "Failed to scan file",
})
return
}
files = append(files, file)
}
// Fetch profile metadata for file owners
ownerPubkeys := make([]string, 0)
for _, file := range files {
if file.OwnerPubkey != "" {
ownerPubkeys = append(ownerPubkeys, file.OwnerPubkey)
}
}
if len(ownerPubkeys) > 0 {
profiles := ah.profileFetcher.GetBatchProfiles(ownerPubkeys)
for i := range files {
if files[i].OwnerPubkey != "" {
if profile, exists := profiles[files[i].OwnerPubkey]; exists {
files[i].OwnerProfile = profile
}
}
}
}
// Log admin action
ah.adminAuth.LogAdminAction(adminPubkey, "view_files", "", "Admin viewed file list")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(files)
}
// AdminDeleteFileHandler deletes a file with admin privileges
func (ah *AdminHandlers) AdminDeleteFileHandler(w http.ResponseWriter, r *http.Request) {
adminPubkey, err := ah.adminAuth.ValidateAdminRequest(r)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "Unauthorized",
})
return
}
vars := mux.Vars(r)
fileHash := vars["hash"]
if fileHash == "" {
http.Error(w, "Missing file hash", http.StatusBadRequest)
return
}
// Get reason from request body
var reqBody struct {
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Get file info before deletion for logging
metadata, err := ah.gateway.GetStorage().GetFileMetadata(fileHash)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
// Admin can delete any file
err = ah.gateway.GetStorage().AdminDeleteFile(fileHash)
if err != nil {
http.Error(w, "Failed to delete file", http.StatusInternalServerError)
return
}
// Log admin action
reason := reqBody.Reason
if reason == "" {
reason = "Admin deletion"
}
ah.adminAuth.LogAdminAction(adminPubkey, "delete_file", fileHash,
fmt.Sprintf("Deleted file '%s' (owner: %s) - %s", metadata.OriginalName, metadata.OwnerPubkey, reason))
response := map[string]interface{}{
"success": true,
"message": "File deleted successfully",
"hash": fileHash,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// BanUserRequest represents a user ban request
type BanUserRequest struct {
Reason string `json:"reason"`
}
// AdminBanUserHandler bans a user
func (ah *AdminHandlers) AdminBanUserHandler(w http.ResponseWriter, r *http.Request) {
adminPubkey, err := ah.adminAuth.ValidateAdminRequest(r)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "Unauthorized",
})
return
}
vars := mux.Vars(r)
userPubkey := vars["pubkey"]
if userPubkey == "" {
http.Error(w, "Missing user pubkey", http.StatusBadRequest)
return
}
var req BanUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Ban the user
err = ah.adminAuth.BanUser(userPubkey, adminPubkey, req.Reason)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to ban user: %v", err), http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"success": true,
"message": "User banned successfully",
"pubkey": userPubkey,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// AdminUnbanUserHandler unbans a user
func (ah *AdminHandlers) AdminUnbanUserHandler(w http.ResponseWriter, r *http.Request) {
adminPubkey, err := ah.adminAuth.ValidateAdminRequest(r)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "Unauthorized",
})
return
}
vars := mux.Vars(r)
userPubkey := vars["pubkey"]
if userPubkey == "" {
http.Error(w, "Missing user pubkey", http.StatusBadRequest)
return
}
var req struct {
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Unban the user
err = ah.adminAuth.UnbanUser(userPubkey, adminPubkey, req.Reason)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to unban user: %v", err), http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"success": true,
"message": "User unbanned successfully",
"pubkey": userPubkey,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// AdminReportsHandler returns content reports
func (ah *AdminHandlers) AdminReportsHandler(w http.ResponseWriter, r *http.Request) {
adminPubkey, err := ah.adminAuth.ValidateAdminRequest(r)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "Unauthorized",
})
return
}
// Parse query parameters
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
status := r.URL.Query().Get("status")
query := `
SELECT cr.id, cr.file_hash, cr.reporter_pubkey, cr.reason, cr.status, cr.created_at,
f.original_name, f.size, f.owner_pubkey
FROM content_reports cr
LEFT JOIN files f ON cr.file_hash = f.hash
WHERE 1=1
`
args := []interface{}{}
if status != "" {
query += " AND cr.status = ?"
args = append(args, status)
}
query += " ORDER BY cr.created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := ah.gateway.GetDB().Query(query, args...)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "Failed to query reports",
})
return
}
defer rows.Close()
var reports []map[string]interface{}
for rows.Next() {
var report ContentReport
var fileName, ownerPubkey sql.NullString
var fileSize sql.NullInt64
err := rows.Scan(&report.ID, &report.FileHash, &report.ReporterPubkey,
&report.Reason, &report.Status, &report.CreatedAt,
&fileName, &fileSize, &ownerPubkey)
if err != nil {
http.Error(w, "Failed to scan report", http.StatusInternalServerError)
return
}
reportData := map[string]interface{}{
"id": report.ID,
"file_hash": report.FileHash,
"reporter_pubkey": report.ReporterPubkey,
"reason": report.Reason,
"status": report.Status,
"created_at": report.CreatedAt,
"file_name": fileName.String,
"file_size": fileSize.Int64,
"file_owner": ownerPubkey.String,
}
reports = append(reports, reportData)
}
// Log admin action
ah.adminAuth.LogAdminAction(adminPubkey, "view_reports", "", "Admin viewed content reports")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(reports)
}
// AdminCleanupHandler triggers cleanup operations
func (ah *AdminHandlers) AdminCleanupHandler(w http.ResponseWriter, r *http.Request) {
adminPubkey, err := ah.adminAuth.ValidateAdminRequest(r)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "Unauthorized",
})
return
}
var req struct {
Operation string `json:"operation"`
MaxAge string `json:"max_age,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
var cleanupResult map[string]interface{}
var cleanupErr error
switch req.Operation {
case "old_files":
maxAge := "90d"
if req.MaxAge != "" {
maxAge = req.MaxAge
}
duration, err := time.ParseDuration(maxAge)
if err != nil {
http.Error(w, "Invalid max_age format", http.StatusBadRequest)
return
}
cleanupResult, cleanupErr = ah.gateway.CleanupOldFiles(duration)
case "orphaned_chunks":
cleanupResult, cleanupErr = ah.gateway.CleanupOrphanedChunks()
case "inactive_users":
days := 365
if req.MaxAge != "" {
if d, err := strconv.Atoi(req.MaxAge); err == nil {
days = d
}
}
cleanupResult, cleanupErr = ah.gateway.CleanupInactiveUsers(days)
default:
http.Error(w, "Invalid cleanup operation", http.StatusBadRequest)
return
}
if cleanupErr != nil {
http.Error(w, fmt.Sprintf("Cleanup failed: %v", cleanupErr), http.StatusInternalServerError)
return
}
// Log admin action
ah.adminAuth.LogAdminAction(adminPubkey, "cleanup", req.Operation,
fmt.Sprintf("Executed cleanup operation: %s", req.Operation))
response := map[string]interface{}{
"success": true,
"operation": req.Operation,
"result": cleanupResult,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// AdminLogsHandler returns admin action logs
func (ah *AdminHandlers) AdminLogsHandler(w http.ResponseWriter, r *http.Request) {
_, err := ah.adminAuth.ValidateAdminRequest(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Parse query parameters
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
actions, err := ah.adminAuth.GetAdminActions(limit, offset, "")
if err != nil {
http.Error(w, "Failed to get admin actions", http.StatusInternalServerError)
return
}
// Log admin action (don't log viewing logs to avoid spam)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(actions)
}