using System.ComponentModel.DataAnnotations; using DysonNetwork.Drive.Storage; using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Http; using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Proto; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace DysonNetwork.Drive.Index; [ApiController] [Route("/api/index")] [Authorize] public class FileIndexController( FileIndexService fileIndexService, AppDatabase db, ILogger logger ) : ControllerBase { /// /// Gets files in a specific path for the current user /// /// The path to browse (defaults to root "/") /// List of files in the specified path [HttpGet("browse")] public async Task BrowseFiles([FromQuery] string path = "/") { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); try { var fileIndexes = await fileIndexService.GetByPathAsync(accountId, path); // Get all file indexes for this account to extract child folders var allFileIndexes = await fileIndexService.GetByAccountIdAsync(accountId); // Extract unique child folder paths var childFolders = ExtractChildFolders(allFileIndexes, path); return Ok(new { Path = path, Files = fileIndexes, Folders = childFolders, TotalCount = fileIndexes.Count }); } catch (Exception ex) { logger.LogError(ex, "Failed to browse files for account {AccountId} at path {Path}", accountId, path); return new ObjectResult(new ApiError { Code = "BROWSE_FAILED", Message = "Failed to browse files", Status = 500 }) { StatusCode = 500 }; } } /// /// Extracts unique child folder paths from all file indexes for a given parent path /// /// All file indexes for the account /// The parent path to find children for /// List of unique child folder names private List ExtractChildFolders(List allFileIndexes, string parentPath) { var normalizedParentPath = FileIndexService.NormalizePath(parentPath); var childFolders = new HashSet(); foreach (var index in allFileIndexes) { var normalizedIndexPath = FileIndexService.NormalizePath(index.Path); // Check if this path is a direct child of the parent path if (normalizedIndexPath.StartsWith(normalizedParentPath) && normalizedIndexPath != normalizedParentPath) { // Remove the parent path prefix to get the relative path var relativePath = normalizedIndexPath.Substring(normalizedParentPath.Length); // Extract the first folder name (direct child) var firstSlashIndex = relativePath.IndexOf('/'); if (firstSlashIndex > 0) { var folderName = relativePath.Substring(0, firstSlashIndex); childFolders.Add(folderName); } } } return childFolders.OrderBy(f => f).ToList(); } /// /// Gets all files for the current user (across all paths) /// /// List of all files for the user [HttpGet("all")] public async Task GetAllFiles() { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); try { var fileIndexes = await fileIndexService.GetByAccountIdAsync(accountId); return Ok(new { Files = fileIndexes, TotalCount = fileIndexes.Count() }); } catch (Exception ex) { logger.LogError(ex, "Failed to get all files for account {AccountId}", accountId); return new ObjectResult(new ApiError { Code = "GET_ALL_FAILED", Message = "Failed to get files", Status = 500 }) { StatusCode = 500 }; } } /// /// Gets files that have not been indexed for the current user. /// /// The number of files to skip /// The number of files to return /// List of unindexed files [HttpGet("unindexed")] public async Task GetUnindexedFiles([FromQuery] int offset = 0, [FromQuery] int take = 20) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); try { var query = db.Files .Where(f => f.AccountId == accountId && !f.IsMarkedRecycle && !db.FileIndexes.Any(fi => fi.FileId == f.Id && fi.AccountId == accountId)) .OrderByDescending(f => f.CreatedAt); var totalCount = await query.CountAsync(); Response.Headers.Append("X-Total", totalCount.ToString()); var unindexedFiles = await query .Skip(offset) .Take(take) .ToListAsync(); return Ok(unindexedFiles); } catch (Exception ex) { logger.LogError(ex, "Failed to get unindexed files for account {AccountId}", accountId); return new ObjectResult(new ApiError { Code = "GET_UNINDEXED_FAILED", Message = "Failed to get unindexed files", Status = 500 }) { StatusCode = 500 }; } } /// /// Moves a file to a new path /// /// The file index ID /// The new path /// The updated file index [HttpPost("move/{indexId}")] public async Task MoveFile(Guid indexId, [FromBody] MoveFileRequest request) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); try { // Verify ownership var existingIndex = await db.FileIndexes .Include(fi => fi.File) .FirstOrDefaultAsync(fi => fi.Id == indexId && fi.AccountId == accountId); if (existingIndex == null) return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 }; var updatedIndex = await fileIndexService.UpdateAsync(indexId, request.NewPath); if (updatedIndex == null) return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 }; return Ok(new { updatedIndex.FileId, IndexId = updatedIndex.Id, OldPath = existingIndex.Path, NewPath = updatedIndex.Path, Message = "File moved successfully" }); } catch (Exception ex) { logger.LogError(ex, "Failed to move file index {IndexId} for account {AccountId}", indexId, accountId); return new ObjectResult(new ApiError { Code = "MOVE_FAILED", Message = "Failed to move file", Status = 500 }) { StatusCode = 500 }; } } /// /// Removes a file index (does not delete the actual file by default) /// /// The file index ID /// Whether to also delete the actual file data /// Success message [HttpDelete("remove/{indexId}")] public async Task RemoveFileIndex(Guid indexId, [FromQuery] bool deleteFile = false) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); try { // Verify ownership var existingIndex = await db.FileIndexes .Include(fi => fi.File) .FirstOrDefaultAsync(fi => fi.Id == indexId && fi.AccountId == accountId); if (existingIndex == null) return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 }; var fileId = existingIndex.FileId; var fileName = existingIndex.File.Name; var filePath = existingIndex.Path; // Remove the index var removed = await fileIndexService.RemoveAsync(indexId); if (!removed) return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 }; // Optionally delete the actual file if (!deleteFile) return Ok(new { Message = deleteFile ? "File index and file data removed successfully" : "File index removed successfully", FileId = fileId, FileName = fileName, Path = filePath, FileDataDeleted = deleteFile }); try { // Check if there are any other indexes for this file var remainingIndexes = await fileIndexService.GetByFileIdAsync(fileId); if (remainingIndexes.Count == 0) { // No other indexes exist, safe to delete the file var file = await db.Files.FirstOrDefaultAsync(f => f.Id == fileId.ToString()); if (file != null) { db.Files.Remove(file); await db.SaveChangesAsync(); logger.LogInformation("Deleted file {FileId} ({FileName}) as requested", fileId, fileName); } } } catch (Exception ex) { logger.LogWarning(ex, "Failed to delete file {FileId} while removing index", fileId); // Continue even if file deletion fails } return Ok(new { Message = deleteFile ? "File index and file data removed successfully" : "File index removed successfully", FileId = fileId, FileName = fileName, Path = filePath, FileDataDeleted = deleteFile }); } catch (Exception ex) { logger.LogError(ex, "Failed to remove file index {IndexId} for account {AccountId}", indexId, accountId); return new ObjectResult(new ApiError { Code = "REMOVE_FAILED", Message = "Failed to remove file", Status = 500 }) { StatusCode = 500 }; } } /// /// Removes all file indexes in a specific path /// /// The path to clear /// Whether to also delete the actual file data /// Success message with count of removed items [HttpDelete("clear-path")] public async Task ClearPath([FromQuery] string path = "/", [FromQuery] bool deleteFiles = false) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); try { var removedCount = await fileIndexService.RemoveByPathAsync(accountId, path); if (!deleteFiles || removedCount <= 0) return Ok(new { Message = deleteFiles ? $"Cleared {removedCount} file indexes from path and deleted orphaned files" : $"Cleared {removedCount} file indexes from path", Path = path, RemovedCount = removedCount, FilesDeleted = deleteFiles }); // Get the files that were in this path and check if they have other indexes var filesInPath = await fileIndexService.GetByPathAsync(accountId, path); var fileIdsToCheck = filesInPath.Select(fi => fi.FileId).Distinct().ToList(); foreach (var fileId in fileIdsToCheck) { var remainingIndexes = await fileIndexService.GetByFileIdAsync(fileId); if (remainingIndexes.Count != 0) continue; // No other indexes exist, safe to delete the file var file = await db.Files.FirstOrDefaultAsync(f => f.Id == fileId.ToString()); if (file == null) continue; db.Files.Remove(file); logger.LogInformation("Deleted orphaned file {FileId} after clearing path {Path}", fileId, path); } await db.SaveChangesAsync(); return Ok(new { Message = deleteFiles ? $"Cleared {removedCount} file indexes from path and deleted orphaned files" : $"Cleared {removedCount} file indexes from path", Path = path, RemovedCount = removedCount, FilesDeleted = deleteFiles }); } catch (Exception ex) { logger.LogError(ex, "Failed to clear path {Path} for account {AccountId}", path, accountId); return new ObjectResult(new ApiError { Code = "CLEAR_PATH_FAILED", Message = "Failed to clear path", Status = 500 }) { StatusCode = 500 }; } } /// /// Creates a new file index (useful for adding existing files to a path) /// /// The create index request /// The created file index [HttpPost("create")] public async Task CreateFileIndex([FromBody] CreateFileIndexRequest request) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); try { // Verify the file exists and belongs to the user var file = await db.Files.FirstOrDefaultAsync(f => f.Id == request.FileId); if (file == null) return new ObjectResult(ApiError.NotFound("File")) { StatusCode = 404 }; if (file.AccountId != accountId) return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 }; // Check if index already exists for this file and path var existingIndex = await db.FileIndexes .FirstOrDefaultAsync(fi => fi.FileId == request.FileId && fi.Path == request.Path && fi.AccountId == accountId); if (existingIndex != null) return new ObjectResult(ApiError.Validation(new Dictionary { { "fileId", ["File index already exists for this path"] } })) { StatusCode = 400 }; var fileIndex = await fileIndexService.CreateAsync(request.Path, request.FileId, accountId); return Ok(new { IndexId = fileIndex.Id, fileIndex.FileId, fileIndex.Path, Message = "File index created successfully" }); } catch (Exception ex) { logger.LogError(ex, "Failed to create file index for file {FileId} at path {Path} for account {AccountId}", request.FileId, request.Path, accountId); return new ObjectResult(new ApiError { Code = "CREATE_INDEX_FAILED", Message = "Failed to create file index", Status = 500 }) { StatusCode = 500 }; } } /// /// Searches for files by name or metadata /// /// The search query /// Optional path to limit search to /// Matching files [HttpGet("search")] public async Task SearchFiles([FromQuery] string query, [FromQuery] string? path = null) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); try { // Build the query with all conditions at once var searchTerm = query.ToLower(); var fileIndexes = await db.FileIndexes .Where(fi => fi.AccountId == accountId) .Include(fi => fi.File) .Where(fi => (string.IsNullOrEmpty(path) || fi.Path == FileIndexService.NormalizePath(path)) && (fi.File.Name.ToLower().Contains(searchTerm) || (fi.File.Description != null && fi.File.Description.ToLower().Contains(searchTerm)) || (fi.File.MimeType != null && fi.File.MimeType.ToLower().Contains(searchTerm)))) .ToListAsync(); return Ok(new { Query = query, Path = path, Results = fileIndexes, TotalCount = fileIndexes.Count() }); } catch (Exception ex) { logger.LogError(ex, "Failed to search files for account {AccountId} with query {Query}", accountId, query); return new ObjectResult(new ApiError { Code = "SEARCH_FAILED", Message = "Failed to search files", Status = 500 }) { StatusCode = 500 }; } } } public class MoveFileRequest { public string NewPath { get; set; } = null!; } public class CreateFileIndexRequest { [MaxLength(32)] public string FileId { get; set; } = null!; public string Path { get; set; } = null!; }