Files
MyClub/internal/controllers/files_controller.go
T
Tomáš Dvořák 12cba639b9 upload
2025-10-16 13:32:05 +02:00

426 lines
12 KiB
Go

package controllers
import (
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"fotbal-club/internal/models"
"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{
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".svg": "image/svg+xml",
".pdf": "application/pdf",
".webp": "image/webp",
}
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
}