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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user