diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/FilesController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/FilesController.cs new file mode 100644 index 00000000..9724e844 --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/FilesController.cs @@ -0,0 +1,413 @@ +using System.Text; +using ICSharpCode.SharpZipLib.GZip; +using ICSharpCode.SharpZipLib.Tar; +using ICSharpCode.SharpZipLib.Zip; +using Microsoft.AspNetCore.Mvc; +using MoonCore.Extended.PermFilter; +using MoonCore.Helpers; +using Moonlight.Shared.Http.Requests.Admin.Sys.Files; +using Moonlight.Shared.Http.Responses.Admin.Sys.Files; + +namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys; + +[ApiController] +[Route("api/admin/system/files")] +[RequirePermission("admin.system.files")] +public class FilesController : Controller +{ + private readonly string BaseDirectory = PathBuilder.Dir("storage"); + + [HttpGet("list")] + public Task List([FromQuery] string path) + { + var safePath = SanitizePath(path); + var physicalPath = PathBuilder.Dir(BaseDirectory, safePath); + + var entries = new List(); + + var files = Directory.GetFiles(physicalPath); + + foreach (var file in files) + { + var fi = new FileInfo(file); + + entries.Add(new FileSystemEntryResponse() + { + Name = fi.Name, + Size = fi.Length, + CreatedAt = fi.CreationTimeUtc, + IsFile = true, + UpdatedAt = fi.LastWriteTimeUtc + }); + } + + var directories = Directory.GetDirectories(physicalPath); + + foreach (var directory in directories) + { + var di = new DirectoryInfo(directory); + + entries.Add(new FileSystemEntryResponse() + { + Name = di.Name, + Size = 0, + CreatedAt = di.CreationTimeUtc, + UpdatedAt = di.LastWriteTimeUtc, + IsFile = false + }); + } + + return Task.FromResult( + entries.ToArray() + ); + } + + [HttpPost("create")] + public async Task Create([FromQuery] string path) + { + var stream = Request.Body; + + var safePath = SanitizePath(path); + var physicalPath = PathBuilder.File(BaseDirectory, safePath); + var baseDir = Path.GetDirectoryName(physicalPath); + + if (!string.IsNullOrEmpty(baseDir)) + Directory.CreateDirectory(baseDir); + + await using var fs = System.IO.File.Create( + physicalPath + ); + + await stream.CopyToAsync(fs); + + await fs.FlushAsync(); + fs.Close(); + stream.Close(); + } + + [HttpPost("move")] + public Task Move([FromQuery] string oldPath, [FromQuery] string newPath) + { + var oldSafePath = SanitizePath(oldPath); + var newSafePath = SanitizePath(newPath); + + var oldPhysicalDirPath = PathBuilder.Dir(BaseDirectory, oldSafePath); + + if (Directory.Exists(oldPhysicalDirPath)) + { + var newPhysicalDirPath = PathBuilder.Dir(BaseDirectory, newSafePath); + + Directory.Move( + oldPhysicalDirPath, + newPhysicalDirPath + ); + } + else + { + var oldPhysicalFilePath = PathBuilder.File(BaseDirectory, oldSafePath); + var newPhysicalFilePath = PathBuilder.File(BaseDirectory, newSafePath); + + System.IO.File.Move( + oldPhysicalFilePath, + newPhysicalFilePath + ); + } + + return Task.CompletedTask; + } + + [HttpDelete("delete")] + public Task Delete([FromQuery] string path) + { + var safePath = SanitizePath(path); + var physicalDirPath = PathBuilder.Dir(BaseDirectory, safePath); + + if (Directory.Exists(physicalDirPath)) + Directory.Delete(physicalDirPath, true); + else + { + var physicalFilePath = PathBuilder.File(BaseDirectory, safePath); + + System.IO.File.Delete(physicalFilePath); + } + + return Task.CompletedTask; + } + + [HttpPost("mkdir")] + public Task CreateDirectory([FromQuery] string path) + { + var safePath = SanitizePath(path); + var physicalPath = PathBuilder.Dir(BaseDirectory, safePath); + + Directory.CreateDirectory(physicalPath); + return Task.CompletedTask; + } + + [HttpGet("read")] + public async Task Read([FromQuery] string path) + { + var safePath = SanitizePath(path); + var physicalPath = PathBuilder.File(BaseDirectory, safePath); + + await using var fs = System.IO.File.Open(physicalPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + await fs.CopyToAsync(Response.Body); + + fs.Close(); + } + + [HttpPost("compress")] + public async Task Compress([FromBody] CompressRequest request) + { + if (request.Type == "tar.gz") + await CompressTarGz(request.Path, request.ItemsToCompress); + else if (request.Type == "zip") + await CompressZip(request.Path, request.ItemsToCompress); + } + + #region Tar Gz + + private async Task CompressTarGz(string path, string[] itemsToCompress) + { + var safePath = SanitizePath(path); + var destination = PathBuilder.File(BaseDirectory, safePath); + + await using var outStream = System.IO.File.Create(destination); + await using var gzoStream = new GZipOutputStream(outStream); + await using var tarStream = new TarOutputStream(gzoStream, Encoding.UTF8); + + foreach (var itemName in itemsToCompress) + { + var safeFilePath = SanitizePath(itemName); + var filePath = PathBuilder.File(BaseDirectory, safeFilePath); + + var fi = new FileInfo(filePath); + + if (fi.Exists) + await AddFileToTarGz(tarStream, filePath); + else + { + var safeDirePath = SanitizePath(itemName); + var dirPath = PathBuilder.Dir(BaseDirectory, safeDirePath); + + await AddDirectoryToTarGz(tarStream, dirPath); + } + } + + await tarStream.FlushAsync(); + await gzoStream.FlushAsync(); + await outStream.FlushAsync(); + + tarStream.Close(); + gzoStream.Close(); + outStream.Close(); + } + + private async Task AddDirectoryToTarGz(TarOutputStream tarOutputStream, string root) + { + foreach (var file in Directory.GetFiles(root)) + await AddFileToTarGz(tarOutputStream, file); + + foreach (var directory in Directory.GetDirectories(root)) + await AddDirectoryToTarGz(tarOutputStream, directory); + } + + private async Task AddFileToTarGz(TarOutputStream tarOutputStream, string file) + { + // Open file stream + var fs = System.IO.File.Open(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + // Meta + var entry = TarEntry.CreateTarEntry(file); + + // Fix path + entry.Name = Formatter + .ReplaceStart(entry.Name, BaseDirectory, "") + .TrimStart('/'); + + entry.Size = fs.Length; + + // Write entry + await tarOutputStream.PutNextEntryAsync(entry, CancellationToken.None); + + // Copy file content to tar stream + await fs.CopyToAsync(tarOutputStream); + fs.Close(); + + // Close the entry + tarOutputStream.CloseEntry(); + } + + #endregion + + #region ZIP + + private async Task CompressZip(string path, string[] itemsToCompress) + { + var safePath = SanitizePath(path); + var destination = PathBuilder.File(BaseDirectory, safePath); + + await using var outStream = System.IO.File.Create(destination); + await using var zipOutputStream = new ZipOutputStream(outStream); + + foreach (var itemName in itemsToCompress) + { + var safeFilePath = SanitizePath(itemName); + var filePath = PathBuilder.File(BaseDirectory, safeFilePath); + + var fi = new FileInfo(filePath); + + if (fi.Exists) + await AddFileToZip(zipOutputStream, filePath); + else + { + var safeDirePath = SanitizePath(itemName); + var dirPath = PathBuilder.Dir(BaseDirectory, safeDirePath); + + await AddDirectoryToZip(zipOutputStream, dirPath); + } + } + + await zipOutputStream.FlushAsync(); + await outStream.FlushAsync(); + + zipOutputStream.Close(); + outStream.Close(); + } + + private async Task AddFileToZip(ZipOutputStream zipOutputStream, string path) + { + // Open file stream + var fs = System.IO.File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + // Fix path + var name = Formatter + .ReplaceStart(path, BaseDirectory, "") + .TrimStart('/'); + + // Meta + var entry = new ZipEntry(name); + + entry.Size = fs.Length; + + // Write entry + await zipOutputStream.PutNextEntryAsync(entry, CancellationToken.None); + + // Copy file content to tar stream + await fs.CopyToAsync(zipOutputStream); + fs.Close(); + + // Close the entry + zipOutputStream.CloseEntry(); + } + + private async Task AddDirectoryToZip(ZipOutputStream zipOutputStream, string root) + { + foreach (var file in Directory.GetFiles(root)) + await AddFileToZip(zipOutputStream, file); + + foreach (var directory in Directory.GetDirectories(root)) + await AddDirectoryToZip(zipOutputStream, directory); + } + + #endregion + + [HttpPost("decompress")] + public async Task Decompress([FromBody] DecompressRequest request) + { + if (request.Type == "tar.gz") + await DecompressTarGz(request.Path, request.Destination); + else if (request.Type == "zip") + await DecompressZip(request.Path, request.Destination); + } + + #region Tar Gz + + private async Task DecompressTarGz(string path, string destination) + { + var safeDestination = SanitizePath(destination); + + var safeArchivePath = SanitizePath(path); + var archivePath = PathBuilder.File(BaseDirectory, safeArchivePath); + + await using var fs = System.IO.File.Open(archivePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + await using var gzipInputStream = new GZipInputStream(fs); + await using var tarInputStream = new TarInputStream(gzipInputStream, Encoding.UTF8); + + while (true) + { + var entry = await tarInputStream.GetNextEntryAsync(CancellationToken.None); + + if (entry == null) + break; + + var safeFilePath = SanitizePath(entry.Name); + var fileDestination = PathBuilder.File(BaseDirectory, safeDestination, safeFilePath); + var parentFolder = Path.GetDirectoryName(fileDestination); + + // Ensure parent directory exists, if it's not the base directory + if (parentFolder != null && parentFolder != BaseDirectory) + Directory.CreateDirectory(parentFolder); + + await using var fileDestinationFs = + System.IO.File.Open(fileDestination, FileMode.Create, FileAccess.ReadWrite, FileShare.Read); + await tarInputStream.CopyToAsync(fileDestinationFs, CancellationToken.None); + + await fileDestinationFs.FlushAsync(); + fileDestinationFs.Close(); + } + + tarInputStream.Close(); + gzipInputStream.Close(); + fs.Close(); + } + + #endregion + + #region Zip + + private async Task DecompressZip(string path, string destination) + { + var safeDestination = SanitizePath(destination); + + var safeArchivePath = SanitizePath(path); + var archivePath = PathBuilder.File(BaseDirectory, safeArchivePath); + + await using var fs = System.IO.File.Open(archivePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + await using var zipInputStream = new ZipInputStream(fs); + + while (true) + { + var entry = zipInputStream.GetNextEntry(); + + if (entry == null) + break; + + if (entry.IsDirectory) + continue; + + var safeFilePath = SanitizePath(entry.Name); + var fileDestination = PathBuilder.File(BaseDirectory, safeDestination, safeFilePath); + var parentFolder = Path.GetDirectoryName(fileDestination); + + // Ensure parent directory exists, if it's not the base directory + if (parentFolder != null && parentFolder != BaseDirectory) + Directory.CreateDirectory(parentFolder); + + await using var fileDestinationFs = + System.IO.File.Open(fileDestination, FileMode.Create, FileAccess.ReadWrite, FileShare.Read); + await zipInputStream.CopyToAsync(fileDestinationFs, CancellationToken.None); + + await fileDestinationFs.FlushAsync(); + fileDestinationFs.Close(); + } + + zipInputStream.Close(); + fs.Close(); + } + + #endregion + + private string SanitizePath(string path) + => path.Replace("..", ""); +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Moonlight.ApiServer.csproj b/Moonlight.ApiServer/Moonlight.ApiServer.csproj index 0c9dff2a..8cef2652 100644 --- a/Moonlight.ApiServer/Moonlight.ApiServer.csproj +++ b/Moonlight.ApiServer/Moonlight.ApiServer.csproj @@ -28,6 +28,7 @@ + diff --git a/Moonlight.Client/Implementations/SysFileSystemProvider.cs b/Moonlight.Client/Implementations/SysFileSystemProvider.cs new file mode 100644 index 00000000..0a1454c0 --- /dev/null +++ b/Moonlight.Client/Implementations/SysFileSystemProvider.cs @@ -0,0 +1,83 @@ +using MoonCore.Blazor.Tailwind.Fm; +using MoonCore.Blazor.Tailwind.Fm.Models; +using MoonCore.Helpers; +using Moonlight.Shared.Http.Requests.Admin.Sys.Files; +using Moonlight.Shared.Http.Responses.Admin.Sys.Files; + +namespace Moonlight.Client.Implementations; + +public class SysFileSystemProvider : IFileSystemProvider, ICompressFileSystemProvider +{ + private readonly HttpApiClient HttpApiClient; + private readonly string BaseApiUrl = "api/admin/system/files"; + + public CompressType[] CompressTypes { get; } = + [ + new() + { + Extension = "zip", + DisplayName = "ZIP Archive" + }, + new() + { + Extension = "tar.gz", + DisplayName = "GZ Compressed Tar Archive" + } + ]; + + public SysFileSystemProvider(HttpApiClient httpApiClient) + { + HttpApiClient = httpApiClient; + } + + public async Task List(string path) + { + var entries = await HttpApiClient.GetJson( + $"{BaseApiUrl}/list?path={path}" + ); + + return entries.Select(x => new FileSystemEntry() + { + Name = x.Name, + Size = x.Size, + CreatedAt = x.CreatedAt, + IsFile = x.IsFile, + UpdatedAt = x.UpdatedAt + }).ToArray(); + } + + public async Task Create(string path, Stream stream) + => await HttpApiClient.PostStream($"{BaseApiUrl}/create?path={path}", stream); + + public async Task Move(string oldPath, string newPath) + => await HttpApiClient.Post($"{BaseApiUrl}/move?oldPath={oldPath}&newPath={newPath}"); + + public async Task Delete(string path) + => await HttpApiClient.Delete($"{BaseApiUrl}/delete?path={path}"); + + public async Task CreateDirectory(string path) + => await HttpApiClient.Post($"{BaseApiUrl}/mkdir?path={path}"); + + public async Task Read(string path) + => await HttpApiClient.GetStream($"{BaseApiUrl}/read?path={path}"); + + public async Task Compress(CompressType type, string path, string[] itemsToCompress) + { + await HttpApiClient.Post($"{BaseApiUrl}/compress", new CompressRequest() + { + Type = type.Extension, + Path = path, + ItemsToCompress = itemsToCompress + }); + } + + public async Task Decompress(CompressType type, string path, string destination) + { + await HttpApiClient.Post($"{BaseApiUrl}/decompress", new DecompressRequest() + { + Type = type.Extension, + Path = path, + Destination = destination + }); + } +} \ No newline at end of file diff --git a/Moonlight.Client/UI/Views/Admin/Sys/Files.razor b/Moonlight.Client/UI/Views/Admin/Sys/Files.razor new file mode 100644 index 00000000..bcc9fb27 --- /dev/null +++ b/Moonlight.Client/UI/Views/Admin/Sys/Files.razor @@ -0,0 +1,26 @@ +@page "/admin/system/files" + +@using MoonCore.Attributes +@using MoonCore.Helpers +@using MoonCore.Blazor.Tailwind.Fm +@using Moonlight.Client.Implementations + +@attribute [RequirePermission("admin.system.overview")] + +@inject HttpApiClient ApiClient + +
+ +
+ + + +@code +{ + private IFileSystemProvider FileSystemProvider; + + protected override void OnInitialized() + { + FileSystemProvider = new SysFileSystemProvider(ApiClient); + } +} diff --git a/Moonlight.Client/UiConstants.cs b/Moonlight.Client/UiConstants.cs index 0a34e94f..118724cb 100644 --- a/Moonlight.Client/UiConstants.cs +++ b/Moonlight.Client/UiConstants.cs @@ -2,6 +2,6 @@ namespace Moonlight.Client; public static class UiConstants { - public static readonly string[] AdminNavNames = ["Overview", "Theme"]; - public static readonly string[] AdminNavLinks = ["/admin/system", "/admin/system/theme"]; + public static readonly string[] AdminNavNames = ["Overview", "Theme", "Files"]; + public static readonly string[] AdminNavLinks = ["/admin/system", "/admin/system/theme", "/admin/system/files"]; } \ No newline at end of file diff --git a/Moonlight.Shared/Http/Requests/Admin/Sys/Files/CompressRequest.cs b/Moonlight.Shared/Http/Requests/Admin/Sys/Files/CompressRequest.cs new file mode 100644 index 00000000..a797b56d --- /dev/null +++ b/Moonlight.Shared/Http/Requests/Admin/Sys/Files/CompressRequest.cs @@ -0,0 +1,8 @@ +namespace Moonlight.Shared.Http.Requests.Admin.Sys.Files; + +public class CompressRequest +{ + public string Type { get; set; } + public string Path { get; set; } + public string[] ItemsToCompress { get; set; } +} \ No newline at end of file diff --git a/Moonlight.Shared/Http/Requests/Admin/Sys/Files/DecompressRequest.cs b/Moonlight.Shared/Http/Requests/Admin/Sys/Files/DecompressRequest.cs new file mode 100644 index 00000000..29a9a9c5 --- /dev/null +++ b/Moonlight.Shared/Http/Requests/Admin/Sys/Files/DecompressRequest.cs @@ -0,0 +1,8 @@ +namespace Moonlight.Shared.Http.Requests.Admin.Sys.Files; + +public class DecompressRequest +{ + public string Type { get; set; } + public string Path { get; set; } + public string Destination { get; set; } +} \ No newline at end of file diff --git a/Moonlight.Shared/Http/Responses/Admin/Sys/Files/FileSystemEntryResponse.cs b/Moonlight.Shared/Http/Responses/Admin/Sys/Files/FileSystemEntryResponse.cs new file mode 100644 index 00000000..5de853c7 --- /dev/null +++ b/Moonlight.Shared/Http/Responses/Admin/Sys/Files/FileSystemEntryResponse.cs @@ -0,0 +1,11 @@ +namespace Moonlight.Shared.Http.Responses.Admin.Sys.Files; + +public class FileSystemEntryResponse +{ + public string Name { get; set; } + public bool IsFile { get; set; } + public long Size { get; set; } + + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} \ No newline at end of file