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
This commit is contained in:
@@ -24,6 +24,9 @@ public record AppConfiguration
|
|||||||
[YamlMember(Description = "\nSettings for the internal web server moonlight is running in")]
|
[YamlMember(Description = "\nSettings for the internal web server moonlight is running in")]
|
||||||
public KestrelConfig Kestrel { get; set; } = new();
|
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")]
|
[YamlMember(Description = "\nSettings for open telemetry")]
|
||||||
public OpenTelemetryData OpenTelemetry { get; set; } = new();
|
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
|
public record FrontendData
|
||||||
{
|
{
|
||||||
[YamlMember(Description = "Enable the hosting of the frontend. Disable this if you only want to run the api server")]
|
[YamlMember(Description = "Enable the hosting of the frontend. Disable this if you only want to run the api server")]
|
||||||
|
|||||||
25
Moonlight.ApiServer/Helpers/FilePathHelper.cs
Normal file
25
Moonlight.ApiServer/Helpers/FilePathHelper.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IResult> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IResult> 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<string> 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<string> 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<DownloadUrlResponse> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FileSystemEntryResponse[]> List([FromQuery] string path)
|
||||||
|
{
|
||||||
|
var safePath = FilePathHelper.SanitizePath(path);
|
||||||
|
var physicalPath = Path.Combine(BaseDirectory, safePath);
|
||||||
|
|
||||||
|
var entries = new List<FileSystemEntryResponse>();
|
||||||
|
|
||||||
|
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<IResult> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FileSystemEntryResponse[]> List([FromQuery] string path)
|
|
||||||
{
|
|
||||||
var safePath = SanitizePath(path);
|
|
||||||
var physicalPath = Path.Combine(BaseDirectory, safePath);
|
|
||||||
|
|
||||||
var entries = new List<FileSystemEntryResponse>();
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Database\Migrations\"/>
|
<Folder Include="Database\Migrations\"/>
|
||||||
<Folder Include="Helpers\"/>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<PackageId>Moonlight.ApiServer</PackageId>
|
<PackageId>Moonlight.ApiServer</PackageId>
|
||||||
@@ -28,7 +27,7 @@
|
|||||||
<PackageReference Include="Hangfire.EntityFrameworkCore" Version="0.7.0"/>
|
<PackageReference Include="Hangfire.EntityFrameworkCore" Version="0.7.0"/>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"/>
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"/>
|
||||||
<PackageReference Include="MoonCore" Version="1.9.6"/>
|
<PackageReference Include="MoonCore" Version="1.9.6"/>
|
||||||
<PackageReference Include="MoonCore.Extended" Version="1.3.6"/>
|
<PackageReference Include="MoonCore.Extended" Version="1.3.7" />
|
||||||
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.2"/>
|
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.2"/>
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.12.0-beta.1"/>
|
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.12.0-beta.1"/>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using MoonCore.Blazor.FlyonUi.Files;
|
using MoonCore.Blazor.FlyonUi.Files;
|
||||||
using MoonCore.Blazor.FlyonUi.Files.Manager;
|
using MoonCore.Blazor.FlyonUi.Files.Manager;
|
||||||
|
using MoonCore.Blazor.FlyonUi.Files.Manager.Abstractions;
|
||||||
using MoonCore.Helpers;
|
using MoonCore.Helpers;
|
||||||
|
using Moonlight.Shared.Http.Requests.Admin.Sys.Files;
|
||||||
using Moonlight.Shared.Http.Responses.Admin.Sys;
|
using Moonlight.Shared.Http.Responses.Admin.Sys;
|
||||||
|
|
||||||
namespace Moonlight.Client.Implementations;
|
namespace Moonlight.Client.Implementations;
|
||||||
|
|
||||||
public class SystemFsAccess : IFsAccess
|
public class SystemFsAccess : IFsAccess, ICombineAccess, IArchiveAccess, IDownloadUrlAccess
|
||||||
{
|
{
|
||||||
private readonly HttpApiClient ApiClient;
|
private readonly HttpApiClient ApiClient;
|
||||||
|
|
||||||
@@ -53,14 +55,27 @@ public class SystemFsAccess : IFsAccess
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task Read(string path, Func<Stream, Task> onHandleData)
|
public async Task Read(string path, Func<Stream, Task> 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)
|
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(
|
await ApiClient.Post(
|
||||||
$"{BaseApiUrl}/upload?path={path}&chunkId={chunkId}&chunkSize={chunkSize}&totalSize={totalSize}",
|
$"{BaseApiUrl}/combine",
|
||||||
formContent
|
new CombineRequest()
|
||||||
|
{
|
||||||
|
Destination = destination,
|
||||||
|
Files = files
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<byte[]> 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<string, Task>? 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<string, Task>? onProgress = null)
|
||||||
|
{
|
||||||
|
await ApiClient.Post(
|
||||||
|
$"{BaseApiUrl}/decompress",
|
||||||
|
new DecompressRequest()
|
||||||
|
{
|
||||||
|
Format = format.Identifier,
|
||||||
|
Destination = destination,
|
||||||
|
Path = path
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetFileUrl(string path)
|
||||||
|
=> await GetDownloadUrl(path);
|
||||||
|
|
||||||
|
public async Task<string> GetFolderUrl(string path)
|
||||||
|
=> await GetDownloadUrl(path);
|
||||||
|
|
||||||
|
private async Task<string> GetDownloadUrl(string path)
|
||||||
|
{
|
||||||
|
var response = await ApiClient.PostJson<DownloadUrlResponse>(
|
||||||
|
$"{BaseApiUrl}/downloadUrl?path={path}"
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.Url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.8" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.8" />
|
||||||
<PackageReference Include="MoonCore" Version="1.9.6" />
|
<PackageReference Include="MoonCore" Version="1.9.6" />
|
||||||
<PackageReference Include="MoonCore.Blazor" Version="1.3.1" />
|
<PackageReference Include="MoonCore.Blazor" Version="1.3.1" />
|
||||||
<PackageReference Include="MoonCore.Blazor.FlyonUi" Version="1.1.5" />
|
<PackageReference Include="MoonCore.Blazor.FlyonUi" Version="1.1.6" />
|
||||||
<PackageReference Include="System.Security.Claims" Version="4.3.0" />
|
<PackageReference Include="System.Security.Claims" Version="4.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ overlay-open:duration-50
|
|||||||
overlay-open:opacity-100
|
overlay-open:opacity-100
|
||||||
p-0.5
|
p-0.5
|
||||||
p-1
|
p-1
|
||||||
|
p-1.5
|
||||||
p-2
|
p-2
|
||||||
p-3
|
p-3
|
||||||
p-4
|
p-4
|
||||||
@@ -399,6 +400,7 @@ radio
|
|||||||
range
|
range
|
||||||
relative
|
relative
|
||||||
resize
|
resize
|
||||||
|
ring
|
||||||
ring-0
|
ring-0
|
||||||
ring-1
|
ring-1
|
||||||
ring-white/10
|
ring-white/10
|
||||||
|
|||||||
@@ -14,16 +14,12 @@
|
|||||||
<NavTabs Index="2" Names="UiConstants.AdminNavNames" Links="UiConstants.AdminNavLinks"/>
|
<NavTabs Index="2" Names="UiConstants.AdminNavNames" Links="UiConstants.AdminNavLinks"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FileManager OnConfigure="OnConfigure" FsAccess="FsAccess" TransferChunkSize="TransferChunkSize"
|
<FileManager OnConfigure="OnConfigure" FsAccess="FsAccess" />
|
||||||
UploadLimit="UploadLimit"/>
|
|
||||||
|
|
||||||
@code
|
@code
|
||||||
{
|
{
|
||||||
private IFsAccess FsAccess;
|
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()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
FsAccess = new SystemFsAccess(ApiClient);
|
FsAccess = new SystemFsAccess(ApiClient);
|
||||||
@@ -34,10 +30,18 @@
|
|||||||
options.AddMultiOperation<DeleteOperation>();
|
options.AddMultiOperation<DeleteOperation>();
|
||||||
options.AddMultiOperation<MoveOperation>();
|
options.AddMultiOperation<MoveOperation>();
|
||||||
options.AddMultiOperation<DownloadOperation>();
|
options.AddMultiOperation<DownloadOperation>();
|
||||||
|
options.AddMultiOperation<ArchiveOperation>();
|
||||||
|
|
||||||
|
//options.AddSingleOperation<UnarchiveOperation>();
|
||||||
options.AddSingleOperation<RenameOperation>();
|
options.AddSingleOperation<RenameOperation>();
|
||||||
|
|
||||||
options.AddToolbarOperation<CreateFileOperation>();
|
options.AddToolbarOperation<CreateFileOperation>();
|
||||||
options.AddToolbarOperation<CreateFolderOperation>();
|
options.AddToolbarOperation<CreateFolderOperation>();
|
||||||
|
|
||||||
|
options.AddOpenOperation<EditorOpenOperation>();
|
||||||
|
options.AddOpenOperation<ImageOpenOperation>();
|
||||||
|
options.AddOpenOperation<VideoOpenOperation>();
|
||||||
|
|
||||||
|
options.WriteLimit = ByteConverter.FromMegaBytes(20).Bytes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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 class CompressRequest
|
||||||
{
|
{
|
||||||
public string Type { get; set; }
|
[Required(ErrorMessage = "Format is required")]
|
||||||
public string Path { get; set; }
|
public string Format { get; set; }
|
||||||
public string[] ItemsToCompress { 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; }
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
public class DecompressRequest
|
public class DecompressRequest
|
||||||
{
|
{
|
||||||
public string Type { get; set; }
|
public string Format { get; set; }
|
||||||
public string Path { get; set; }
|
public string Path { get; set; }
|
||||||
public string Destination { get; set; }
|
public string Destination { get; set; }
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Moonlight.Shared.Http.Responses.Admin.Sys;
|
||||||
|
|
||||||
|
public class DownloadUrlResponse
|
||||||
|
{
|
||||||
|
public string Url { get; set; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user