using System.ComponentModel.DataAnnotations; 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, FolderService folderService, 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 child folders using the folder system var childFolders = await GetChildFoldersAsync(accountId, 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 }; } } /// /// Gets child folders for a given parent path using the folder system /// /// The account ID /// The parent path to find children for /// List of child folder objects private async Task> GetChildFoldersAsync(Guid accountId, string parentPath) { var normalizedParentPath = FileIndexService.NormalizePath(parentPath); // Try to find a folder that corresponds to this path var parentFolder = await FindFolderByPathAsync(accountId, normalizedParentPath); if (parentFolder != null) { // Use folder-based approach return await folderService.GetChildFoldersAsync(parentFolder.Id, accountId); } else { // Fall back to path-based approach - find folders that start with this path var allFolders = await folderService.GetByAccountIdAsync(accountId); var childFolders = new List(); foreach (var folder in allFolders) { // For path-based folders, we need to check if they belong under this path // This is a simplified approach - in a full implementation, folders would have path information if (folder.ParentFolderId == null && normalizedParentPath == "/") { // Root level folders childFolders.Add(folder); } // For nested folders, we'd need path information in the folder model } return childFolders.OrderBy(f => f.Name).ToList(); } } /// /// Attempts to find a folder by its path /// /// The account ID /// The path to search for /// The folder if found, null otherwise private async Task FindFolderByPathAsync(Guid accountId, string path) { // This is a simplified implementation // In a full implementation, folders would have path information stored var allFolders = await folderService.GetByAccountIdAsync(accountId); // For now, just return null to use path-based approach // TODO: Implement proper path-to-folder mapping return null; } /// /// Gets or creates a folder hierarchy based on a file path /// /// The file path (e.g., "/folder/sub/file.txt") /// The account ID /// The folder where the file should be placed private async Task GetOrCreateFolderByPathAsync(string filePath, Guid accountId) { // Extract folder path from file path (remove filename) var lastSlashIndex = filePath.LastIndexOf('/'); var folderPath = lastSlashIndex == 0 ? "/" : filePath[..(lastSlashIndex + 1)]; // Ensure root folder exists var rootFolder = await folderService.EnsureRootFolderAsync(accountId); // If it's the root folder, return it if (folderPath == "/") return rootFolder; // Split the folder path into segments var pathSegments = folderPath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); var currentParent = rootFolder; var currentPath = "/"; // Create folder hierarchy foreach (var segment in pathSegments) { currentPath += segment + "/"; // Check if folder already exists var existingFolder = await db.Folders .FirstOrDefaultAsync(f => f.AccountId == accountId && f.Path == currentPath); if (existingFolder != null) { currentParent = existingFolder; continue; } // Create new folder var newFolder = await folderService.CreateAsync(segment, accountId, currentParent.Id); currentParent = newFolder; } return currentParent; } /// /// 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 }; } } /// /// 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 - since Path is now optional, we need to check by folder // For now, we'll check if any index exists for this file in the same folder that would result from the path var targetFolder = await GetOrCreateFolderByPathAsync(FileIndexService.NormalizePath(request.Path), accountId); var existingIndex = await db.FileIndexes .FirstOrDefaultAsync(fi => fi.FileId == request.FileId && fi.FolderId == targetFolder.Id && 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, Path = 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 }; } } /// /// Gets unindexed files for the current user (files that exist but don't have file indexes) /// /// Pagination offset /// Number of files to take /// 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 { // Get files that belong to the user but don't have any file indexes var unindexedFiles = await db.Files .Where(f => f.AccountId == accountId) .Where(f => !db.FileIndexes.Any(fi => fi.FileId == f.Id && fi.AccountId == accountId)) .OrderByDescending(f => f.CreatedAt) .Skip(offset) .Take(take) .ToListAsync(); var totalCount = await db.Files .Where(f => f.AccountId == accountId) .Where(f => !db.FileIndexes.Any(fi => fi.FileId == f.Id && fi.AccountId == accountId)) .CountAsync(); return Ok(new { Files = unindexedFiles, TotalCount = totalCount, Offset = offset, Take = take }); } 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 }; } } /// /// 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 baseQuery = db.FileIndexes .Where(fi => fi.AccountId == accountId) .Include(fi => fi.File); IQueryable queryable; // If a path is specified, find the folder and filter by folder ID if (!string.IsNullOrEmpty(path)) { var normalizedPath = FileIndexService.NormalizePath(path); var targetFolder = await GetOrCreateFolderByPathAsync(normalizedPath, accountId); queryable = baseQuery.Where(fi => fi.FolderId == targetFolder.Id); } else { queryable = baseQuery; } var fileIndexes = await queryable .Where(fi => 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 }; } } /// /// Creates a new folder /// /// The folder creation request /// The created folder [HttpPost("folders")] public async Task CreateFolder([FromBody] CreateFolderRequest request) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); try { var folder = await folderService.CreateAsync(request.Name, accountId, request.ParentFolderId); return Ok(folder); } catch (ArgumentException ex) { return BadRequest(ex.Message); } catch (InvalidOperationException ex) { return Conflict(ex.Message); } } /// /// Gets a folder by ID with its contents /// /// The folder ID /// The folder with child folders and files [HttpGet("folders/{folderId:guid}")] public async Task GetFolderById(Guid folderId) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); var folder = await folderService.GetByIdAsync(folderId, accountId); if (folder == null) { return NotFound($"Folder with ID '{folderId}' not found"); } return Ok(folder); } /// /// Gets all folders for the current account /// /// List of folders [HttpGet("folders")] public async Task GetAllFolders() { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); var folders = await folderService.GetByAccountIdAsync(accountId); return Ok(folders); } /// /// Gets child folders of a parent folder /// /// The parent folder ID /// List of child folders [HttpGet("folders/children/{parentFolderId:guid}")] public async Task GetChildFolders(Guid parentFolderId) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); var folders = await folderService.GetChildFoldersAsync(parentFolderId, accountId); return Ok(folders); } /// /// Updates a folder's name /// /// The folder ID /// The update request /// The updated folder [HttpPut("folders/{folderId:guid}")] public async Task UpdateFolder(Guid folderId, [FromBody] UpdateFolderRequest request) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); try { var folder = await folderService.UpdateAsync(folderId, request.Name, accountId); if (folder == null) { return NotFound($"Folder with ID '{folderId}' not found"); } return Ok(folder); } catch (ArgumentException ex) { return BadRequest(ex.Message); } catch (InvalidOperationException ex) { return Conflict(ex.Message); } } /// /// Deletes a folder and all its contents /// /// The folder ID /// Success status [HttpDelete("folders/{folderId:guid}")] public async Task DeleteFolder(Guid folderId) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); var deleted = await folderService.DeleteAsync(folderId, accountId); if (!deleted) { return NotFound($"Folder with ID '{folderId}' not found"); } return NoContent(); } /// /// Moves a folder to a new parent folder /// /// The folder ID /// The move request /// The moved folder [HttpPost("folders/{folderId:guid}/move")] public async Task MoveFolder(Guid folderId, [FromBody] MoveFolderRequest request) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); try { var folder = await folderService.MoveAsync(folderId, request.NewParentFolderId, accountId); if (folder == null) { return NotFound($"Folder with ID '{folderId}' not found"); } return Ok(folder); } catch (InvalidOperationException ex) { return Conflict(ex.Message); } } /// /// Searches for folders by name /// /// The search term /// List of matching folders [HttpGet("folders/search")] public async Task SearchFolders([FromQuery] string searchTerm) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); if (string.IsNullOrWhiteSpace(searchTerm)) { return BadRequest("Search term cannot be empty"); } var folders = await folderService.SearchAsync(accountId, searchTerm); return Ok(folders); } /// /// Gets files in a specific folder /// /// The folder ID /// List of files in the folder [HttpGet("folders/{folderId:guid}/files")] public async Task GetFilesInFolder(Guid folderId) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); var files = await fileIndexService.GetByFolderAsync(accountId, folderId); return Ok(files); } /// /// Moves a file to a different folder /// /// The file index ID /// The move request /// The updated file index [HttpPost("files/{fileIndexId:guid}/move-to-folder")] public async Task MoveFileToFolder(Guid fileIndexId, [FromBody] MoveFileToFolderRequest request) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; var accountId = Guid.Parse(currentUser.Id); try { var fileIndex = await fileIndexService.MoveAsync(fileIndexId, request.NewFolderId, accountId); if (fileIndex == null) { return NotFound($"File index with ID '{fileIndexId}' not found"); } return Ok(fileIndex); } catch (InvalidOperationException ex) { return Conflict(ex.Message); } } } 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!; } /// /// Request model for creating a folder /// public class CreateFolderRequest { /// /// The name of the folder /// public string Name { get; set; } = null!; /// /// Optional parent folder ID (null for root folder) /// public Guid? ParentFolderId { get; set; } } /// /// Request model for updating a folder /// public class UpdateFolderRequest { /// /// The new name for the folder /// public string Name { get; set; } = null!; } /// /// Request model for moving a folder /// public class MoveFolderRequest { /// /// The new parent folder ID (null for root) /// public Guid? NewParentFolderId { get; set; } } /// /// Request model for moving a file to a folder /// public class MoveFileToFolderRequest { /// /// The new folder ID /// public Guid NewFolderId { get; set; } }