← Back to Files
FileService.cs
using KaiChat.Models;
namespace KaiChat.Services;
public class FileService : IFileService
{
private readonly string _baseFolder;
private readonly ILogger<FileService> _logger;
public FileService(IConfiguration config, ILogger<FileService> logger)
{
var baseFolder = config.GetValue<string>("KaiChat:BaseFolder")
?? throw new InvalidOperationException("KaiChat:BaseFolder must be set");
_baseFolder = Path.GetFullPath(baseFolder);
_logger = logger;
if (!Directory.Exists(_baseFolder))
Directory.CreateDirectory(_baseFolder);
}
public List<FileItem> GetDirectoryContents(string relativePath = "")
{
string fullPath;
try
{
fullPath = GetFullPath(relativePath);
}
catch (Exception ex) when (ex is ArgumentException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Rejected directory listing outside base folder: {Path}", relativePath);
return new();
}
if (!Directory.Exists(fullPath))
return new();
var items = new List<FileItem>();
foreach (var dir in Directory.GetDirectories(fullPath))
{
var dirInfo = new DirectoryInfo(dir);
items.Add(new FileItem
{
Name = dirInfo.Name,
Path = dirInfo.FullName,
RelativePath = Path.GetRelativePath(_baseFolder, dirInfo.FullName),
IsDirectory = true,
LastModified = dirInfo.LastWriteTime
});
}
foreach (var file in Directory.GetFiles(fullPath))
{
var fileInfo = new FileInfo(file);
items.Add(new FileItem
{
Name = fileInfo.Name,
Path = fileInfo.FullName,
RelativePath = Path.GetRelativePath(_baseFolder, fileInfo.FullName),
IsDirectory = false,
SizeBytes = fileInfo.Length,
SizeDisplay = FormatSize(fileInfo.Length),
LastModified = fileInfo.LastWriteTime
});
}
return items.OrderByDescending(i => i.IsDirectory).ThenBy(i => i.Name).ToList();
}
public string ReadFile(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
return "(invalid path)";
string fullPath;
try
{
fullPath = GetFullPath(relativePath);
}
catch (Exception ex) when (ex is ArgumentException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Rejected read outside base folder: {Path}", relativePath);
return "(invalid path)";
}
if (!System.IO.File.Exists(fullPath))
return "(file not found)";
return System.IO.File.ReadAllText(fullPath);
}
public async Task WriteFileAsync(string relativePath, string content)
{
var fullPath = GetFullPath(relativePath);
var dir = Path.GetDirectoryName(fullPath);
if (dir != null && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
await System.IO.File.WriteAllTextAsync(fullPath, content);
_logger.LogInformation("Written file: {Path}", relativePath);
}
public Task DeleteFileAsync(string relativePath)
{
var fullPath = GetFullPath(relativePath);
if (System.IO.File.Exists(fullPath))
{
System.IO.File.Delete(fullPath);
_logger.LogInformation("Deleted file: {Path}", relativePath);
}
else
{
throw new FileNotFoundException($"File not found: {relativePath}");
}
return Task.CompletedTask;
}
public Task RenameFileAsync(string sourcePath, string destPath)
{
var fullSource = GetFullPath(sourcePath);
var fullDest = GetFullPath(destPath);
var dir = Path.GetDirectoryName(fullDest);
if (dir != null && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
if (System.IO.File.Exists(fullSource))
{
System.IO.File.Move(fullSource, fullDest);
_logger.LogInformation("Renamed file: {Source} -> {Dest}", sourcePath, destPath);
}
else
{
throw new FileNotFoundException($"File not found: {sourcePath}");
}
return Task.CompletedTask;
}
public string? GetRelativePath(string fullPath)
{
var resolvedPath = Path.GetFullPath(fullPath);
if (IsUnderBaseFolder(resolvedPath))
return Path.GetRelativePath(_baseFolder, resolvedPath);
return null;
}
public string Combine(string relativePath, string name)
{
return string.IsNullOrEmpty(relativePath) ? name : $"{relativePath}/{name}".TrimStart('/');
}
private string GetFullPath(string relativePath)
{
if (Path.IsPathRooted(relativePath))
throw new UnauthorizedAccessException("Absolute paths are not allowed.");
var cleaned = relativePath.Replace('/', Path.DirectorySeparatorChar);
var fullPath = Path.GetFullPath(Path.Combine(_baseFolder, cleaned));
if (!IsUnderBaseFolder(fullPath))
throw new UnauthorizedAccessException("Path escapes the configured base folder.");
return fullPath;
}
private bool IsUnderBaseFolder(string fullPath)
{
var relative = Path.GetRelativePath(_baseFolder, fullPath);
return relative == "."
|| (!Path.IsPathRooted(relative)
&& relative != ".."
&& !relative.StartsWith(".." + Path.DirectorySeparatorChar)
&& !relative.StartsWith("../"));
}
private static string FormatSize(long bytes) => bytes switch
{
< 1024 => $"{bytes} B",
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
< 1024 * 1024 * 1024 => $"{bytes / (1024.0 * 1024):F1} MB",
_ => $"{bytes / (1024.0 * 1024 * 1024):F1} GB"
};
}