From a6ae2aacfbacb0b8308638b39aa6497a1dcb7f37 Mon Sep 17 00:00:00 2001 From: ChiaraBm Date: Tue, 26 Aug 2025 01:07:59 +0200 Subject: [PATCH] Extended file manager to support the new interfaces for downloading via url. Improved the handling of compressing and decompressing. Seperated file manager controllers. Updated mooncore versions --- .../Configuration/AppConfiguration.cs | 9 + Moonlight.ApiServer/Helpers/FilePathHelper.cs | 25 + .../Admin/Sys/Files/CombineController.cs | 81 +++ .../Admin/Sys/Files/CompressController.cs | 183 +++++++ .../Admin/Sys/Files/DecompressController.cs | 113 +++++ .../Admin/Sys/Files/DownloadUrlController.cs | 128 +++++ .../Admin/Sys/Files/FilesController.cs | 192 +++++++ .../Controllers/Admin/Sys/FilesController.cs | 475 ------------------ .../Moonlight.ApiServer.csproj | 3 +- .../Implementations/SystemFsAccess.cs | 104 +++- Moonlight.Client/Moonlight.Client.csproj | 2 +- .../MoonCore.Blazor.FlyonUi/mooncore.map | 2 + .../UI/Views/Admin/Sys/Files.razor | 16 +- .../Admin/Sys/Files/CombineRequest.cs | 12 + .../Admin/Sys/Files/CompressRequest.cs | 18 +- .../Admin/Sys/Files/DecompressRequest.cs | 2 +- .../Admin/Sys/DownloadUrlResponse.cs | 6 + 17 files changed, 868 insertions(+), 503 deletions(-) create mode 100644 Moonlight.ApiServer/Helpers/FilePathHelper.cs create mode 100644 Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/CombineController.cs create mode 100644 Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/CompressController.cs create mode 100644 Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/DecompressController.cs create mode 100644 Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/DownloadUrlController.cs create mode 100644 Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/FilesController.cs delete mode 100644 Moonlight.ApiServer/Http/Controllers/Admin/Sys/FilesController.cs create mode 100644 Moonlight.Shared/Http/Requests/Admin/Sys/Files/CombineRequest.cs create mode 100644 Moonlight.Shared/Http/Responses/Admin/Sys/DownloadUrlResponse.cs diff --git a/Moonlight.ApiServer/Configuration/AppConfiguration.cs b/Moonlight.ApiServer/Configuration/AppConfiguration.cs index e6abc853..e04e4068 100644 --- a/Moonlight.ApiServer/Configuration/AppConfiguration.cs +++ b/Moonlight.ApiServer/Configuration/AppConfiguration.cs @@ -23,6 +23,9 @@ public record AppConfiguration [YamlMember(Description = "\nSettings for the internal web server moonlight is running in")] public KestrelConfig Kestrel { get; set; } = new(); + + [YamlMember(Description = "\nSettings for the internal file manager for moonlights storage access")] + public FilesData Files { get; set; } = new(); [YamlMember(Description = "\nSettings for open telemetry")] public OpenTelemetryData OpenTelemetry { get; set; } = new(); @@ -44,6 +47,12 @@ public record AppConfiguration }; } + public record FilesData + { + [YamlMember(Description = "The maximum file size limit a combine operation is allowed to process")] + public long CombineLimit { get; set; } = ByteConverter.FromGigaBytes(5).MegaBytes; + } + public record FrontendData { [YamlMember(Description = "Enable the hosting of the frontend. Disable this if you only want to run the api server")] diff --git a/Moonlight.ApiServer/Helpers/FilePathHelper.cs b/Moonlight.ApiServer/Helpers/FilePathHelper.cs new file mode 100644 index 00000000..49937bb5 --- /dev/null +++ b/Moonlight.ApiServer/Helpers/FilePathHelper.cs @@ -0,0 +1,25 @@ +namespace Moonlight.ApiServer.Helpers; + +public class FilePathHelper +{ + public static string SanitizePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return string.Empty; + + // Normalize separators + path = path.Replace('\\', '/'); + + // Remove ".." and "." + var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries) + .Where(part => part != ".." && part != "."); + + var sanitized = string.Join("/", parts); + + // Ensure it does not start with a slash + if (sanitized.StartsWith('/')) + sanitized = sanitized.TrimStart('/'); + + return sanitized; + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/CombineController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/CombineController.cs new file mode 100644 index 00000000..5d4e006e --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/CombineController.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using MoonCore.Helpers; +using Moonlight.ApiServer.Configuration; +using Moonlight.ApiServer.Helpers; +using Moonlight.Shared.Http.Requests.Admin.Sys.Files; + +namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys.Files; + +[ApiController] +[Route("api/admin/system/files")] +[Authorize(Policy = "permissions:admin.system.files")] +public class CombineController : Controller +{ + private readonly AppConfiguration Configuration; + + private const string BaseDirectory = "storage"; + + public CombineController(AppConfiguration configuration) + { + Configuration = configuration; + } + + [HttpPost("combine")] + public async Task Combine([FromBody] CombineRequest request) + { + // Validate file lenght + if (request.Files.Length < 2) + return Results.Problem("At least two files are required", statusCode: 400); + + // Resolve the physical paths + var destination = Path.Combine(BaseDirectory, FilePathHelper.SanitizePath(request.Destination)); + + var files = request.Files + .Select(path => Path.Combine(BaseDirectory, FilePathHelper.SanitizePath(path))) + .ToArray(); + + // Validate max file size + long combinedSize = 0; + + foreach (var file in files) + { + var fi = new FileInfo(file); + combinedSize += fi.Length; + } + + if (ByteConverter.FromBytes(combinedSize).MegaBytes > Configuration.Files.CombineLimit) + { + return Results.Problem("The combine operation exceeds the maximum file size", statusCode: 400); + } + + // Combine files + + await using var destinationFs = System.IO.File.Open( + destination, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.Read + ); + + foreach (var file in files) + { + await using var fs = System.IO.File.Open( + file, + FileMode.Open, + FileAccess.ReadWrite + ); + + await fs.CopyToAsync(destinationFs); + await destinationFs.FlushAsync(); + + fs.Close(); + } + + await destinationFs.FlushAsync(); + destinationFs.Close(); + + return Results.Ok(); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/CompressController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/CompressController.cs new file mode 100644 index 00000000..26584ee1 --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/CompressController.cs @@ -0,0 +1,183 @@ +using System.Text; +using ICSharpCode.SharpZipLib.GZip; +using ICSharpCode.SharpZipLib.Tar; +using ICSharpCode.SharpZipLib.Zip; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using MoonCore.Helpers; +using Moonlight.ApiServer.Helpers; +using Moonlight.Shared.Http.Requests.Admin.Sys.Files; + +namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys.Files; + +[ApiController] +[Route("api/admin/system/files")] +[Authorize(Policy = "permissions:admin.system.files")] +public class CompressController : Controller +{ + private const string BaseDirectory = "storage"; + + [HttpPost("compress")] + public async Task Compress([FromBody] CompressRequest request) + { + // Validate item length + if (request.Items.Length == 0) + { + return Results.Problem( + "At least one item is required", + statusCode: 400 + ); + } + + // Build paths + var destinationPath = Path.Combine(BaseDirectory, FilePathHelper.SanitizePath(request.Destination)); + var rootPath = Path.Combine(BaseDirectory, FilePathHelper.SanitizePath(request.Root)); + + // Resolve the relative to the root item paths to absolute paths + var itemsPaths = request.Items.Select(item => + Path.Combine( + BaseDirectory, + FilePathHelper.SanitizePath( + UnixPath.Combine(request.Root, item) + ) + ) + ); + + // Execute request + switch (request.Format) + { + case "tar.gz": + await CompressTarGz(destinationPath, itemsPaths, rootPath); + break; + + case "zip": + await CompressZip(destinationPath, itemsPaths, rootPath); + break; + + default: + return Results.Problem("Unsupported archive format specified", statusCode: 400); + } + + return Results.Ok(); + } + + + + #region Tar Gz + + private async Task CompressTarGz(string destination, IEnumerable items, string root) + { + 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 item in items) + await CompressItemToTarGz(tarStream, item, root); + + await tarStream.FlushAsync(); + await gzoStream.FlushAsync(); + await outStream.FlushAsync(); + + tarStream.Close(); + gzoStream.Close(); + outStream.Close(); + } + + private async Task CompressItemToTarGz(TarOutputStream tarOutputStream, string item, string root) + { + if (System.IO.File.Exists(item)) + { + // Open file stream + var fs = System.IO.File.Open(item, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + // Meta + var entry = TarEntry.CreateTarEntry( + Formatter + .ReplaceStart(item, root, "") + .TrimStart('/') + ); + + // Set size + 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(); + + return; + } + + if (Directory.Exists(item)) + { + foreach (var fsEntry in Directory.EnumerateFileSystemEntries(item)) + await CompressItemToTarGz(tarOutputStream, fsEntry, root); + } + } + + #endregion + + #region ZIP + + private async Task CompressZip(string destination, IEnumerable items, string root) + { + await using var outStream = System.IO.File.Create(destination); + await using var zipOutputStream = new ZipOutputStream(outStream); + + foreach (var item in items) + await AddItemToZip(zipOutputStream, item, root); + + await zipOutputStream.FlushAsync(); + await outStream.FlushAsync(); + + zipOutputStream.Close(); + outStream.Close(); + } + + private async Task AddItemToZip(ZipOutputStream outputStream, string item, string root) + { + if (System.IO.File.Exists(item)) + { + // Open file stream + var fs = System.IO.File.Open(item, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + // Meta + var entry = new ZipEntry( + Formatter + .ReplaceStart(item, root, "") + .TrimStart('/') + ); + + entry.Size = fs.Length; + + // Write entry + await outputStream.PutNextEntryAsync(entry, CancellationToken.None); + + // Copy file content to tar stream + await fs.CopyToAsync(outputStream); + fs.Close(); + + // Close the entry + outputStream.CloseEntry(); + + // Flush caches + await outputStream.FlushAsync(); + + return; + } + + if (Directory.Exists(item)) + { + foreach (var subItem in Directory.EnumerateFileSystemEntries(item)) + await AddItemToZip(outputStream, subItem, root); + } + } + + #endregion +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/DecompressController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/DecompressController.cs new file mode 100644 index 00000000..72db443e --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/DecompressController.cs @@ -0,0 +1,113 @@ +using System.Text; +using ICSharpCode.SharpZipLib.GZip; +using ICSharpCode.SharpZipLib.Tar; +using ICSharpCode.SharpZipLib.Zip; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Moonlight.ApiServer.Helpers; +using Moonlight.Shared.Http.Requests.Admin.Sys.Files; + +namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys.Files; + +[ApiController] +[Route("api/admin/system/files")] +[Authorize(Policy = "permissions:admin.system.files")] +public class DecompressController : Controller +{ + private const string BaseDirectory = "storage"; + + [HttpPost("decompress")] + public async Task Decompress([FromBody] DecompressRequest request) + { + var path = Path.Combine(BaseDirectory, FilePathHelper.SanitizePath(request.Path)); + var destination = Path.Combine(BaseDirectory, FilePathHelper.SanitizePath(request.Destination)); + + switch (request.Format) + { + case "tar.gz": + await DecompressTarGz(path, destination); + break; + + case "zip": + await DecompressZip(path, destination); + break; + } + } + + #region Tar Gz + + private async Task DecompressTarGz(string path, string destination) + { + await using var fs = System.IO.File.Open(path, 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 = FilePathHelper.SanitizePath(entry.Name); + var fileDestination = Path.Combine(destination, 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) + { + await using var fs = System.IO.File.Open(path, 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 = FilePathHelper.SanitizePath(entry.Name); + var fileDestination = Path.Combine(destination, 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 +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/DownloadUrlController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/DownloadUrlController.cs new file mode 100644 index 00000000..baa1498c --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/DownloadUrlController.cs @@ -0,0 +1,128 @@ +using ICSharpCode.SharpZipLib.Zip; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using MoonCore.Exceptions; +using MoonCore.Helpers; +using Moonlight.ApiServer.Configuration; +using Moonlight.ApiServer.Helpers; +using Moonlight.Shared.Http.Responses.Admin.Sys; + +namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys.Files; + +[ApiController] +[Route("api/admin/system/files/downloadUrl")] +[Authorize(Policy = "permissions:admin.system.files")] +public class DownloadUrlController : Controller +{ + private readonly AppConfiguration Configuration; + + private const string BaseDirectory = "storage"; + + public DownloadUrlController(AppConfiguration configuration) + { + Configuration = configuration; + } + + [HttpGet] + public async Task Get([FromQuery] string path) + { + var physicalPath = Path.Combine(BaseDirectory, FilePathHelper.SanitizePath(path)); + var name = Path.GetFileName(physicalPath); + + if (System.IO.File.Exists(physicalPath)) + { + await using var fs = System.IO.File.Open(physicalPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + await Results.File(fs, fileDownloadName: name).ExecuteAsync(HttpContext); + } + else if(Directory.Exists(physicalPath)) + { + // Without the base directory we would have the full path to the target folder + // inside the zip + + var baseDirectory = Path.Combine( + BaseDirectory, + FilePathHelper.SanitizePath(Path.GetDirectoryName(path) ?? "") + ); + + Response.StatusCode = 200; + Response.ContentType = "application/zip"; + Response.Headers["Content-Disposition"] = $"attachment; filename=\"{name}.zip\""; + + try + { + await using var zipStream = new ZipOutputStream(Response.Body); + zipStream.IsStreamOwner = false; + + await StreamFolderAsZip(zipStream, physicalPath, baseDirectory, HttpContext.RequestAborted); + } + catch (ZipException) + { + // Ignored + } + catch (TaskCanceledException) + { + // Ignored + } + } + } + + private async Task StreamFolderAsZip( + ZipOutputStream zipStream, + string path, string rootPath, + CancellationToken cancellationToken + ) + { + foreach (var file in Directory.EnumerateFiles(path)) + { + if (HttpContext.RequestAborted.IsCancellationRequested) + return; + + var fi = new FileInfo(file); + + var filePath = Formatter.ReplaceStart(file, rootPath, ""); + + await zipStream.PutNextEntryAsync(new ZipEntry(filePath) + { + Size = fi.Length, + }, cancellationToken); + + await using var fs = System.IO.File.Open(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + await fs.CopyToAsync(zipStream, cancellationToken); + await fs.FlushAsync(cancellationToken); + + fs.Close(); + + await zipStream.FlushAsync(cancellationToken); + } + + foreach (var directory in Directory.EnumerateDirectories(path)) + { + if (HttpContext.RequestAborted.IsCancellationRequested) + return; + + await StreamFolderAsZip(zipStream, directory, rootPath, cancellationToken); + } + } + + + // Yes I know we can just create that url on the client as the exist validation is done on both endpoints, + // but we leave it here for future modifications. E.g. using a distributed file provider or smth like that + [HttpPost] + public Task Post([FromQuery] string path) + { + var safePath = FilePathHelper.SanitizePath(path); + var physicalPath = Path.Combine(BaseDirectory, safePath); + + if (System.IO.File.Exists(physicalPath) || Directory.Exists(physicalPath)) + { + return Task.FromResult(new DownloadUrlResponse() + { + Url = $"{Configuration.PublicUrl}/api/admin/system/files/downloadUrl?path={path}" + }); + } + + throw new HttpApiException("No such file or directory found", 404); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/FilesController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/FilesController.cs new file mode 100644 index 00000000..c399ff7f --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/Files/FilesController.cs @@ -0,0 +1,192 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using MoonCore.Exceptions; +using Moonlight.ApiServer.Helpers; +using Moonlight.Shared.Http.Responses.Admin.Sys; + +namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys.Files; + +[ApiController] +[Route("api/admin/system/files")] +[Authorize(Policy = "permissions:admin.system.files")] +public class FilesController : Controller +{ + private const string BaseDirectory = "storage"; + + [HttpPost("touch")] + public async Task CreateFile([FromQuery] string path) + { + var safePath = FilePathHelper.SanitizePath(path); + var physicalPath = Path.Combine(BaseDirectory, safePath); + + if (System.IO.File.Exists(physicalPath)) + throw new HttpApiException("A file already exists at that path", 400); + + if (Directory.Exists(path)) + throw new HttpApiException("A folder already exists at that path", 400); + + await using var fs = System.IO.File.Create(physicalPath); + fs.Close(); + } + + [HttpPost("mkdir")] + public Task CreateFolder([FromQuery] string path) + { + var safePath = FilePathHelper.SanitizePath(path); + var physicalPath = Path.Combine(BaseDirectory, safePath); + + if (Directory.Exists(path)) + throw new HttpApiException("A folder already exists at that path", 400); + + if (System.IO.File.Exists(physicalPath)) + throw new HttpApiException("A file already exists at that path", 400); + + Directory.CreateDirectory(physicalPath); + return Task.CompletedTask; + } + + [HttpGet("list")] + public Task List([FromQuery] string path) + { + var safePath = FilePathHelper.SanitizePath(path); + var physicalPath = Path.Combine(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, + IsFolder = false, + 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, + IsFolder = true + }); + } + + return Task.FromResult( + entries.ToArray() + ); + } + + [HttpPost("move")] + public Task Move([FromQuery] string oldPath, [FromQuery] string newPath) + { + var oldSafePath = FilePathHelper.SanitizePath(oldPath); + var newSafePath = FilePathHelper.SanitizePath(newPath); + + var oldPhysicalDirPath = Path.Combine(BaseDirectory, oldSafePath); + + if (Directory.Exists(oldPhysicalDirPath)) + { + var newPhysicalDirPath = Path.Combine(BaseDirectory, newSafePath); + + Directory.Move( + oldPhysicalDirPath, + newPhysicalDirPath + ); + } + else + { + var oldPhysicalFilePath = Path.Combine(BaseDirectory, oldSafePath); + var newPhysicalFilePath = Path.Combine(BaseDirectory, newSafePath); + + System.IO.File.Move( + oldPhysicalFilePath, + newPhysicalFilePath + ); + } + + return Task.CompletedTask; + } + + [HttpDelete("delete")] + public Task Delete([FromQuery] string path) + { + var safePath = FilePathHelper.SanitizePath(path); + var physicalDirPath = Path.Combine(BaseDirectory, safePath); + + if (Directory.Exists(physicalDirPath)) + Directory.Delete(physicalDirPath, true); + else + { + var physicalFilePath = Path.Combine(BaseDirectory, safePath); + + System.IO.File.Delete(physicalFilePath); + } + + return Task.CompletedTask; + } + + [HttpPost("upload")] + public async Task Upload([FromQuery] string path) + { + if (Request.Form.Files.Count != 1) + return Results.Problem("Only one file is allowed in the request", statusCode: 400); + + var file = Request.Form.Files[0]; + + var safePath = FilePathHelper.SanitizePath(path); + var physicalPath = Path.Combine(BaseDirectory, safePath); + + // Create directory which the new file should be put into + var baseDirectory = Path.GetDirectoryName(physicalPath); + + if(!string.IsNullOrEmpty(baseDirectory)) + Directory.CreateDirectory(baseDirectory); + + // Create file from provided form + await using var dataStream = file.OpenReadStream(); + + await using var targetStream = System.IO.File.Open( + physicalPath, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.Read + ); + + // Copy the content to the newly created file + await dataStream.CopyToAsync(targetStream); + await targetStream.FlushAsync(); + + // Close both streams + targetStream.Close(); + dataStream.Close(); + + return Results.Ok(); + } + + [HttpGet("download")] + public async Task Download([FromQuery] string path) + { + var safePath = FilePathHelper.SanitizePath(path); + var physicalPath = Path.Combine(BaseDirectory, safePath); + + await using var fs = System.IO.File.Open(physicalPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + await fs.CopyToAsync(Response.Body); + + fs.Close(); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/FilesController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/FilesController.cs deleted file mode 100644 index ce81517d..00000000 --- a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/FilesController.cs +++ /dev/null @@ -1,475 +0,0 @@ -using System.Text; -using ICSharpCode.SharpZipLib.GZip; -using ICSharpCode.SharpZipLib.Tar; -using ICSharpCode.SharpZipLib.Zip; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using MoonCore.Exceptions; -using MoonCore.Helpers; -using Moonlight.Shared.Http.Requests.Admin.Sys.Files; -using Moonlight.Shared.Http.Responses.Admin.Sys; - -namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys; - -[ApiController] -[Route("api/admin/system/files")] -[Authorize(Policy = "permissions:admin.system.files")] -public class FilesController : Controller -{ - private readonly string BaseDirectory = "storage"; - private readonly long MaxChunkSize = ByteConverter.FromMegaBytes(20).Bytes; - - [HttpPost("touch")] - public async Task CreateFile([FromQuery] string path) - { - var safePath = SanitizePath(path); - var physicalPath = Path.Combine(BaseDirectory, safePath); - - if (System.IO.File.Exists(physicalPath)) - throw new HttpApiException("A file already exists at that path", 400); - - if (Directory.Exists(path)) - throw new HttpApiException("A folder already exists at that path", 400); - - await using var fs = System.IO.File.Create(physicalPath); - fs.Close(); - } - - [HttpPost("mkdir")] - public Task CreateFolder([FromQuery] string path) - { - var safePath = SanitizePath(path); - var physicalPath = Path.Combine(BaseDirectory, safePath); - - if (Directory.Exists(path)) - throw new HttpApiException("A folder already exists at that path", 400); - - if (System.IO.File.Exists(physicalPath)) - throw new HttpApiException("A file already exists at that path", 400); - - Directory.CreateDirectory(physicalPath); - return Task.CompletedTask; - } - - [HttpGet("list")] - public Task List([FromQuery] string path) - { - var safePath = SanitizePath(path); - var physicalPath = Path.Combine(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, - IsFolder = false, - 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, - IsFolder = true - }); - } - - return Task.FromResult( - entries.ToArray() - ); - } - - [HttpPost("upload")] - public async Task Upload([FromQuery] string path, [FromQuery] long chunkSize, [FromQuery] long totalSize, [FromQuery] int chunkId) - { - if (Request.Form.Files.Count != 1) - throw new HttpApiException("You need to provide exactly one file", 400); - - var file = Request.Form.Files[0]; - - if (file.Length > chunkSize) - throw new HttpApiException("The provided data exceeds the chunk size limit", 400); - - var chunks = totalSize / chunkSize; - chunks += totalSize % chunkSize > 0 ? 1 : 0; - - if (chunkId > chunks) - throw new HttpApiException("Invalid chunk id: Out of bounds", 400); - - var positionToSkipTo = chunkSize * chunkId; - - var safePath = SanitizePath(path); - var physicalPath = Path.Combine(BaseDirectory, safePath); - var baseDir = Path.GetDirectoryName(physicalPath); - - if (!string.IsNullOrEmpty(baseDir)) - Directory.CreateDirectory(baseDir); - - await using var fs = System.IO.File.Open(physicalPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); - - // This resizes the file to the correct size so we can handle the chunk if it didnt exist - - if (fs.Length != totalSize) - fs.SetLength(totalSize); - - fs.Position = positionToSkipTo; - - var dataStream = file.OpenReadStream(); - - await dataStream.CopyToAsync(fs); - await fs.FlushAsync(); - - fs.Close(); - } - - [HttpPost("move")] - public Task Move([FromQuery] string oldPath, [FromQuery] string newPath) - { - var oldSafePath = SanitizePath(oldPath); - var newSafePath = SanitizePath(newPath); - - var oldPhysicalDirPath = Path.Combine(BaseDirectory, oldSafePath); - - if (Directory.Exists(oldPhysicalDirPath)) - { - var newPhysicalDirPath = Path.Combine(BaseDirectory, newSafePath); - - Directory.Move( - oldPhysicalDirPath, - newPhysicalDirPath - ); - } - else - { - var oldPhysicalFilePath = Path.Combine(BaseDirectory, oldSafePath); - var newPhysicalFilePath = Path.Combine(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 = Path.Combine(BaseDirectory, safePath); - - if (Directory.Exists(physicalDirPath)) - Directory.Delete(physicalDirPath, true); - else - { - var physicalFilePath = Path.Combine(BaseDirectory, safePath); - - System.IO.File.Delete(physicalFilePath); - } - - return Task.CompletedTask; - } - - [HttpGet("download")] - public async Task Download([FromQuery] string path) - { - var safePath = SanitizePath(path); - var physicalPath = Path.Combine(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 = Path.Combine(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 = Path.Combine(BaseDirectory, safeFilePath); - - var fi = new FileInfo(filePath); - - if (fi.Exists) - await AddFileToTarGz(tarStream, filePath); - else - { - var safeDirePath = SanitizePath(itemName); - var dirPath = Path.Combine(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 = Path.Combine(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 = Path.Combine(BaseDirectory, safeFilePath); - - var fi = new FileInfo(filePath); - - if (fi.Exists) - await AddFileToZip(zipOutputStream, filePath); - else - { - var safeDirePath = SanitizePath(itemName); - var dirPath = Path.Combine(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 = Path.Combine(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 = Path.Combine(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 = Path.Combine(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 = Path.Combine(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) - { - if (string.IsNullOrWhiteSpace(path)) - return string.Empty; - - // Normalize separators - path = path.Replace('\\', '/'); - - // Remove ".." and "." - var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries) - .Where(part => part != ".." && part != "."); - - var sanitized = string.Join("/", parts); - - // Ensure it does not start with a slash - if (sanitized.StartsWith('/')) - sanitized = sanitized.TrimStart('/'); - - return sanitized; - } -} \ No newline at end of file diff --git a/Moonlight.ApiServer/Moonlight.ApiServer.csproj b/Moonlight.ApiServer/Moonlight.ApiServer.csproj index 3ea69dab..a951a5ec 100644 --- a/Moonlight.ApiServer/Moonlight.ApiServer.csproj +++ b/Moonlight.ApiServer/Moonlight.ApiServer.csproj @@ -10,7 +10,6 @@ - Moonlight.ApiServer @@ -28,7 +27,7 @@ - + diff --git a/Moonlight.Client/Implementations/SystemFsAccess.cs b/Moonlight.Client/Implementations/SystemFsAccess.cs index 83843570..25d19ce4 100644 --- a/Moonlight.Client/Implementations/SystemFsAccess.cs +++ b/Moonlight.Client/Implementations/SystemFsAccess.cs @@ -1,14 +1,16 @@ using MoonCore.Blazor.FlyonUi.Files; using MoonCore.Blazor.FlyonUi.Files.Manager; +using MoonCore.Blazor.FlyonUi.Files.Manager.Abstractions; using MoonCore.Helpers; +using Moonlight.Shared.Http.Requests.Admin.Sys.Files; using Moonlight.Shared.Http.Responses.Admin.Sys; namespace Moonlight.Client.Implementations; -public class SystemFsAccess : IFsAccess +public class SystemFsAccess : IFsAccess, ICombineAccess, IArchiveAccess, IDownloadUrlAccess { private readonly HttpApiClient ApiClient; - + private const string BaseApiUrl = "api/admin/system/files"; public SystemFsAccess(HttpApiClient apiClient) @@ -53,14 +55,27 @@ public class SystemFsAccess : IFsAccess ); } - public Task Read(string path, Func onHandleData) + public async Task Read(string path, Func onHandleData) { - throw new NotImplementedException(); + await using var stream = await ApiClient.GetStream( + $"{BaseApiUrl}/download?path={path}" + ); + + await onHandleData.Invoke(stream); + + stream.Close(); } - public Task Write(string path, Stream dataStream) + public async Task Write(string path, Stream dataStream) { - throw new NotImplementedException(); + using var multiPartForm = new MultipartFormDataContent(); + + multiPartForm.Add(new StreamContent(dataStream), "file", "file"); + + await ApiClient.Post( + $"{BaseApiUrl}/upload?path={path}", + multiPartForm + ); } public async Task Delete(string path) @@ -70,19 +85,80 @@ public class SystemFsAccess : IFsAccess ); } - public async Task UploadChunk(string path, int chunkId, long chunkSize, long totalSize, byte[] data) + public async Task Combine(string destination, string[] files) { - using var formContent = new MultipartFormDataContent(); - formContent.Add(new ByteArrayContent(data), "file", "file"); - await ApiClient.Post( - $"{BaseApiUrl}/upload?path={path}&chunkId={chunkId}&chunkSize={chunkSize}&totalSize={totalSize}", - formContent + $"{BaseApiUrl}/combine", + new CombineRequest() + { + Destination = destination, + Files = files + } ); } - public Task DownloadChunk(string path, int chunkId, long chunkSize) + public ArchiveFormat[] ArchiveFormats { get; } = new[] { - throw new NotImplementedException(); + new ArchiveFormat() + { + DisplayName = "Zip Archive", + Extensions = ["zip"], + Identifier = "zip" + }, + new ArchiveFormat() + { + DisplayName = "Tar.gz Archive", + Extensions = ["tar.gz"], + Identifier = "tar.gz" + } + }; + + public async Task Archive( + string destination, + ArchiveFormat format, + string root, + FsEntry[] files, + Func? onProgress = null + ) + { + await ApiClient.Post($"{BaseApiUrl}/compress", new CompressRequest() + { + Destination = destination, + Items = files.Select(x => x.Name).ToArray(), + Root = root, + Format = format.Identifier + }); + } + + public async Task Unarchive( + string path, + ArchiveFormat format, + string destination, + Func? onProgress = null) + { + await ApiClient.Post( + $"{BaseApiUrl}/decompress", + new DecompressRequest() + { + Format = format.Identifier, + Destination = destination, + Path = path + } + ); + } + + public async Task GetFileUrl(string path) + => await GetDownloadUrl(path); + + public async Task GetFolderUrl(string path) + => await GetDownloadUrl(path); + + private async Task GetDownloadUrl(string path) + { + var response = await ApiClient.PostJson( + $"{BaseApiUrl}/downloadUrl?path={path}" + ); + + return response.Url; } } \ No newline at end of file diff --git a/Moonlight.Client/Moonlight.Client.csproj b/Moonlight.Client/Moonlight.Client.csproj index 0f6b4133..201d8cbb 100644 --- a/Moonlight.Client/Moonlight.Client.csproj +++ b/Moonlight.Client/Moonlight.Client.csproj @@ -25,7 +25,7 @@ - + diff --git a/Moonlight.Client/Styles/MoonCore.Blazor.FlyonUi/mooncore.map b/Moonlight.Client/Styles/MoonCore.Blazor.FlyonUi/mooncore.map index 31c2b5c9..67f69d38 100755 --- a/Moonlight.Client/Styles/MoonCore.Blazor.FlyonUi/mooncore.map +++ b/Moonlight.Client/Styles/MoonCore.Blazor.FlyonUi/mooncore.map @@ -366,6 +366,7 @@ overlay-open:duration-50 overlay-open:opacity-100 p-0.5 p-1 +p-1.5 p-2 p-3 p-4 @@ -399,6 +400,7 @@ radio range relative resize +ring ring-0 ring-1 ring-white/10 diff --git a/Moonlight.Client/UI/Views/Admin/Sys/Files.razor b/Moonlight.Client/UI/Views/Admin/Sys/Files.razor index 2d0c456e..d5087f46 100644 --- a/Moonlight.Client/UI/Views/Admin/Sys/Files.razor +++ b/Moonlight.Client/UI/Views/Admin/Sys/Files.razor @@ -14,16 +14,12 @@ - + @code { private IFsAccess FsAccess; - private static readonly long TransferChunkSize = ByteConverter.FromMegaBytes(20).Bytes; - private static readonly long UploadLimit = ByteConverter.FromGigaBytes(20).Bytes; - protected override void OnInitialized() { FsAccess = new SystemFsAccess(ApiClient); @@ -34,10 +30,18 @@ options.AddMultiOperation(); options.AddMultiOperation(); options.AddMultiOperation(); - + options.AddMultiOperation(); + + //options.AddSingleOperation(); options.AddSingleOperation(); options.AddToolbarOperation(); options.AddToolbarOperation(); + + options.AddOpenOperation(); + options.AddOpenOperation(); + options.AddOpenOperation(); + + options.WriteLimit = ByteConverter.FromMegaBytes(20).Bytes; } } diff --git a/Moonlight.Shared/Http/Requests/Admin/Sys/Files/CombineRequest.cs b/Moonlight.Shared/Http/Requests/Admin/Sys/Files/CombineRequest.cs new file mode 100644 index 00000000..269b6326 --- /dev/null +++ b/Moonlight.Shared/Http/Requests/Admin/Sys/Files/CombineRequest.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Moonlight.Shared.Http.Requests.Admin.Sys.Files; + +public class CombineRequest +{ + [Required(ErrorMessage = "Destination is required")] + public string Destination { get; set; } + + [Required(ErrorMessage = "Files are required")] + public string[] Files { get; set; } +} \ 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 index a797b56d..521810e4 100644 --- a/Moonlight.Shared/Http/Requests/Admin/Sys/Files/CompressRequest.cs +++ b/Moonlight.Shared/Http/Requests/Admin/Sys/Files/CompressRequest.cs @@ -1,8 +1,18 @@ -namespace Moonlight.Shared.Http.Requests.Admin.Sys.Files; +using System.ComponentModel.DataAnnotations; + +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; } + [Required(ErrorMessage = "Format is required")] + public string Format { get; set; } + + [Required(ErrorMessage = "Destination is required")] + public string Destination { get; set; } + + [Required(ErrorMessage = "Root is required")] + public string Root { get; set; } + + [Required(ErrorMessage = "Items are required")] + public string[] Items { 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 index 29a9a9c5..79866eec 100644 --- a/Moonlight.Shared/Http/Requests/Admin/Sys/Files/DecompressRequest.cs +++ b/Moonlight.Shared/Http/Requests/Admin/Sys/Files/DecompressRequest.cs @@ -2,7 +2,7 @@ public class DecompressRequest { - public string Type { get; set; } + public string Format { 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/DownloadUrlResponse.cs b/Moonlight.Shared/Http/Responses/Admin/Sys/DownloadUrlResponse.cs new file mode 100644 index 00000000..aba6a249 --- /dev/null +++ b/Moonlight.Shared/Http/Responses/Admin/Sys/DownloadUrlResponse.cs @@ -0,0 +1,6 @@ +namespace Moonlight.Shared.Http.Responses.Admin.Sys; + +public class DownloadUrlResponse +{ + public string Url { get; set; } +} \ No newline at end of file