mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
555 lines
16 KiB
Go
555 lines
16 KiB
Go
package controllers
|
|
|
|
import (
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"fotbal-club/internal/models"
|
|
"fotbal-club/internal/services"
|
|
"fotbal-club/pkg/logger"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type FilesController struct {
|
|
DB *gorm.DB
|
|
}
|
|
|
|
// FileInfo represents detailed file information with usage tracking
|
|
type FileInfo struct {
|
|
ID uint `json:"id"`
|
|
Filename string `json:"filename"`
|
|
FilePath string `json:"file_path"`
|
|
FileURL string `json:"file_url"`
|
|
FileSize int64 `json:"file_size"`
|
|
MimeType string `json:"mime_type"`
|
|
UploadedBy *models.User `json:"uploaded_by,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
Usages []models.FileUsage `json:"usages,omitempty"`
|
|
UsageCount int `json:"usage_count"`
|
|
MD5Hash string `json:"md5_hash,omitempty"`
|
|
}
|
|
|
|
// GetAllFiles returns all uploaded files with usage information
|
|
func (fc *FilesController) GetAllFiles(c *gin.Context) {
|
|
var files []models.UploadedFile
|
|
|
|
query := fc.DB.Preload("UploadedBy").Preload("Usages")
|
|
|
|
// Optional filtering
|
|
if search := c.Query("search"); search != "" {
|
|
query = query.Where("filename ILIKE ?", "%"+search+"%")
|
|
}
|
|
|
|
if mimeType := c.Query("mime_type"); mimeType != "" {
|
|
query = query.Where("mime_type LIKE ?", mimeType+"%")
|
|
}
|
|
|
|
// Sorting
|
|
sortBy := c.DefaultQuery("sort_by", "created_at")
|
|
sortOrder := c.DefaultQuery("sort_order", "desc")
|
|
query = query.Order(fmt.Sprintf("%s %s", sortBy, sortOrder))
|
|
|
|
if err := query.Find(&files).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch files"})
|
|
return
|
|
}
|
|
|
|
// Convert to FileInfo format
|
|
fileInfos := make([]FileInfo, len(files))
|
|
for i, file := range files {
|
|
fileInfos[i] = FileInfo{
|
|
ID: file.ID,
|
|
Filename: file.Filename,
|
|
FilePath: file.FilePath,
|
|
FileURL: file.FileURL,
|
|
FileSize: file.FileSize,
|
|
MimeType: file.MimeType,
|
|
UploadedBy: file.UploadedBy,
|
|
CreatedAt: file.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
Usages: file.Usages,
|
|
UsageCount: len(file.Usages),
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, fileInfos)
|
|
}
|
|
|
|
// GetUnusedFiles returns files that have no usage records
|
|
func (fc *FilesController) GetUnusedFiles(c *gin.Context) {
|
|
var files []models.UploadedFile
|
|
|
|
// Find files with no usages
|
|
if err := fc.DB.Preload("UploadedBy").
|
|
Preload("Usages").
|
|
Joins("LEFT JOIN file_usages ON file_usages.file_id = uploaded_files.id").
|
|
Where("file_usages.id IS NULL").
|
|
Group("uploaded_files.id").
|
|
Order("uploaded_files.created_at DESC").
|
|
Find(&files).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch unused files"})
|
|
return
|
|
}
|
|
|
|
fileInfos := make([]FileInfo, len(files))
|
|
for i, file := range files {
|
|
fileInfos[i] = FileInfo{
|
|
ID: file.ID,
|
|
Filename: file.Filename,
|
|
FilePath: file.FilePath,
|
|
FileURL: file.FileURL,
|
|
FileSize: file.FileSize,
|
|
MimeType: file.MimeType,
|
|
UploadedBy: file.UploadedBy,
|
|
CreatedAt: file.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
Usages: file.Usages,
|
|
UsageCount: 0,
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, fileInfos)
|
|
}
|
|
|
|
// GetDuplicateFiles finds files with the same content (based on MD5 hash)
|
|
func (fc *FilesController) GetDuplicateFiles(c *gin.Context) {
|
|
var files []models.UploadedFile
|
|
|
|
if err := fc.DB.Preload("UploadedBy").Preload("Usages").Find(&files).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch files"})
|
|
return
|
|
}
|
|
|
|
// Calculate MD5 hashes and group by hash
|
|
hashMap := make(map[string][]FileInfo)
|
|
|
|
for _, file := range files {
|
|
// Calculate MD5 hash of file content
|
|
hash, err := calculateFileMD5(file.FilePath)
|
|
if err != nil {
|
|
logger.Warn("Failed to calculate hash for %s: %v", file.FilePath, err)
|
|
continue
|
|
}
|
|
|
|
fileInfo := FileInfo{
|
|
ID: file.ID,
|
|
Filename: file.Filename,
|
|
FilePath: file.FilePath,
|
|
FileURL: file.FileURL,
|
|
FileSize: file.FileSize,
|
|
MimeType: file.MimeType,
|
|
UploadedBy: file.UploadedBy,
|
|
CreatedAt: file.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
Usages: file.Usages,
|
|
UsageCount: len(file.Usages),
|
|
MD5Hash: hash,
|
|
}
|
|
|
|
hashMap[hash] = append(hashMap[hash], fileInfo)
|
|
}
|
|
|
|
// Filter only duplicates (groups with more than one file)
|
|
duplicates := make(map[string][]FileInfo)
|
|
for hash, files := range hashMap {
|
|
if len(files) > 1 {
|
|
duplicates[hash] = files
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, duplicates)
|
|
}
|
|
|
|
// DeleteFile deletes a file and its usage records
|
|
func (fc *FilesController) DeleteFile(c *gin.Context) {
|
|
fileID := c.Param("id")
|
|
|
|
var file models.UploadedFile
|
|
if err := fc.DB.Preload("Usages").First(&file, fileID).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch file"})
|
|
}
|
|
return
|
|
}
|
|
|
|
// Return usage information if file is being used and force flag is not set
|
|
force := c.Query("force") == "true"
|
|
if len(file.Usages) > 0 && !force {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": "File is in use",
|
|
"usages": file.Usages,
|
|
"message": "This file is being used. Use force=true to delete anyway.",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Delete physical file
|
|
if err := os.Remove(file.FilePath); err != nil {
|
|
logger.Warn("Failed to delete physical file %s: %v", file.FilePath, err)
|
|
// Continue with database deletion even if physical file deletion fails
|
|
}
|
|
|
|
// Delete database record (cascades to usages)
|
|
if err := fc.DB.Delete(&file).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete file record"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "File deleted successfully"})
|
|
}
|
|
|
|
// GetFileUsages returns where a specific file is being used
|
|
func (fc *FilesController) GetFileUsages(c *gin.Context) {
|
|
fileID := c.Param("id")
|
|
|
|
var usages []models.FileUsage
|
|
if err := fc.DB.Where("file_id = ?", fileID).Find(&usages).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch file usages"})
|
|
return
|
|
}
|
|
|
|
// Enrich usage data with entity information
|
|
enrichedUsages := make([]map[string]interface{}, len(usages))
|
|
for i, usage := range usages {
|
|
entityInfo := getEntityInfo(fc.DB, usage.EntityType, usage.EntityID)
|
|
enrichedUsages[i] = map[string]interface{}{
|
|
"id": usage.ID,
|
|
"entity_type": usage.EntityType,
|
|
"entity_id": usage.EntityID,
|
|
"field_name": usage.FieldName,
|
|
"entity_info": entityInfo,
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, enrichedUsages)
|
|
}
|
|
|
|
// ScanAndSyncFiles scans the uploads directory and syncs with database
|
|
func (fc *FilesController) ScanAndSyncFiles(c *gin.Context) {
|
|
uploadsDir := "uploads"
|
|
|
|
var filesInDB []models.UploadedFile
|
|
if err := fc.DB.Find(&filesInDB).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database files"})
|
|
return
|
|
}
|
|
|
|
dbFileMap := make(map[string]models.UploadedFile)
|
|
for _, file := range filesInDB {
|
|
dbFileMap[file.FilePath] = file
|
|
}
|
|
|
|
foundFiles := 0
|
|
newFiles := 0
|
|
orphanedFiles := 0
|
|
skippedFiles := 0
|
|
newFilesList := []string{}
|
|
orphanedFilesList := []string{}
|
|
|
|
// Walk through uploads directory
|
|
err := filepath.Walk(uploadsDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
logger.Warn("Error accessing path %s: %v", path, err)
|
|
return nil // Continue with other files
|
|
}
|
|
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
// Skip .gitkeep and hidden files
|
|
filename := filepath.Base(path)
|
|
if filename == ".gitkeep" || strings.HasPrefix(filename, ".") {
|
|
skippedFiles++
|
|
return nil
|
|
}
|
|
|
|
foundFiles++
|
|
|
|
// Normalize path separators for cross-platform compatibility
|
|
normalizedPath := filepath.ToSlash(path)
|
|
if filepath.Separator == '\\' {
|
|
// On Windows, convert backslashes to forward slashes
|
|
normalizedPath = strings.ReplaceAll(path, "\\", "/")
|
|
}
|
|
|
|
// Check both original path and normalized path
|
|
_, existsOriginal := dbFileMap[path]
|
|
_, existsNormalized := dbFileMap[normalizedPath]
|
|
|
|
if !existsOriginal && !existsNormalized {
|
|
// File exists on disk but not in database - add it
|
|
mimeType := detectMimeType(path)
|
|
fileURL := "/" + filepath.ToSlash(path)
|
|
|
|
newFile := models.UploadedFile{
|
|
Filename: filename,
|
|
FilePath: normalizedPath,
|
|
FileURL: fileURL,
|
|
FileSize: info.Size(),
|
|
MimeType: mimeType,
|
|
}
|
|
|
|
if err := fc.DB.Create(&newFile).Error; err != nil {
|
|
logger.Warn("Failed to create database record for %s: %v", path, err)
|
|
} else {
|
|
newFiles++
|
|
newFilesList = append(newFilesList, filename)
|
|
logger.Info("Added new file to database: %s", filename)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
logger.Error("Failed to scan directory: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan directory"})
|
|
return
|
|
}
|
|
|
|
// Check for orphaned database records (files in DB but not on disk)
|
|
for _, dbFile := range filesInDB {
|
|
// Try both original path and with backslashes (for Windows)
|
|
paths := []string{dbFile.FilePath, strings.ReplaceAll(dbFile.FilePath, "/", "\\")}
|
|
found := false
|
|
for _, p := range paths {
|
|
if _, err := os.Stat(p); err == nil {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
orphanedFiles++
|
|
orphanedFilesList = append(orphanedFilesList, dbFile.Filename)
|
|
// Optionally delete orphaned records
|
|
// fc.DB.Delete(&dbFile)
|
|
}
|
|
}
|
|
|
|
logger.Info("Scan completed: %d found, %d new, %d orphaned, %d skipped", foundFiles, newFiles, orphanedFiles, skippedFiles)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Scan completed",
|
|
"found_files": foundFiles,
|
|
"new_files": newFiles,
|
|
"orphaned_files": orphanedFiles,
|
|
"skipped_files": skippedFiles,
|
|
"new_files_list": newFilesList,
|
|
"orphaned_list": orphanedFilesList,
|
|
})
|
|
}
|
|
|
|
// Helper function to calculate MD5 hash of a file
|
|
func calculateFileMD5(filePath string) (string, error) {
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer file.Close()
|
|
|
|
hash := md5.New()
|
|
if _, err := io.Copy(hash, file); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return hex.EncodeToString(hash.Sum(nil)), nil
|
|
}
|
|
|
|
// Helper function to detect MIME type from file extension
|
|
func detectMimeType(filePath string) string {
|
|
ext := strings.ToLower(filepath.Ext(filePath))
|
|
mimeTypes := map[string]string{
|
|
// Images
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".png": "image/png",
|
|
".gif": "image/gif",
|
|
".svg": "image/svg+xml",
|
|
".webp": "image/webp",
|
|
".bmp": "image/bmp",
|
|
".ico": "image/x-icon",
|
|
// Documents
|
|
".pdf": "application/pdf",
|
|
".doc": "application/msword",
|
|
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
".xls": "application/vnd.ms-excel",
|
|
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
".ppt": "application/vnd.ms-powerpoint",
|
|
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
".txt": "text/plain",
|
|
".csv": "text/csv",
|
|
// Archives
|
|
".zip": "application/zip",
|
|
".rar": "application/x-rar-compressed",
|
|
".7z": "application/x-7z-compressed",
|
|
".tar": "application/x-tar",
|
|
".gz": "application/gzip",
|
|
// Media
|
|
".mp4": "video/mp4",
|
|
".avi": "video/x-msvideo",
|
|
".mov": "video/quicktime",
|
|
".mp3": "audio/mpeg",
|
|
".wav": "audio/wav",
|
|
}
|
|
|
|
if mime, ok := mimeTypes[ext]; ok {
|
|
return mime
|
|
}
|
|
return "application/octet-stream"
|
|
}
|
|
|
|
// Helper function to get entity information
|
|
func getEntityInfo(db *gorm.DB, entityType string, entityID uint) map[string]interface{} {
|
|
info := map[string]interface{}{
|
|
"type": entityType,
|
|
"id": entityID,
|
|
}
|
|
|
|
switch entityType {
|
|
case "article":
|
|
var article models.Article
|
|
if err := db.Select("id", "title", "slug").First(&article, entityID).Error; err == nil {
|
|
info["title"] = article.Title
|
|
info["slug"] = article.Slug
|
|
info["url"] = fmt.Sprintf("/news/%s", article.Slug)
|
|
}
|
|
case "player":
|
|
var player models.Player
|
|
if err := db.Select("id", "first_name", "last_name").First(&player, entityID).Error; err == nil {
|
|
info["name"] = fmt.Sprintf("%s %s", player.FirstName, player.LastName)
|
|
info["url"] = fmt.Sprintf("/hraci/%d", player.ID)
|
|
}
|
|
case "sponsor":
|
|
var sponsor models.Sponsor
|
|
if err := db.Select("id", "name").First(&sponsor, entityID).Error; err == nil {
|
|
info["name"] = sponsor.Name
|
|
}
|
|
case "event":
|
|
var event models.Event
|
|
if err := db.Select("id", "title").First(&event, entityID).Error; err == nil {
|
|
info["title"] = event.Title
|
|
info["url"] = fmt.Sprintf("/aktivita/%d", event.ID)
|
|
}
|
|
case "contact":
|
|
var contact models.Contact
|
|
if err := db.Select("id", "name", "position").First(&contact, entityID).Error; err == nil {
|
|
info["name"] = contact.Name
|
|
info["position"] = contact.Position
|
|
}
|
|
}
|
|
|
|
return info
|
|
}
|
|
|
|
// RefreshFileTracking re-scans all entities and updates file usage tracking
|
|
func (fc *FilesController) RefreshFileTracking(c *gin.Context) {
|
|
entityType := c.Query("entity_type") // Optional: "article", "event", "player", etc.
|
|
|
|
stats := map[string]int{
|
|
"articles_scanned": 0,
|
|
"events_scanned": 0,
|
|
"players_scanned": 0,
|
|
"sponsors_scanned": 0,
|
|
"contacts_scanned": 0,
|
|
"teams_scanned": 0,
|
|
"settings_scanned": 0,
|
|
}
|
|
|
|
fileTracker := services.NewFileTracker(fc.DB)
|
|
|
|
// Refresh articles
|
|
if entityType == "" || entityType == "article" {
|
|
var articles []models.Article
|
|
if err := fc.DB.Find(&articles).Error; err == nil {
|
|
for _, article := range articles {
|
|
fileTracker.TrackArticleFiles(&article)
|
|
stats["articles_scanned"]++
|
|
}
|
|
logger.Info("Refreshed file tracking for %d articles", len(articles))
|
|
}
|
|
}
|
|
|
|
// Refresh events
|
|
if entityType == "" || entityType == "event" {
|
|
var events []models.Event
|
|
if err := fc.DB.Preload("Attachments").Find(&events).Error; err == nil {
|
|
for _, event := range events {
|
|
fileTracker.TrackEventFiles(&event)
|
|
stats["events_scanned"]++
|
|
}
|
|
logger.Info("Refreshed file tracking for %d events", len(events))
|
|
}
|
|
}
|
|
|
|
// Refresh players
|
|
if entityType == "" || entityType == "player" {
|
|
var players []models.Player
|
|
if err := fc.DB.Find(&players).Error; err == nil {
|
|
for _, player := range players {
|
|
fileTracker.TrackPlayerFiles(&player)
|
|
stats["players_scanned"]++
|
|
}
|
|
logger.Info("Refreshed file tracking for %d players", len(players))
|
|
}
|
|
}
|
|
|
|
// Refresh sponsors
|
|
if entityType == "" || entityType == "sponsor" {
|
|
var sponsors []models.Sponsor
|
|
if err := fc.DB.Find(&sponsors).Error; err == nil {
|
|
for _, sponsor := range sponsors {
|
|
fileTracker.TrackSponsorFiles(&sponsor)
|
|
stats["sponsors_scanned"]++
|
|
}
|
|
logger.Info("Refreshed file tracking for %d sponsors", len(sponsors))
|
|
}
|
|
}
|
|
|
|
// Refresh contacts
|
|
if entityType == "" || entityType == "contact" {
|
|
var contacts []models.Contact
|
|
if err := fc.DB.Find(&contacts).Error; err == nil {
|
|
for _, contact := range contacts {
|
|
fileTracker.TrackContactFiles(&contact)
|
|
stats["contacts_scanned"]++
|
|
}
|
|
logger.Info("Refreshed file tracking for %d contacts", len(contacts))
|
|
}
|
|
}
|
|
|
|
// Refresh teams
|
|
if entityType == "" || entityType == "team" {
|
|
var teams []models.Team
|
|
if err := fc.DB.Find(&teams).Error; err == nil {
|
|
for _, team := range teams {
|
|
fileTracker.TrackTeamFiles(&team)
|
|
stats["teams_scanned"]++
|
|
}
|
|
logger.Info("Refreshed file tracking for %d teams", len(teams))
|
|
}
|
|
}
|
|
|
|
// Refresh settings
|
|
if entityType == "" || entityType == "settings" {
|
|
var settings models.Settings
|
|
if err := fc.DB.First(&settings).Error; err == nil {
|
|
fileTracker.TrackSettingsFiles(&settings)
|
|
stats["settings_scanned"]++
|
|
logger.Info("Refreshed file tracking for settings")
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "File tracking refreshed successfully",
|
|
"stats": stats,
|
|
})
|
|
}
|