✨ File index
This commit is contained in:
411
DysonNetwork.Drive/Index/FileIndexController.cs
Normal file
411
DysonNetwork.Drive/Index/FileIndexController.cs
Normal file
@@ -0,0 +1,411 @@
|
||||
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<FileIndexController> logger
|
||||
) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets files in a specific path for the current user
|
||||
/// </summary>
|
||||
/// <param name="path">The path to browse (defaults to root "/")</param>
|
||||
/// <returns>List of files in the specified path</returns>
|
||||
[HttpGet("browse")]
|
||||
public async Task<IActionResult> 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);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Path = path,
|
||||
Files = fileIndexes,
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all files for the current user (across all paths)
|
||||
/// </summary>
|
||||
/// <returns>List of all files for the user</returns>
|
||||
[HttpGet("all")]
|
||||
public async Task<IActionResult> 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 };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves a file to a new path
|
||||
/// </summary>
|
||||
/// <param name="indexId">The file index ID</param>
|
||||
/// <param name="newPath">The new path</param>
|
||||
/// <returns>The updated file index</returns>
|
||||
[HttpPost("move/{indexId}")]
|
||||
public async Task<IActionResult> 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 };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a file index (does not delete the actual file by default)
|
||||
/// </summary>
|
||||
/// <param name="indexId">The file index ID</param>
|
||||
/// <param name="deleteFile">Whether to also delete the actual file data</param>
|
||||
/// <returns>Success message</returns>
|
||||
[HttpDelete("remove/{indexId}")]
|
||||
public async Task<IActionResult> 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 };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all file indexes in a specific path
|
||||
/// </summary>
|
||||
/// <param name="path">The path to clear</param>
|
||||
/// <param name="deleteFiles">Whether to also delete the actual file data</param>
|
||||
/// <returns>Success message with count of removed items</returns>
|
||||
[HttpDelete("clear-path")]
|
||||
public async Task<IActionResult> 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 };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new file index (useful for adding existing files to a path)
|
||||
/// </summary>
|
||||
/// <param name="request">The create index request</param>
|
||||
/// <returns>The created file index</returns>
|
||||
[HttpPost("create")]
|
||||
public async Task<IActionResult> 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<string, string[]>
|
||||
{
|
||||
{ "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 };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for files by name or metadata
|
||||
/// </summary>
|
||||
/// <param name="query">The search query</param>
|
||||
/// <param name="path">Optional path to limit search to</param>
|
||||
/// <returns>Matching files</returns>
|
||||
[HttpGet("search")]
|
||||
public async Task<IActionResult> 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!;
|
||||
}
|
||||
187
DysonNetwork.Drive/Index/FileIndexService.cs
Normal file
187
DysonNetwork.Drive/Index/FileIndexService.cs
Normal file
@@ -0,0 +1,187 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Drive.Index;
|
||||
|
||||
public class FileIndexService(AppDatabase db)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new file index entry
|
||||
/// </summary>
|
||||
/// <param name="path">The parent folder path with trailing slash</param>
|
||||
/// <param name="fileId">The file ID</param>
|
||||
/// <param name="accountId">The account ID</param>
|
||||
/// <returns>The created file index</returns>
|
||||
public async Task<SnCloudFileIndex> CreateAsync(string path, string fileId, Guid accountId)
|
||||
{
|
||||
// Ensure a path has trailing slash and is query-safe
|
||||
var normalizedPath = NormalizePath(path);
|
||||
|
||||
var fileIndex = new SnCloudFileIndex
|
||||
{
|
||||
Path = normalizedPath,
|
||||
FileId = fileId,
|
||||
AccountId = accountId
|
||||
};
|
||||
|
||||
db.FileIndexes.Add(fileIndex);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return fileIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing file index entry by removing the old one and creating a new one
|
||||
/// </summary>
|
||||
/// <param name="id">The file index ID</param>
|
||||
/// <param name="newPath">The new parent folder path with trailing slash</param>
|
||||
/// <returns>The updated file index</returns>
|
||||
public async Task<SnCloudFileIndex?> UpdateAsync(Guid id, string newPath)
|
||||
{
|
||||
var fileIndex = await db.FileIndexes.FindAsync(id);
|
||||
if (fileIndex == null)
|
||||
return null;
|
||||
|
||||
// Since properties are init-only, we need to remove the old index and create a new one
|
||||
db.FileIndexes.Remove(fileIndex);
|
||||
|
||||
var newFileIndex = new SnCloudFileIndex
|
||||
{
|
||||
Path = NormalizePath(newPath),
|
||||
FileId = fileIndex.FileId,
|
||||
AccountId = fileIndex.AccountId
|
||||
};
|
||||
|
||||
db.FileIndexes.Add(newFileIndex);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return newFileIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a file index entry by ID
|
||||
/// </summary>
|
||||
/// <param name="id">The file index ID</param>
|
||||
/// <returns>True if the index was found and removed, false otherwise</returns>
|
||||
public async Task<bool> RemoveAsync(Guid id)
|
||||
{
|
||||
var fileIndex = await db.FileIndexes.FindAsync(id);
|
||||
if (fileIndex == null)
|
||||
return false;
|
||||
|
||||
db.FileIndexes.Remove(fileIndex);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes file index entries by file ID
|
||||
/// </summary>
|
||||
/// <param name="fileId">The file ID</param>
|
||||
/// <returns>The number of indexes removed</returns>
|
||||
public async Task<int> RemoveByFileIdAsync(string fileId)
|
||||
{
|
||||
var indexes = await db.FileIndexes
|
||||
.Where(fi => fi.FileId == fileId)
|
||||
.ToListAsync();
|
||||
|
||||
if (indexes.Count == 0)
|
||||
return 0;
|
||||
|
||||
db.FileIndexes.RemoveRange(indexes);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return indexes.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes file index entries by account ID and path
|
||||
/// </summary>
|
||||
/// <param name="accountId">The account ID</param>
|
||||
/// <param name="path">The parent folder path</param>
|
||||
/// <returns>The number of indexes removed</returns>
|
||||
public async Task<int> RemoveByPathAsync(Guid accountId, string path)
|
||||
{
|
||||
var normalizedPath = NormalizePath(path);
|
||||
|
||||
var indexes = await db.FileIndexes
|
||||
.Where(fi => fi.AccountId == accountId && fi.Path == normalizedPath)
|
||||
.ToListAsync();
|
||||
|
||||
if (!indexes.Any())
|
||||
return 0;
|
||||
|
||||
db.FileIndexes.RemoveRange(indexes);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return indexes.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets file indexes by account ID and path
|
||||
/// </summary>
|
||||
/// <param name="accountId">The account ID</param>
|
||||
/// <param name="path">The parent folder path</param>
|
||||
/// <returns>List of file indexes</returns>
|
||||
public async Task<List<SnCloudFileIndex>> GetByPathAsync(Guid accountId, string path)
|
||||
{
|
||||
var normalizedPath = NormalizePath(path);
|
||||
|
||||
return await db.FileIndexes
|
||||
.Where(fi => fi.AccountId == accountId && fi.Path == normalizedPath)
|
||||
.Include(fi => fi.File)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets file indexes by file ID
|
||||
/// </summary>
|
||||
/// <param name="fileId">The file ID</param>
|
||||
/// <returns>List of file indexes</returns>
|
||||
public async Task<List<SnCloudFileIndex>> GetByFileIdAsync(string fileId)
|
||||
{
|
||||
return await db.FileIndexes
|
||||
.Where(fi => fi.FileId == fileId)
|
||||
.Include(fi => fi.File)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all file indexes for an account
|
||||
/// </summary>
|
||||
/// <param name="accountId">The account ID</param>
|
||||
/// <returns>List of file indexes</returns>
|
||||
public async Task<List<SnCloudFileIndex>> GetByAccountIdAsync(Guid accountId)
|
||||
{
|
||||
return await db.FileIndexes
|
||||
.Where(fi => fi.AccountId == accountId)
|
||||
.Include(fi => fi.File)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes the path to ensure it has a trailing slash and is query-safe
|
||||
/// </summary>
|
||||
/// <param name="path">The original path</param>
|
||||
/// <returns>The normalized path</returns>
|
||||
public static string NormalizePath(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return "/";
|
||||
|
||||
// Ensure the path starts with a slash
|
||||
if (!path.StartsWith('/'))
|
||||
path = "/" + path;
|
||||
|
||||
// Ensure the path ends with a slash (unless it's just the root)
|
||||
if (path != "/" && !path.EndsWith('/'))
|
||||
path += "/";
|
||||
|
||||
// Make path query-safe by removing problematic characters
|
||||
// This is a basic implementation - you might want to add more robust validation
|
||||
path = path.Replace("%", "").Replace("'", "").Replace("\"", "");
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
341
DysonNetwork.Drive/Index/README.md
Normal file
341
DysonNetwork.Drive/Index/README.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# File Indexing System Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The File Indexing System provides a hierarchical file organization layer on top of the existing file storage system in DysonNetwork Drive. It allows users to organize their files in folders and paths while maintaining the underlying file storage capabilities.
|
||||
|
||||
When using with the gateway, replace the `/api` with the `/drive` in the path.
|
||||
And all the arguments will be transformed into snake case via the gateway.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **SnCloudFileIndex Model** - Represents the file-to-path mapping
|
||||
2. **FileIndexService** - Business logic for file index operations
|
||||
3. **FileIndexController** - REST API endpoints for file management
|
||||
4. **FileUploadController Integration** - Automatic index creation during upload
|
||||
|
||||
### Database Schema
|
||||
|
||||
```sql
|
||||
-- File Indexes table
|
||||
CREATE TABLE "FileIndexes" (
|
||||
"Id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"Path" character varying(8192) NOT NULL,
|
||||
"FileId" uuid NOT NULL,
|
||||
"AccountId" uuid NOT NULL,
|
||||
"CreatedAt" timestamp with time zone NOT NULL DEFAULT (now() at time zone 'utc'),
|
||||
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT (now() at time zone 'utc'),
|
||||
CONSTRAINT "PK_FileIndexes" PRIMARY KEY ("Id"),
|
||||
CONSTRAINT "FK_FileIndexes_Files_FileId" FOREIGN KEY ("FileId") REFERENCES "Files" ("Id") ON DELETE CASCADE,
|
||||
INDEX "IX_FileIndexes_Path_AccountId" ("Path", "AccountId")
|
||||
);
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Browse Files
|
||||
**GET** `/api/index/browse?path=/documents/`
|
||||
|
||||
Browse files in a specific path.
|
||||
|
||||
**Query Parameters:**
|
||||
- `path` (optional, default: "/") - The path to browse
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"path": "/documents/",
|
||||
"files": [
|
||||
{
|
||||
"id": "guid",
|
||||
"path": "/documents/",
|
||||
"fileId": "guid",
|
||||
"accountId": "guid",
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
"file": {
|
||||
"id": "string",
|
||||
"name": "document.pdf",
|
||||
"size": 1024,
|
||||
"mimeType": "application/pdf",
|
||||
"hash": "sha256-hash",
|
||||
"uploadedAt": "2024-01-01T00:00:00Z",
|
||||
"expiredAt": null,
|
||||
"hasCompression": false,
|
||||
"hasThumbnail": true,
|
||||
"isEncrypted": false,
|
||||
"description": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"totalCount": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Get All Files
|
||||
**GET** `/api/index/all`
|
||||
|
||||
Get all files for the current user across all paths.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"files": [
|
||||
// Same structure as browse endpoint
|
||||
],
|
||||
"totalCount": 10
|
||||
}
|
||||
```
|
||||
|
||||
### Move File
|
||||
**POST** `/api/index/move/{indexId}`
|
||||
|
||||
Move a file to a new path.
|
||||
|
||||
**Path Parameters:**
|
||||
- `indexId` - The file index ID
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"newPath": "/archived/"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"fileId": "guid",
|
||||
"indexId": "guid",
|
||||
"oldPath": "/documents/",
|
||||
"newPath": "/archived/",
|
||||
"message": "File moved successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### Remove File Index
|
||||
**DELETE** `/api/index/remove/{indexId}?deleteFile=false`
|
||||
|
||||
Remove a file index. Optionally delete the actual file data.
|
||||
|
||||
**Path Parameters:**
|
||||
- `indexId` - The file index ID
|
||||
|
||||
**Query Parameters:**
|
||||
- `deleteFile` (optional, default: false) - Whether to also delete the file data
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "File index removed successfully",
|
||||
"fileId": "guid",
|
||||
"fileName": "document.pdf",
|
||||
"path": "/documents/",
|
||||
"fileDataDeleted": false
|
||||
}
|
||||
```
|
||||
|
||||
### Clear Path
|
||||
**DELETE** `/api/index/clear-path?path=/temp/&deleteFiles=false`
|
||||
|
||||
Remove all file indexes in a specific path.
|
||||
|
||||
**Query Parameters:**
|
||||
- `path` (optional, default: "/") - The path to clear
|
||||
- `deleteFiles` (optional, default: false) - Whether to also delete orphaned files
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Cleared 5 file indexes from path",
|
||||
"path": "/temp/",
|
||||
"removedCount": 5,
|
||||
"filesDeleted": false
|
||||
}
|
||||
```
|
||||
|
||||
### Create File Index
|
||||
**POST** `/api/index/create`
|
||||
|
||||
Create a new file index for an existing file.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"fileId": "guid",
|
||||
"path": "/documents/"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"indexId": "guid",
|
||||
"fileId": "guid",
|
||||
"path": "/documents/",
|
||||
"message": "File index created successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### Search Files
|
||||
**GET** `/api/index/search?query=report&path=/documents/`
|
||||
|
||||
Search for files by name or metadata.
|
||||
|
||||
**Query Parameters:**
|
||||
- `query` (required) - The search query
|
||||
- `path` (optional) - Limit search to specific path
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"query": "report",
|
||||
"path": "/documents/",
|
||||
"results": [
|
||||
// Same structure as browse endpoint
|
||||
],
|
||||
"totalCount": 3
|
||||
}
|
||||
```
|
||||
|
||||
## Path Normalization
|
||||
|
||||
The system automatically normalizes paths to ensure consistency:
|
||||
|
||||
- **Trailing Slash**: All paths end with `/`
|
||||
- **Root Path**: User home folder is represented as `/`
|
||||
- **Query Safety**: Paths are validated to avoid SQL injection
|
||||
- **Examples**:
|
||||
- `/documents/` ✅ (correct)
|
||||
- `/documents` → `/documents/` ✅ (normalized)
|
||||
- `/documents/reports/` ✅ (correct)
|
||||
- `/documents/reports` → `/documents/reports/` ✅ (normalized)
|
||||
|
||||
## File Upload Integration
|
||||
|
||||
When uploading files with the `FileUploadController`, you can specify a path to automatically create file indexes:
|
||||
|
||||
**Create Upload Task Request:**
|
||||
```json
|
||||
{
|
||||
"fileName": "document.pdf",
|
||||
"fileSize": 1024,
|
||||
"contentType": "application/pdf",
|
||||
"hash": "sha256-hash",
|
||||
"path": "/documents/" // New field for file indexing
|
||||
}
|
||||
```
|
||||
|
||||
The system will automatically create a file index when the upload completes successfully.
|
||||
|
||||
## Service Methods
|
||||
|
||||
### FileIndexService
|
||||
|
||||
```csharp
|
||||
public class FileIndexService
|
||||
{
|
||||
// Create a new file index
|
||||
Task<SnCloudFileIndex> CreateAsync(string path, Guid fileId, Guid accountId);
|
||||
|
||||
// Get files by path
|
||||
Task<List<SnCloudFileIndex>> GetByPathAsync(Guid accountId, string path);
|
||||
|
||||
// Get all files for account
|
||||
Task<List<SnCloudFileIndex>> GetByAccountIdAsync(Guid accountId);
|
||||
|
||||
// Get indexes for specific file
|
||||
Task<List<SnCloudFileIndex>> GetByFileIdAsync(Guid fileId);
|
||||
|
||||
// Move file to new path
|
||||
Task<SnCloudFileIndex?> UpdateAsync(Guid indexId, string newPath);
|
||||
|
||||
// Remove file index
|
||||
Task<bool> RemoveAsync(Guid indexId);
|
||||
|
||||
// Remove all indexes in path
|
||||
Task<int> RemoveByPathAsync(Guid accountId, string path);
|
||||
|
||||
// Normalize path format
|
||||
public static string NormalizePath(string path);
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The API returns appropriate HTTP status codes and error messages:
|
||||
|
||||
- **400 Bad Request**: Invalid input parameters
|
||||
- **401 Unauthorized**: User not authenticated
|
||||
- **403 Forbidden**: User lacks permission
|
||||
- **404 Not Found**: Resource not found
|
||||
- **500 Internal Server Error**: Server-side error
|
||||
|
||||
**Error Response Format:**
|
||||
```json
|
||||
{
|
||||
"code": "BROWSE_FAILED",
|
||||
"message": "Failed to browse files",
|
||||
"status": 500
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Ownership Verification**: All operations verify that the user owns the file indexes
|
||||
2. **Path Validation**: Paths are normalized and validated
|
||||
3. **Cascade Deletion**: File indexes are automatically removed when files are deleted
|
||||
4. **Safe File Deletion**: Files are only deleted when no other indexes reference them
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Upload File to Specific Path
|
||||
```bash
|
||||
# Create upload task with path
|
||||
curl -X POST /api/files/upload/create \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"fileName": "report.pdf",
|
||||
"fileSize": 2048,
|
||||
"contentType": "application/pdf",
|
||||
"path": "/documents/reports/"
|
||||
}'
|
||||
```
|
||||
|
||||
### Browse Files
|
||||
```bash
|
||||
curl -X GET "/api/index/browse?path=/documents/reports/" \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
### Move File
|
||||
```bash
|
||||
curl -X POST "/api/index/move/{indexId}" \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"newPath": "/archived/"}'
|
||||
```
|
||||
|
||||
### Search Files
|
||||
```bash
|
||||
curl -X GET "/api/index/search?query=invoice&path=/documents/" \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Trailing Slashes**: Always include trailing slashes in paths
|
||||
2. **Organize Hierarchically**: Use meaningful folder structures
|
||||
3. **Search Efficiently**: Use the search endpoint instead of client-side filtering
|
||||
4. **Clean Up**: Use the clear-path endpoint for temporary directories
|
||||
5. **Monitor Usage**: Check total file counts for quota management
|
||||
|
||||
## Integration Notes
|
||||
|
||||
- The file indexing system works alongside the existing file storage
|
||||
- Files can exist in multiple paths (hard links)
|
||||
- File deletion is optional and only removes data when safe
|
||||
- The system maintains referential integrity between files and indexes
|
||||
Reference in New Issue
Block a user