yavsc/src/Yavsc.Server/Helpers/FileSystemHelpers.cs
Paul Schneider 446b595077
All checks were successful
Dotnet build and test / build (pull_request) Successful in 33m37s
renaming of the 3Blog3 setting => UserFilesRoot
2026-10-11 00:16:44 +01:00

326 lines
14 KiB
C#

#nullable enable annotations
using System.Security.Claims;
using Microsoft.AspNetCore.Html;
using Microsoft.Extensions.FileProviders;
using Microsoft.AspNetCore.Http;
using ImageMagick;
using Yavsc.Models;
using Yavsc.Models.Streaming;
using Yavsc.ViewModels;
using Yavsc.Exceptions;
using Yavsc.Abstract.Helpers;
using Yavsc.Server.Models.FileSystem;
using Yavsc.ViewModels.UserFiles;
namespace Yavsc.Server.Helpers
{
public static class FileSystemHelpers
{
public static async Task SaveAsAsync(this IFormFile formFile, string path)
{
if (formFile.Length > 0)
{
using (Stream fileStream = new FileStream(path, FileMode.Create))
{
await formFile.CopyToAsync(fileStream);
}
}
}
public static async Task<FileReceivedInfo> ReceiveProSignatureAsync(this ClaimsPrincipal user, string billingCode, long estimateId, IFormFile formFile, string signType,
SiteSettings settings
)
{
var item = new FileReceivedInfo(
settings.Bills,
AbstractFileSystemHelpers.SignFileNameFormat(signType, billingCode, estimateId));
var fi = new FileInfo(item.FullName);
if (fi.Exists) item.Overridden = true;
await formFile.SaveAsAsync(fi.FullName);
return item;
}
public static string EnsureDestinationDirectory(
this ClaimsPrincipal user,
string subpath, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.UserFilesRoot, user.GetUserName());
if (!string.IsNullOrWhiteSpace(subpath))
{
if (!subpath.IsValidYavscPath())
{
throw new InvalidPathException();
}
root = Path.Combine(root, subpath);
}
var di = new DirectoryInfo(root);
if (!di.Exists) di.Create();
return di.FullName;
}
/// <summary>
/// Deletes user file.
/// User info is modified, but not save in db.
/// </summary>
/// <param name="user"></param>
/// <param name="fileName"></param>
public static void DeleteUserFile(this ApplicationUser user, string fileName, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
var fi = new FileInfo(Path.Combine(root, fileName));
if (!fi.Exists) return;
// Capture the length BEFORE deleting: FileInfo.Length throws
// FileNotFoundException once the file is gone, which would
// surface as a 400 even though the delete already succeeded.
var length = fi.Length;
fi.Delete();
user.DiskUsage -= length;
}
public static FsOperationInfo DeleteUserDirOrFile(this ApplicationUser user, string dirName, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
if (string.IsNullOrEmpty(dirName))
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a directory or file name" };
var di = new DirectoryInfo(Path.Combine(root, dirName));
if (!di.Exists)
{
var fi = new FileInfo(Path.Combine(root, dirName));
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "non existent" };
// Capture the length BEFORE deleting: FileInfo.Length
// throws FileNotFoundException once the file is gone, so
// reading it after fi.Delete() would throw — the caller's
// catch turns that into a 400 even though the file was
// already removed (the user sees "file does not exist"
// while the delete in fact succeeded).
var length = fi.Length;
fi.Delete();
user.DiskUsage -= length;
}
else
{
if (di.GetDirectories().Length > 0 || di.GetFiles().Length > 0)
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "dir is not empty, refusing to remove it" };
di.Delete();
}
return new FsOperationInfo { Done = true };
}
public static FsOperationInfo MoveUserDir(this ApplicationUser user, string fromDirName, string toDirName, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
if (string.IsNullOrEmpty(fromDirName))
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a dir name " };
var di = new DirectoryInfo(Path.Combine(root, fromDirName));
if (!di.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "fromDirName: non existent" };
if (string.IsNullOrEmpty(toDirName)) toDirName = ".";
var destPath = Path.Combine(root, toDirName);
var fout = new FileInfo(destPath);
if (fout.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "destination is a regular file" };
var dOut = new DirectoryInfo(destPath);
if (dOut.Exists)
{
destPath = Path.Combine(destPath, dOut.Name);
}
di.MoveTo(destPath);
return new FsOperationInfo { Done = true };
}
public static FsOperationInfo MoveUserFileToDir(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
string dest;
if (!string.IsNullOrEmpty(fileNameDest)) dest = Path.Combine(root, fileNameDest);
else dest = root;
var fo = new FileInfo(dest);
if (fo.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.DestExists, ErrorMessage = "destination file name is an existing file" };
var dout = new DirectoryInfo(dest);
if (!dout.Exists) dout.Create();
fi.MoveTo(Path.Combine(dout.FullName, fi.Name));
return new FsOperationInfo { Done = true };
}
public static FsOperationInfo MoveUserFile(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
var fo = new FileInfo(Path.Combine(root, fileNameDest));
if (fo.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.DestExists, ErrorMessage = "destination file name is an existing file" };
fi.MoveTo(fo.FullName);
return new FsOperationInfo { Done = true };
}
public static void AddQuota(this ApplicationUser user, int quota)
{
user.DiskQuota += quota;
}
public static FileReceivedInfo ReceiveUserFile(this ApplicationUser user, string root, IFormFile f, string destFileName = null)
{
// f.FileName is parsed by ASP.NET Core from the
// Content-Disposition header: it handles unquoted
// values and the RFC 5987 filename* parameter that
// modern HttpClient multipart bodies emit. The
// previous hand-rolled parser assumed a quoted
// filename in a fixed position and silently dropped
// the first character ("note.txt" → "ote.txt"),
// which broke the attachment links PostIt appends
// to the article. FilterFileName, downstream, still
// sanitises any path traversal attempt.
return ReceiveUserFile(user, root, f.OpenReadStream(), destFileName ?? f.FileName, f.ContentType, CancellationToken.None);
}
/// <summary>
/// Modifies the user's disk usage
/// </summary>
/// <param name="user"></param>
/// <param name="root"></param>
/// <param name="inputStream"></param>
/// <param name="destFileName"></param>
/// <param name="contentType"></param>
/// <param name="token"></param>
/// <returns></returns> <summary>
///
/// </summary>
/// <param name="user"></param>
/// <param name="root"></param>
/// <param name="inputStream"></param>
/// <param name="destFileName"></param>
/// <param name="contentType"></param>
/// <param name="token"></param>
/// <returns></returns>
public static FileReceivedInfo ReceiveUserFile(this ApplicationUser user, string root, Stream inputStream, string destFileName, string contentType, CancellationToken token)
{
// TODO lock user's disk usage for this scope,
// this process is not safe at concurrent access.
long usage = user.DiskUsage;
var item = new FileReceivedInfo
(root, AbstractFileSystemHelpers.FilterFileName(destFileName));
var fi = new FileInfo(Path.Combine(root, item.FileName));
if (fi.Exists)
{
item.Overridden = true;
usage -= fi.Length;
}
using (var dest = fi.OpenWrite())
{
using (inputStream)
{
const int blen = 1024;
byte[] buffer = new byte[blen];
int len = 0;
while (!token.IsCancellationRequested && (len = inputStream.Read(buffer, 0, blen)) > 0)
{
dest.Write(buffer, 0, len);
usage += len;
if (usage >= user.DiskQuota) break;
}
user.DiskUsage = usage;
dest.Close();
inputStream.Close();
}
}
if (usage >= user.DiskQuota)
{
item.QuotaOffense = true;
}
user.DiskUsage = usage;
return item;
}
public static HtmlString FileLink(this RemoteFileInfo info, string username, string subpath)
{
return new HtmlString(
$"{Config.UserFilesOptions.RequestPath}/{username}/{subpath}/{info.Name}");
}
public static RemoteFileInfo FileInfo(this ApplicationUser user, string path)
{
IFileInfo info = Config.UserFilesOptions.FileProvider.GetFileInfo($"{user.UserName}/{path}");
if (!info.Exists) return null;
return new RemoteFileInfo { Name = info.Name, Size = info.Length, LastModified = info.LastModified.UtcDateTime };
}
public static FileReceivedInfo ReceiveAvatar(this ApplicationUser user,
IFormFile formFile,
SiteSettings settings)
{
if (user == null) throw new ArgumentNullException(nameof(user));
if (formFile == null) throw new ArgumentNullException(nameof(formFile));
var avatarsRequestPath = Config.AvatarsOptions?.RequestPath.ToUriComponent();
if (string.IsNullOrWhiteSpace(avatarsRequestPath))
{
avatarsRequestPath = Constants.AvatarsPath;
}
var avatarsDirectory = settings.Avatars;
if (string.IsNullOrWhiteSpace(avatarsDirectory))
{
avatarsDirectory = "avatars";
}
Directory.CreateDirectory(avatarsDirectory);
var item = new FileReceivedInfo
(avatarsRequestPath,
user.UserName + ".png");
using (var org = formFile.OpenReadStream())
{
var size = new MagickGeometry(128, 128);
size.IgnoreAspectRatio = false;
using var image = new MagickImage(org);
image.Resize(size);
image.Write(Path.Combine(avatarsDirectory, item.FileName));
size.X = 64;
size.Y = 64;
image.Resize(size);
image.Write(Path.Combine(avatarsDirectory, user.UserName + ".s.png"));
size.X = 32;
size.Y = 32;
image.Resize(size);
image.Write(Path.Combine(avatarsDirectory, user.UserName + ".xs.png"));
}
user.Avatar = $"{item.DestDir}/{item.FileName}";
return item;
}
public static string GetFileUrl(this LiveFlow flow)
{
// no server-side backup for this stream
return $"{Config.UserFilesOptions.RequestPath}/{flow.Owner.UserName}/live/" + GetFileName(flow);
}
public static string GetFileName(this LiveFlow flow)
{
var fileInfo = new FileInfo(flow.DifferedFileName);
var ext = fileInfo.Extension;
var namelen = flow.DifferedFileName.Length - ext.Length;
var basename = flow.DifferedFileName.Substring(0, namelen);
return $"{basename}-{flow.SequenceNumber}{ext}";
}
public static UserDirectoryInfo GetUserFiles(SiteSettings settings, string userId, string subdir)
{
UserDirectoryInfo di = new UserDirectoryInfo(settings.UserFilesRoot, userId, subdir);
return di;
}
}
}