Size: 6.0 KB Modified: 27/06/2026 1:06 PM
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"
    };
}
Offline