yavsc/src/Yavsc.Server/Helpers/FileSystemHelpers.cs

315 lines
13 KiB
C#
Raw Normal View History

#nullable enable annotations
2016-11-14 12:17:07 +01:00
using System.Security.Claims;
2023-03-19 17:57:55 +00:00
using Microsoft.AspNetCore.Html;
using Microsoft.Extensions.FileProviders;
Server: wire ProfileService so access tokens carry the name claim The custom ProfileService (Yavsc.Services.ProfileService) was never registered with IdentityServer, so IS8 fell back to its default profile service — which only emits claims listed in each API resource's UserClaims (RequestedClaimTypes). "name" was never among them, so every access token lacked "name" and GetUserName() resolved to null on all bearer resource hosts (Blogs/Api): the MyFiles upload 500 and the unreachable User.Identity.Name. The class was dead code; all prior tweaks to it (FullName→UserName) and to ServiceExtensions (MapInboundClaims=false) were necessary but insufficient until it was actually wired in. - HostingExtensions: AddProfileService<ProfileService>() after AddAspNetIdentity<ApplicationUser>(). - ProfileService: emit name=user.UserName, email, roles for any user grant whose client has "profile" in AllowedScopes, independent of the caller (the old ParsedScopes gate only fired for the id-token caller, never the access-token caller). - ServiceExtensions: JwtBearer NameClaimType=JwtClaimTypes.Name, MapInboundClaims=false (keep short claim names as-emitted). - FileSystemHelpers: EnsureDestinationDirectory uses GetUserName() (the "name" claim) instead of User.Identity.Name for the per-user storage root. Tests: - TestTokenIssuer: stamp a "name" claim = subject so test tokens mirror production shape (fixes FileSystemApiTests upload 500). - Remoting: PasswordToken_carries_name_claim_equal_to_login mints a real JWT via the IS token endpoint (password grant, openid profile) and asserts the access token carries name=login and GetUserName() resolves to it. - WebServerFixture: AccessTokenType=0 (Jwt in IS8; 1=Reference, the inverse of Duende); allow openid+profile on the test client; re-seed the openid/profile identity resources in ReseedAuthTestData (a ResetAndMigrateDatabase wipes the shared in-memory store and previously left them gone → invalid_scope). - BillingServiceTests: [Collection("Yavsc Server")] to serialize with the host build that mutates the static BillingService/Config registries (pre-existing parallel race that the new test's timing surfaced). Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:42:54 +01:00
using Microsoft.AspNetCore.Http;
using ImageMagick;
2016-11-30 16:45:37 +01:00
using Yavsc.Models;
2019-01-30 11:14:32 +00:00
using Yavsc.Models.Streaming;
using Yavsc.ViewModels;
2025-02-14 00:20:35 +00:00
using Yavsc.Exceptions;
2025-02-17 23:56:28 +00:00
using Yavsc.Abstract.Helpers;
2026-06-10 16:59:23 +01:00
using Yavsc.Server.Models.FileSystem;
2026-09-17 22:22:18 +01:00
using Yavsc.ViewModels.UserFiles;
Server: wire ProfileService so access tokens carry the name claim The custom ProfileService (Yavsc.Services.ProfileService) was never registered with IdentityServer, so IS8 fell back to its default profile service — which only emits claims listed in each API resource's UserClaims (RequestedClaimTypes). "name" was never among them, so every access token lacked "name" and GetUserName() resolved to null on all bearer resource hosts (Blogs/Api): the MyFiles upload 500 and the unreachable User.Identity.Name. The class was dead code; all prior tweaks to it (FullName→UserName) and to ServiceExtensions (MapInboundClaims=false) were necessary but insufficient until it was actually wired in. - HostingExtensions: AddProfileService<ProfileService>() after AddAspNetIdentity<ApplicationUser>(). - ProfileService: emit name=user.UserName, email, roles for any user grant whose client has "profile" in AllowedScopes, independent of the caller (the old ParsedScopes gate only fired for the id-token caller, never the access-token caller). - ServiceExtensions: JwtBearer NameClaimType=JwtClaimTypes.Name, MapInboundClaims=false (keep short claim names as-emitted). - FileSystemHelpers: EnsureDestinationDirectory uses GetUserName() (the "name" claim) instead of User.Identity.Name for the per-user storage root. Tests: - TestTokenIssuer: stamp a "name" claim = subject so test tokens mirror production shape (fixes FileSystemApiTests upload 500). - Remoting: PasswordToken_carries_name_claim_equal_to_login mints a real JWT via the IS token endpoint (password grant, openid profile) and asserts the access token carries name=login and GetUserName() resolves to it. - WebServerFixture: AccessTokenType=0 (Jwt in IS8; 1=Reference, the inverse of Duende); allow openid+profile on the test client; re-seed the openid/profile identity resources in ReseedAuthTestData (a ResetAndMigrateDatabase wipes the shared in-memory store and previously left them gone → invalid_scope). - BillingServiceTests: [Collection("Yavsc Server")] to serialize with the host build that mutates the static BillingService/Config registries (pre-existing parallel race that the new test's timing surfaced). Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:42:54 +01:00
2025-02-17 23:56:28 +00:00
namespace Yavsc.Server.Helpers
2016-05-17 18:22:18 +02:00
{
2026-05-28 22:18:26 +01:00
public static class FileSystemHelpers
2016-11-30 16:45:37 +01:00
{
2025-02-14 00:20:35 +00:00
public static async Task SaveAsAsync(this IFormFile formFile, string path)
{
2026-05-28 22:18:26 +01:00
if (formFile.Length > 0)
{
using (Stream fileStream = new FileStream(path, FileMode.Create))
{
2025-02-14 00:20:35 +00:00
await formFile.CopyToAsync(fileStream);
}
}
}
2026-09-17 22:22:18 +01:00
public static async Task<FileReceivedInfo> ReceiveProSignatureAsync(this ClaimsPrincipal user, string billingCode, long estimateId, IFormFile formFile, string signType,
SiteSettings settings
)
2016-11-30 16:45:37 +01:00
{
2025-03-04 18:05:21 +00:00
var item = new FileReceivedInfo(
2026-09-17 22:22:18 +01:00
settings.Bills,
AbstractFileSystemHelpers.SignFileNameFormat(signType, billingCode, estimateId));
2016-11-30 16:45:37 +01:00
2025-03-04 18:05:21 +00:00
var fi = new FileInfo(item.FullName);
if (fi.Exists) item.Overridden = true;
2026-05-28 22:18:26 +01:00
await formFile.SaveAsAsync(fi.FullName);
2018-03-26 19:27:29 +02:00
return item;
2016-11-14 12:17:07 +01:00
}
2025-11-16 15:53:13 +00:00
public static string EnsureDestinationDirectory(
2016-11-30 16:45:37 +01:00
this ClaimsPrincipal user,
2026-09-17 22:22:18 +01:00
string subpath, SiteSettings siteSettings)
2016-11-30 16:45:37 +01:00
{
Server: wire ProfileService so access tokens carry the name claim The custom ProfileService (Yavsc.Services.ProfileService) was never registered with IdentityServer, so IS8 fell back to its default profile service — which only emits claims listed in each API resource's UserClaims (RequestedClaimTypes). "name" was never among them, so every access token lacked "name" and GetUserName() resolved to null on all bearer resource hosts (Blogs/Api): the MyFiles upload 500 and the unreachable User.Identity.Name. The class was dead code; all prior tweaks to it (FullName→UserName) and to ServiceExtensions (MapInboundClaims=false) were necessary but insufficient until it was actually wired in. - HostingExtensions: AddProfileService<ProfileService>() after AddAspNetIdentity<ApplicationUser>(). - ProfileService: emit name=user.UserName, email, roles for any user grant whose client has "profile" in AllowedScopes, independent of the caller (the old ParsedScopes gate only fired for the id-token caller, never the access-token caller). - ServiceExtensions: JwtBearer NameClaimType=JwtClaimTypes.Name, MapInboundClaims=false (keep short claim names as-emitted). - FileSystemHelpers: EnsureDestinationDirectory uses GetUserName() (the "name" claim) instead of User.Identity.Name for the per-user storage root. Tests: - TestTokenIssuer: stamp a "name" claim = subject so test tokens mirror production shape (fixes FileSystemApiTests upload 500). - Remoting: PasswordToken_carries_name_claim_equal_to_login mints a real JWT via the IS token endpoint (password grant, openid profile) and asserts the access token carries name=login and GetUserName() resolves to it. - WebServerFixture: AccessTokenType=0 (Jwt in IS8; 1=Reference, the inverse of Duende); allow openid+profile on the test client; re-seed the openid/profile identity resources in ReseedAuthTestData (a ResetAndMigrateDatabase wipes the shared in-memory store and previously left them gone → invalid_scope). - BillingServiceTests: [Collection("Yavsc Server")] to serialize with the host build that mutates the static BillingService/Config registries (pre-existing parallel race that the new test's timing surfaced). Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:42:54 +01:00
var root = Path.Combine(siteSettings.Blog, user.GetUserName());
2025-11-16 15:53:13 +00:00
if (!string.IsNullOrWhiteSpace(subpath))
{
if (!subpath.IsValidYavscPath())
2016-11-30 16:45:37 +01:00
{
2017-04-11 01:20:00 +02:00
throw new InvalidPathException();
2016-11-30 16:45:37 +01:00
}
2017-04-11 01:20:00 +02:00
root = Path.Combine(root, subpath);
}
2017-09-02 17:09:09 +02:00
var di = new DirectoryInfo(root);
if (!di.Exists) di.Create();
2018-03-12 23:49:05 +01:00
return di.FullName;
2016-11-30 16:45:37 +01:00
}
2019-03-25 09:26:32 +00:00
/// <summary>
/// Deletes user file.
/// User info is modified, but not save in db.
/// </summary>
/// <param name="user"></param>
/// <param name="fileName"></param>
2026-09-17 22:22:18 +01:00
public static void DeleteUserFile(this ApplicationUser user, string fileName, SiteSettings siteSettings)
2017-05-28 00:42:27 +02:00
{
2026-09-17 22:22:18 +01:00
var root = Path.Combine(siteSettings.Blog, user.UserName);
2017-05-28 00:42:27 +02:00
var fi = new FileInfo(Path.Combine(root, fileName));
2026-05-28 22:18:26 +01:00
if (!fi.Exists) return;
2017-05-28 00:42:27 +02:00
fi.Delete();
user.DiskUsage -= fi.Length;
}
2026-05-28 22:18:26 +01:00
2026-09-17 22:22:18 +01:00
public static FsOperationInfo DeleteUserDirOrFile(this ApplicationUser user, string dirName, SiteSettings siteSettings)
2019-09-04 01:25:36 +01:00
{
2026-09-17 22:22:18 +01:00
var root = Path.Combine(siteSettings.Blog, user.UserName);
2026-05-28 22:18:26 +01:00
if (string.IsNullOrEmpty(dirName))
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a directory or file name" };
2019-09-04 01:25:36 +01:00
var di = new DirectoryInfo(Path.Combine(root, dirName));
2026-05-28 22:18:26 +01:00
if (!di.Exists)
{
2019-09-05 21:01:44 +01:00
var fi = new FileInfo(Path.Combine(root, dirName));
2026-05-28 22:18:26 +01:00
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "non existent" };
2019-09-05 21:01:44 +01:00
fi.Delete();
user.DiskUsage -= fi.Length;
}
2026-05-28 22:18:26 +01:00
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" };
2019-09-05 21:01:44 +01:00
di.Delete();
}
2019-09-04 01:25:36 +01:00
return new FsOperationInfo { Done = true };
}
2026-05-28 22:18:26 +01:00
2026-09-17 22:22:18 +01:00
public static FsOperationInfo MoveUserDir(this ApplicationUser user, string fromDirName, string toDirName, SiteSettings siteSettings)
2019-09-04 01:25:36 +01:00
{
2026-09-17 22:22:18 +01:00
var root = Path.Combine(siteSettings.Blog, user.UserName);
2026-05-28 22:18:26 +01:00
if (string.IsNullOrEmpty(fromDirName))
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a dir name " };
2019-09-04 01:25:36 +01:00
var di = new DirectoryInfo(Path.Combine(root, fromDirName));
2026-05-28 22:18:26 +01:00
if (!di.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "fromDirName: non existent" };
2019-09-04 01:25:36 +01:00
2019-09-08 05:17:21 +01:00
if (string.IsNullOrEmpty(toDirName)) toDirName = ".";
2019-09-04 01:25:36 +01:00
var destPath = Path.Combine(root, toDirName);
2019-09-08 05:17:21 +01:00
var fout = new FileInfo(destPath);
2026-05-28 22:18:26 +01:00
if (fout.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "destination is a regular file" };
2019-09-04 01:25:36 +01:00
2026-05-28 22:18:26 +01:00
var dOut = new DirectoryInfo(destPath);
if (dOut.Exists)
{
destPath = Path.Combine(destPath, dOut.Name);
2019-09-04 01:25:36 +01:00
}
2026-05-28 22:18:26 +01:00
di.MoveTo(destPath);
2019-09-04 01:25:36 +01:00
return new FsOperationInfo { Done = true };
}
2026-09-17 22:22:18 +01:00
public static FsOperationInfo MoveUserFileToDir(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
2019-09-08 05:17:21 +01:00
{
2026-09-17 22:22:18 +01:00
var root = Path.Combine(siteSettings.Blog, user.UserName);
2019-09-08 05:17:21 +01:00
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
2026-09-17 22:22:18 +01:00
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
2019-09-08 05:17:21 +01:00
string dest;
if (!string.IsNullOrEmpty(fileNameDest)) dest = Path.Combine(root, fileNameDest);
else dest = root;
var fo = new FileInfo(dest);
2026-09-17 22:22:18 +01:00
if (fo.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.DestExists, ErrorMessage = "destination file name is an existing file" };
2019-09-08 05:17:21 +01:00
var dout = new DirectoryInfo(dest);
if (!dout.Exists) dout.Create();
fi.MoveTo(Path.Combine(dout.FullName, fi.Name));
return new FsOperationInfo { Done = true };
}
2026-09-17 22:22:18 +01:00
public static FsOperationInfo MoveUserFile(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
2019-09-04 01:25:36 +01:00
{
2026-09-17 22:22:18 +01:00
var root = Path.Combine(siteSettings.Blog, user.UserName);
2019-09-04 01:25:36 +01:00
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
2026-09-17 22:22:18 +01:00
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
2019-09-04 01:25:36 +01:00
var fo = new FileInfo(Path.Combine(root, fileNameDest));
2026-09-17 22:22:18 +01:00
if (fo.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.DestExists, ErrorMessage = "destination file name is an existing file" };
2019-09-04 01:25:36 +01:00
fi.MoveTo(fo.FullName);
return new FsOperationInfo { Done = true };
}
public static void AddQuota(this ApplicationUser user, int quota)
{
user.DiskQuota += quota;
}
2025-03-04 18:05:21 +00:00
public static FileReceivedInfo ReceiveUserFile(this ApplicationUser user, string root, IFormFile f, string destFileName = null)
2016-11-30 16:45:37 +01:00
{
2026-09-16 18:23:27 +01:00
// 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);
2019-06-27 10:30:28 +01:00
}
2026-05-28 22:18:26 +01:00
2026-03-01 14:09:10 +00:00
/// <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>
///
2026-03-01 14:09:10 +00:00
/// </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>
2025-03-04 18:05:21 +00:00
public static FileReceivedInfo ReceiveUserFile(this ApplicationUser user, string root, Stream inputStream, string destFileName, string contentType, CancellationToken token)
2019-06-27 10:30:28 +01:00
{
// TODO lock user's disk usage for this scope,
// this process is not safe at concurrent access.
long usage = user.DiskUsage;
2016-11-30 16:45:37 +01:00
2025-03-04 18:05:21 +00:00
var item = new FileReceivedInfo
(root, AbstractFileSystemHelpers.FilterFileName(destFileName));
2019-06-27 10:30:28 +01:00
var fi = new FileInfo(Path.Combine(root, item.FileName));
if (fi.Exists)
{
2025-03-04 18:05:21 +00:00
item.Overridden = true;
2019-06-27 10:30:28 +01:00
usage -= fi.Length;
2026-05-28 22:18:26 +01:00
}
2019-06-27 10:30:28 +01:00
using (var dest = fi.OpenWrite())
{
using (inputStream)
{
const int blen = 1024;
byte[] buffer = new byte[blen];
int len = 0;
2026-05-28 22:18:26 +01:00
while (!token.IsCancellationRequested && (len = inputStream.Read(buffer, 0, blen)) > 0)
2016-11-30 16:45:37 +01:00
{
2019-06-27 10:30:28 +01:00
dest.Write(buffer, 0, len);
usage += len;
if (usage >= user.DiskQuota) break;
2016-11-30 16:45:37 +01:00
}
user.DiskUsage = usage;
2016-11-30 16:45:37 +01:00
dest.Close();
2019-06-27 10:30:28 +01:00
inputStream.Close();
2016-11-30 16:45:37 +01:00
}
}
2026-05-28 22:18:26 +01:00
if (usage >= user.DiskQuota)
{
2025-03-04 18:05:21 +00:00
item.QuotaOffense = true;
}
2016-12-01 17:34:29 +01:00
user.DiskUsage = usage;
2016-11-30 16:45:37 +01:00
return item;
}
2019-06-27 10:30:28 +01:00
public static HtmlString FileLink(this RemoteFileInfo info, string username, string subpath)
{
2026-05-28 22:18:26 +01:00
return new HtmlString(
$"{Config.UserFilesOptions.RequestPath}/{username}/{subpath}/{info.Name}");
}
2019-09-04 01:25:36 +01:00
public static RemoteFileInfo FileInfo(this ApplicationUser user, string path)
{
2024-02-25 18:05:10 +00:00
IFileInfo info = Config.UserFilesOptions.FileProvider.GetFileInfo($"{user.UserName}/{path}");
2019-09-04 01:25:36 +01:00
if (!info.Exists) return null;
2026-05-28 22:18:26 +01:00
return new RemoteFileInfo { Name = info.Name, Size = info.Length, LastModified = info.LastModified.UtcDateTime };
2019-09-04 01:25:36 +01:00
}
2026-09-17 22:22:18 +01:00
public static FileReceivedInfo ReceiveAvatar(this ApplicationUser user,
IFormFile formFile,
SiteSettings settings)
2016-12-01 17:34:29 +01:00
{
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;
}
2026-09-17 22:22:18 +01:00
var avatarsDirectory = settings.Avatars;
if (string.IsNullOrWhiteSpace(avatarsDirectory))
{
avatarsDirectory = "avatars";
}
Directory.CreateDirectory(avatarsDirectory);
2025-03-04 18:05:21 +00:00
var item = new FileReceivedInfo
(avatarsRequestPath,
2025-03-04 18:05:21 +00:00
user.UserName + ".png");
2026-05-28 22:18:26 +01:00
2016-12-01 17:34:29 +01:00
using (var org = formFile.OpenReadStream())
{
2026-05-28 22:18:26 +01:00
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));
2026-05-28 22:18:26 +01:00
size.X = 64;
size.Y = 64;
image.Resize(size);
image.Write(Path.Combine(avatarsDirectory, user.UserName + ".s.png"));
2026-05-28 22:18:26 +01:00
size.X = 32;
size.Y = 32;
image.Resize(size);
image.Write(Path.Combine(avatarsDirectory, user.UserName + ".xs.png"));
2026-05-28 22:18:26 +01:00
2016-12-01 17:34:29 +01:00
}
2026-05-28 22:18:26 +01:00
2017-02-01 19:53:10 +01:00
user.Avatar = $"{item.DestDir}/{item.FileName}";
2016-12-01 17:34:29 +01:00
return item;
}
2026-05-28 22:18:26 +01:00
public static string GetFileUrl(this LiveFlow flow)
2019-01-30 11:14:32 +00:00
{
// no server-side backup for this stream
2026-05-28 22:18:26 +01:00
return $"{Config.UserFilesOptions.RequestPath}/{flow.Owner.UserName}/live/" + GetFileName(flow);
2019-06-28 00:49:20 +01:00
}
2026-05-28 22:18:26 +01:00
public static string GetFileName(this LiveFlow flow)
2019-06-28 00:49:20 +01:00
{
2019-01-30 11:14:32 +00:00
var fileInfo = new FileInfo(flow.DifferedFileName);
var ext = fileInfo.Extension;
var namelen = flow.DifferedFileName.Length - ext.Length;
2026-05-28 22:18:26 +01:00
var basename = flow.DifferedFileName.Substring(0, namelen);
2019-06-28 00:49:20 +01:00
return $"{basename}-{flow.SequenceNumber}{ext}";
2019-01-30 11:14:32 +00:00
}
2023-03-19 17:57:55 +00:00
2026-09-17 22:22:18 +01:00
public static UserDirectoryInfo GetUserFiles(SiteSettings settings, string userId, string subdir)
{
UserDirectoryInfo di = new UserDirectoryInfo(settings.Blog, userId, subdir);
return di;
}
2016-12-01 17:34:29 +01:00
}
2016-05-17 18:22:18 +02:00
}