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, }) }