Merge pull request #50 from v0l/minor4v1

Minor4v1
This commit is contained in:
Kieran 2022-07-26 13:35:39 +01:00 committed by GitHub
commit ab917b0413
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 971 additions and 176 deletions

View File

@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using VoidCat.Model; using VoidCat.Model;
using VoidCat.Services.Abstractions; using VoidCat.Services.Abstractions;
using VoidCat.Services.Files;
namespace VoidCat.Controllers.Admin; namespace VoidCat.Controllers.Admin;
@ -9,13 +10,13 @@ namespace VoidCat.Controllers.Admin;
[Authorize(Policy = Policies.RequireAdmin)] [Authorize(Policy = Policies.RequireAdmin)]
public class AdminController : Controller public class AdminController : Controller
{ {
private readonly IFileStore _fileStore; private readonly FileStoreFactory _fileStore;
private readonly IFileMetadataStore _fileMetadata; private readonly IFileMetadataStore _fileMetadata;
private readonly IFileInfoManager _fileInfo; private readonly IFileInfoManager _fileInfo;
private readonly IUserStore _userStore; private readonly IUserStore _userStore;
private readonly IUserUploadsStore _userUploads; private readonly IUserUploadsStore _userUploads;
public AdminController(IFileStore fileStore, IUserStore userStore, IFileInfoManager fileInfo, public AdminController(FileStoreFactory fileStore, IUserStore userStore, IFileInfoManager fileInfo,
IFileMetadataStore fileMetadata, IUserUploadsStore userUploads) IFileMetadataStore fileMetadata, IUserUploadsStore userUploads)
{ {
_fileStore = fileStore; _fileStore = fileStore;
@ -64,7 +65,7 @@ public class AdminController : Controller
/// <param name="request">Page request</param> /// <param name="request">Page request</param>
/// <returns></returns> /// <returns></returns>
[HttpPost] [HttpPost]
[Route("user")] [Route("users")]
public async Task<RenderedResults<AdminListedUser>> ListUsers([FromBody] PagedRequest request) public async Task<RenderedResults<AdminListedUser>> ListUsers([FromBody] PagedRequest request)
{ {
var result = await _userStore.ListUsers(request); var result = await _userStore.ListUsers(request);
@ -74,6 +75,7 @@ public class AdminController : Controller
var uploads = await _userUploads.ListFiles(a.Id, new(0, int.MaxValue)); var uploads = await _userUploads.ListFiles(a.Id, new(0, int.MaxValue));
return new AdminListedUser(a, uploads.TotalResults); return new AdminListedUser(a, uploads.TotalResults);
}).ToListAsync(); }).ToListAsync();
return new() return new()
{ {
PageSize = request.PageSize, PageSize = request.PageSize,
@ -83,5 +85,21 @@ public class AdminController : Controller
}; };
} }
/// <summary>
/// Admin update user account
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
[HttpPost]
[Route("update-user")]
public async Task<IActionResult> UpdateUser([FromBody] PrivateVoidUser user)
{
var oldUser = await _userStore.Get(user.Id);
if (oldUser == default) return BadRequest();
await _userStore.AdminUpdateUser(user);
return Ok();
}
public record AdminListedUser(PrivateVoidUser User, int Uploads); public record AdminListedUser(PrivateVoidUser User, int Uploads);
} }

View File

@ -15,12 +15,17 @@ public class AuthController : Controller
private readonly IUserManager _manager; private readonly IUserManager _manager;
private readonly VoidSettings _settings; private readonly VoidSettings _settings;
private readonly ICaptchaVerifier _captchaVerifier; private readonly ICaptchaVerifier _captchaVerifier;
private readonly IApiKeyStore _apiKeyStore;
private readonly IUserStore _userStore;
public AuthController(IUserManager userStore, VoidSettings settings, ICaptchaVerifier captchaVerifier) public AuthController(IUserManager userStore, VoidSettings settings, ICaptchaVerifier captchaVerifier, IApiKeyStore apiKeyStore,
IUserStore userStore1)
{ {
_manager = userStore; _manager = userStore;
_settings = settings; _settings = settings;
_captchaVerifier = captchaVerifier; _captchaVerifier = captchaVerifier;
_apiKeyStore = apiKeyStore;
_userStore = userStore1;
} }
/// <summary> /// <summary>
@ -39,15 +44,15 @@ public class AuthController : Controller
var error = ControllerContext.ModelState.FirstOrDefault().Value?.Errors.FirstOrDefault()?.ErrorMessage; var error = ControllerContext.ModelState.FirstOrDefault().Value?.Errors.FirstOrDefault()?.ErrorMessage;
return new(null, error); return new(null, error);
} }
// check captcha // check captcha
if (!await _captchaVerifier.Verify(req.Captcha)) if (!await _captchaVerifier.Verify(req.Captcha))
{ {
return new(null, "Captcha verification failed"); return new(null, "Captcha verification failed");
} }
var user = await _manager.Login(req.Username, req.Password); var user = await _manager.Login(req.Username, req.Password);
var token = CreateToken(user); var token = CreateToken(user, DateTime.UtcNow.AddHours(12));
var tokenWriter = new JwtSecurityTokenHandler(); var tokenWriter = new JwtSecurityTokenHandler();
return new(tokenWriter.WriteToken(token), Profile: user.ToPublic()); return new(tokenWriter.WriteToken(token), Profile: user.ToPublic());
} }
@ -73,15 +78,15 @@ public class AuthController : Controller
var error = ControllerContext.ModelState.FirstOrDefault().Value?.Errors.FirstOrDefault()?.ErrorMessage; var error = ControllerContext.ModelState.FirstOrDefault().Value?.Errors.FirstOrDefault()?.ErrorMessage;
return new(null, error); return new(null, error);
} }
// check captcha // check captcha
if (!await _captchaVerifier.Verify(req.Captcha)) if (!await _captchaVerifier.Verify(req.Captcha))
{ {
return new(null, "Captcha verification failed"); return new(null, "Captcha verification failed");
} }
var newUser = await _manager.Register(req.Username, req.Password); var newUser = await _manager.Register(req.Username, req.Password);
var token = CreateToken(newUser); var token = CreateToken(newUser, DateTime.UtcNow.AddHours(12));
var tokenWriter = new JwtSecurityTokenHandler(); var tokenWriter = new JwtSecurityTokenHandler();
return new(tokenWriter.WriteToken(token), Profile: newUser.ToPublic()); return new(tokenWriter.WriteToken(token), Profile: newUser.ToPublic());
} }
@ -91,7 +96,56 @@ public class AuthController : Controller
} }
} }
private JwtSecurityToken CreateToken(VoidUser user) /// <summary>
/// List api keys for the user
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet]
[Route("api-key")]
public async Task<IActionResult> ListApiKeys()
{
var uid = HttpContext.GetUserId();
if (uid == default) return Unauthorized();
return Json(await _apiKeyStore.ListKeys(uid.Value));
}
/// <summary>
/// Create a new API key for the logged in user
/// </summary>
/// <param name="id"></param>
/// <param name="request"></param>
/// <returns></returns>
[HttpPost]
[Route("api-key")]
public async Task<IActionResult> CreateApiKey([FromBody] CreateApiKeyRequest request)
{
var uid = HttpContext.GetUserId();
if (uid == default) return Unauthorized();
var user = await _userStore.Get(uid.Value);
if (user == default) return Unauthorized();
var expiry = DateTime.SpecifyKind(request.Expiry, DateTimeKind.Utc);
if (expiry > DateTime.UtcNow.AddYears(1))
{
return BadRequest();
}
var key = new ApiKey()
{
Id = Guid.NewGuid(),
UserId = user.Id,
Token = new JwtSecurityTokenHandler().WriteToken(CreateToken(user, expiry)),
Expiry = expiry
};
await _apiKeyStore.Add(key.Id, key);
return Json(key);
}
private JwtSecurityToken CreateToken(VoidUser user, DateTime expiry)
{ {
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.JwtSettings.Key)); var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.JwtSettings.Key));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
@ -99,16 +153,16 @@ public class AuthController : Controller
var claims = new List<Claim>() var claims = new List<Claim>()
{ {
new(ClaimTypes.NameIdentifier, user.Id.ToString()), new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(JwtRegisteredClaimNames.Exp, DateTimeOffset.UtcNow.AddHours(6).ToUnixTimeSeconds().ToString()), new(JwtRegisteredClaimNames.Exp, new DateTimeOffset(expiry).ToUnixTimeSeconds().ToString()),
new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()) new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString())
}; };
claims.AddRange(user.Roles.Select(a => new Claim(ClaimTypes.Role, a))); claims.AddRange(user.Roles.Select(a => new Claim(ClaimTypes.Role, a)));
return new JwtSecurityToken(_settings.JwtSettings.Issuer, claims: claims, return new JwtSecurityToken(_settings.JwtSettings.Issuer, claims: claims,
signingCredentials: credentials); signingCredentials: credentials);
} }
public sealed class LoginRequest public sealed class LoginRequest
{ {
public LoginRequest(string username, string password) public LoginRequest(string username, string password)
@ -120,7 +174,7 @@ public class AuthController : Controller
[Required] [Required]
[EmailAddress] [EmailAddress]
public string Username { get; } public string Username { get; }
[Required] [Required]
[MinLength(6)] [MinLength(6)]
public string Password { get; } public string Password { get; }
@ -129,4 +183,7 @@ public class AuthController : Controller
} }
public sealed record LoginResponse(string? Jwt, string? Error = null, VoidUser? Profile = null); public sealed record LoginResponse(string? Jwt, string? Error = null, VoidUser? Profile = null);
public sealed record CreateApiKeyRequest(DateTime Expiry);
} }

View File

@ -3,18 +3,19 @@ using Microsoft.AspNetCore.Mvc;
using VoidCat.Model; using VoidCat.Model;
using VoidCat.Model.Paywall; using VoidCat.Model.Paywall;
using VoidCat.Services.Abstractions; using VoidCat.Services.Abstractions;
using VoidCat.Services.Files;
namespace VoidCat.Controllers; namespace VoidCat.Controllers;
[Route("d")] [Route("d")]
public class DownloadController : Controller public class DownloadController : Controller
{ {
private readonly IFileStore _storage; private readonly FileStoreFactory _storage;
private readonly IFileInfoManager _fileInfo; private readonly IFileInfoManager _fileInfo;
private readonly IPaywallOrderStore _paywallOrders; private readonly IPaywallOrderStore _paywallOrders;
private readonly ILogger<DownloadController> _logger; private readonly ILogger<DownloadController> _logger;
public DownloadController(IFileStore storage, ILogger<DownloadController> logger, IFileInfoManager fileInfo, public DownloadController(FileStoreFactory storage, ILogger<DownloadController> logger, IFileInfoManager fileInfo,
IPaywallOrderStore paywall) IPaywallOrderStore paywall)
{ {
_storage = storage; _storage = storage;
@ -44,7 +45,7 @@ public class DownloadController : Controller
var voidFile = await SetupDownload(gid); var voidFile = await SetupDownload(gid);
if (voidFile == default) return; if (voidFile == default) return;
var egressReq = new EgressRequest(gid, GetRanges(Request, (long) voidFile!.Metadata!.Size)); var egressReq = new EgressRequest(gid, GetRanges(Request, (long)voidFile!.Metadata!.Size));
if (egressReq.Ranges.Count() > 1) if (egressReq.Ranges.Count() > 1)
{ {
_logger.LogWarning("Multi-range request not supported!"); _logger.LogWarning("Multi-range request not supported!");
@ -56,10 +57,10 @@ public class DownloadController : Controller
} }
else if (egressReq.Ranges.Count() == 1) else if (egressReq.Ranges.Count() == 1)
{ {
Response.StatusCode = (int) HttpStatusCode.PartialContent; Response.StatusCode = (int)HttpStatusCode.PartialContent;
if (egressReq.Ranges.Sum(a => a.Size) == 0) if (egressReq.Ranges.Sum(a => a.Size) == 0)
{ {
Response.StatusCode = (int) HttpStatusCode.RequestedRangeNotSatisfiable; Response.StatusCode = (int)HttpStatusCode.RequestedRangeNotSatisfiable;
return; return;
} }
} }
@ -74,6 +75,15 @@ public class DownloadController : Controller
Response.ContentLength = range.Size; Response.ContentLength = range.Size;
} }
var preResult = await _storage.StartEgress(egressReq);
if (preResult.Redirect != null)
{
Response.StatusCode = (int)HttpStatusCode.Redirect;
Response.Headers.Location = preResult.Redirect.ToString();
Response.ContentLength = 0;
return;
}
var cts = HttpContext.RequestAborted; var cts = HttpContext.RequestAborted;
await Response.StartAsync(cts); await Response.StartAsync(cts);
await _storage.Egress(egressReq, Response.Body, cts); await _storage.Egress(egressReq, Response.Body, cts);
@ -95,7 +105,7 @@ public class DownloadController : Controller
var orderId = Request.Headers.GetHeader("V-OrderId") ?? Request.Query["orderId"]; var orderId = Request.Headers.GetHeader("V-OrderId") ?? Request.Query["orderId"];
if (!await IsOrderPaid(orderId)) if (!await IsOrderPaid(orderId))
{ {
Response.StatusCode = (int) HttpStatusCode.PaymentRequired; Response.StatusCode = (int)HttpStatusCode.PaymentRequired;
return default; return default;
} }
} }
@ -136,4 +146,4 @@ public class DownloadController : Controller
} }
} }
} }
} }

View File

@ -11,14 +11,16 @@ public class InfoController : Controller
private readonly IFileMetadataStore _fileMetadata; private readonly IFileMetadataStore _fileMetadata;
private readonly VoidSettings _settings; private readonly VoidSettings _settings;
private readonly ITimeSeriesStatsReporter _timeSeriesStats; private readonly ITimeSeriesStatsReporter _timeSeriesStats;
private readonly IEnumerable<string?> _fileStores;
public InfoController(IStatsReporter statsReporter, IFileMetadataStore fileMetadata, VoidSettings settings, public InfoController(IStatsReporter statsReporter, IFileMetadataStore fileMetadata, VoidSettings settings,
ITimeSeriesStatsReporter stats) ITimeSeriesStatsReporter stats, IEnumerable<IFileStore> fileStores)
{ {
_statsReporter = statsReporter; _statsReporter = statsReporter;
_fileMetadata = fileMetadata; _fileMetadata = fileMetadata;
_settings = settings; _settings = settings;
_timeSeriesStats = stats; _timeSeriesStats = stats;
_fileStores = fileStores.Select(a => a.Key);
} }
/// <summary> /// <summary>
@ -34,9 +36,10 @@ public class InfoController : Controller
return new(bw, storeStats.Size, storeStats.Files, BuildInfo.GetBuildInfo(), return new(bw, storeStats.Size, storeStats.Files, BuildInfo.GetBuildInfo(),
_settings.CaptchaSettings?.SiteKey, _settings.CaptchaSettings?.SiteKey,
await _timeSeriesStats.GetBandwidth(DateTime.UtcNow.AddDays(-30), DateTime.UtcNow)); await _timeSeriesStats.GetBandwidth(DateTime.UtcNow.AddDays(-30), DateTime.UtcNow),
_fileStores);
} }
public sealed record GlobalInfo(Bandwidth Bandwidth, ulong TotalBytes, long Count, BuildInfo BuildInfo, public sealed record GlobalInfo(Bandwidth Bandwidth, ulong TotalBytes, long Count, BuildInfo BuildInfo,
string? CaptchaSiteKey, IEnumerable<BandwidthPoint> TimeSeriesMetrics); string? CaptchaSiteKey, IEnumerable<BandwidthPoint> TimeSeriesMetrics, IEnumerable<string?> FileStores);
} }

View File

@ -6,23 +6,26 @@ using Newtonsoft.Json;
using VoidCat.Model; using VoidCat.Model;
using VoidCat.Model.Paywall; using VoidCat.Model.Paywall;
using VoidCat.Services.Abstractions; using VoidCat.Services.Abstractions;
using VoidCat.Services.Files;
namespace VoidCat.Controllers namespace VoidCat.Controllers
{ {
[Route("upload")] [Route("upload")]
public class UploadController : Controller public class UploadController : Controller
{ {
private readonly IFileStore _storage; private readonly FileStoreFactory _storage;
private readonly IFileMetadataStore _metadata; private readonly IFileMetadataStore _metadata;
private readonly IPaywallStore _paywall; private readonly IPaywallStore _paywall;
private readonly IPaywallFactory _paywallFactory; private readonly IPaywallFactory _paywallFactory;
private readonly IFileInfoManager _fileInfo; private readonly IFileInfoManager _fileInfo;
private readonly IUserUploadsStore _userUploads; private readonly IUserUploadsStore _userUploads;
private readonly IUserStore _userStore;
private readonly ITimeSeriesStatsReporter _timeSeriesStats; private readonly ITimeSeriesStatsReporter _timeSeriesStats;
private readonly VoidSettings _settings;
public UploadController(IFileStore storage, IFileMetadataStore metadata, IPaywallStore paywall, public UploadController(FileStoreFactory storage, IFileMetadataStore metadata, IPaywallStore paywall,
IPaywallFactory paywallFactory, IFileInfoManager fileInfo, IUserUploadsStore userUploads, IPaywallFactory paywallFactory, IFileInfoManager fileInfo, IUserUploadsStore userUploads,
ITimeSeriesStatsReporter timeSeriesStats) ITimeSeriesStatsReporter timeSeriesStats, IUserStore userStore, VoidSettings settings)
{ {
_storage = storage; _storage = storage;
_metadata = metadata; _metadata = metadata;
@ -31,6 +34,8 @@ namespace VoidCat.Controllers
_fileInfo = fileInfo; _fileInfo = fileInfo;
_userUploads = userUploads; _userUploads = userUploads;
_timeSeriesStats = timeSeriesStats; _timeSeriesStats = timeSeriesStats;
_userStore = userStore;
_settings = settings;
} }
/// <summary> /// <summary>
@ -65,20 +70,29 @@ namespace VoidCat.Controllers
} }
} }
// detect store for ingress
var store = _settings.DefaultFileStore;
if (uid.HasValue)
{
var user = await _userStore.Get<InternalVoidUser>(uid.Value);
if (user?.Storage != default)
{
store = user.Storage!;
}
}
var meta = new SecretVoidFileMeta var meta = new SecretVoidFileMeta
{ {
MimeType = mime, MimeType = mime,
Name = filename, Name = filename,
Description = Request.Headers.GetHeader("V-Description"), Description = Request.Headers.GetHeader("V-Description"),
Digest = Request.Headers.GetHeader("V-Full-Digest"), Digest = Request.Headers.GetHeader("V-Full-Digest"),
Size = (ulong?) Request.ContentLength ?? 0UL Size = (ulong?)Request.ContentLength ?? 0UL,
Storage = store
}; };
var digest = Request.Headers.GetHeader("V-Digest"); var (segment, totalSegments) = ParseSegmentsHeader();
var vf = await _storage.Ingress(new(Request.Body, meta) var vf = await _storage.Ingress(new(Request.Body, meta, segment, totalSegments), HttpContext.RequestAborted);
{
Hash = digest
}, HttpContext.RequestAborted);
// save metadata // save metadata
await _metadata.Set(vf.Id, vf.Metadata!); await _metadata.Set(vf.Id, vf.Metadata!);
@ -130,14 +144,20 @@ namespace VoidCat.Controllers
var meta = await _metadata.Get<SecretVoidFileMeta>(gid); var meta = await _metadata.Get<SecretVoidFileMeta>(gid);
if (meta == default) return UploadResult.Error("File not found"); if (meta == default) return UploadResult.Error("File not found");
var editSecret = Request.Headers.GetHeader("V-EditSecret"); // Parse V-Segment header
var digest = Request.Headers.GetHeader("V-Digest"); var (segment, totalSegments) = ParseSegmentsHeader();
var vf = await _storage.Ingress(new(Request.Body, meta)
// sanity check for append operations
if (segment <= 1 || totalSegments <= 1)
{
return UploadResult.Error("Malformed request, segment must be > 1 for append");
}
var editSecret = Request.Headers.GetHeader("V-EditSecret");
var vf = await _storage.Ingress(new(Request.Body, meta, segment, totalSegments)
{ {
Hash = digest,
EditSecret = editSecret?.FromBase58Guid() ?? Guid.Empty, EditSecret = editSecret?.FromBase58Guid() ?? Guid.Empty,
Id = gid, Id = gid
IsAppend = true
}, HttpContext.RequestAborted); }, HttpContext.RequestAborted);
// update file size // update file size
@ -160,6 +180,7 @@ namespace VoidCat.Controllers
public async Task<IActionResult> GetInfo([FromRoute] string id) public async Task<IActionResult> GetInfo([FromRoute] string id)
{ {
if (!id.TryFromBase58Guid(out var fid)) return StatusCode(404); if (!id.TryFromBase58Guid(out var fid)) return StatusCode(404);
var uid = HttpContext.GetUserId(); var uid = HttpContext.GetUserId();
var isOwner = uid.HasValue && await _userUploads.Uploader(fid) == uid; var isOwner = uid.HasValue && await _userUploads.Uploader(fid) == uid;
@ -240,6 +261,7 @@ namespace VoidCat.Controllers
Handle = req.Strike.Handle, Handle = req.Strike.Handle,
Cost = req.Strike.Cost Cost = req.Strike.Cost
}); });
return Ok(); return Ok();
} }
@ -269,6 +291,24 @@ namespace VoidCat.Controllers
await _metadata.Update(gid, fileMeta); await _metadata.Update(gid, fileMeta);
return Ok(); return Ok();
} }
private (int Segment, int TotalSegments) ParseSegmentsHeader()
{
// Parse V-Segment header
int segment = 1, totalSegments = 1;
var segmentHeader = Request.Headers.GetHeader("V-Segment");
if (!string.IsNullOrEmpty(segmentHeader))
{
var split = segmentHeader.Split("/");
if (split.Length == 2 && int.TryParse(split[0], out var a) && int.TryParse(split[1], out var b))
{
segment = a;
totalSegments = b;
}
}
return (segment, totalSegments);
}
} }
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
@ -303,4 +343,4 @@ namespace VoidCat.Controllers
public StrikePaywallConfig? Strike { get; init; } public StrikePaywallConfig? Strike { get; init; }
} }
} }

View File

@ -12,7 +12,8 @@ public class UserController : Controller
private readonly IEmailVerification _emailVerification; private readonly IEmailVerification _emailVerification;
private readonly IFileInfoManager _fileInfoManager; private readonly IFileInfoManager _fileInfoManager;
public UserController(IUserStore store, IUserUploadsStore userUploads, IEmailVerification emailVerification, IFileInfoManager fileInfoManager) public UserController(IUserStore store, IUserUploadsStore userUploads, IEmailVerification emailVerification,
IFileInfoManager fileInfoManager)
{ {
_store = store; _store = store;
_userUploads = userUploads; _userUploads = userUploads;
@ -42,6 +43,7 @@ public class UserController : Controller
{ {
var pUser = await _store.Get<PrivateVoidUser>(requestedId); var pUser = await _store.Get<PrivateVoidUser>(requestedId);
if (pUser == default) return NotFound(); if (pUser == default) return NotFound();
return Json(pUser); return Json(pUser);
} }
@ -161,4 +163,4 @@ public class UserController : Controller
var gid = id.FromBase58Guid(); var gid = id.FromBase58Guid();
return await _store.Get<InternalVoidUser>(gid); return await _store.Get<InternalVoidUser>(gid);
} }
} }

18
VoidCat/Model/ApiKey.cs Normal file
View File

@ -0,0 +1,18 @@
using Newtonsoft.Json;
namespace VoidCat.Model;
public sealed class ApiKey
{
[JsonConverter(typeof(Base58GuidConverter))]
public Guid Id { get; init; }
[JsonConverter(typeof(Base58GuidConverter))]
public Guid UserId { get; init; }
public string Token { get; init; }
public DateTime Expiry { get; init; }
public DateTime Created { get; init; }
}

View File

@ -1,7 +1,5 @@
using VoidCat.Services.Abstractions;
namespace VoidCat.Model; namespace VoidCat.Model;
public sealed record EgressRequest(Guid Id, IEnumerable<RangeRequest> Ranges) public sealed record EgressRequest(Guid Id, IEnumerable<RangeRequest> Ranges);
{
} public sealed record EgressResult(Uri? Redirect = null);

View File

@ -1,10 +1,11 @@
namespace VoidCat.Model; namespace VoidCat.Model;
public sealed record IngressPayload(Stream InStream, SecretVoidFileMeta Meta) public sealed record IngressPayload(Stream InStream, SecretVoidFileMeta Meta, int Segment, int TotalSegments)
{ {
public Guid Id { get; init; } = Guid.NewGuid(); public Guid Id { get; init; } = Guid.NewGuid();
public Guid? EditSecret { get; init; } public Guid? EditSecret { get; init; }
public string? Hash { get; init; }
public bool IsAppend => Segment > 1 && IsMultipart;
public bool IsAppend { get; init; }
public bool IsMultipart => TotalSegments > 1;
} }

View File

@ -70,6 +70,11 @@ public record VoidFileMeta : IVoidFileMeta
/// Time when the file will expire and be deleted /// Time when the file will expire and be deleted
/// </summary> /// </summary>
public DateTimeOffset? Expires { get; set; } public DateTimeOffset? Expires { get; set; }
/// <summary>
/// What storage system the file is on
/// </summary>
public string? Storage { get; set; }
} }
/// <summary> /// <summary>

View File

@ -75,6 +75,16 @@ namespace VoidCat.Model
/// Prometheus server for querying metrics /// Prometheus server for querying metrics
/// </summary> /// </summary>
public Uri? Prometheus { get; init; } public Uri? Prometheus { get; init; }
/// <summary>
/// Select where to store metadata, if not set "local-disk" will be used
/// </summary>
public string MetadataStore { get; init; } = "local-disk";
/// <summary>
/// Select which store to use for files storage, if not set "local-disk" will be used
/// </summary>
public string DefaultFileStore { get; init; } = "local-disk";
} }
public sealed class TorSettings public sealed class TorSettings
@ -99,17 +109,18 @@ namespace VoidCat.Model
public sealed class CloudStorageSettings public sealed class CloudStorageSettings
{ {
public bool ServeFromCloud { get; init; } public S3BlobConfig[]? S3 { get; init; }
public S3BlobConfig? S3 { get; set; }
} }
public sealed class S3BlobConfig public sealed class S3BlobConfig
{ {
public string Name { get; init; } = null!;
public string? AccessKey { get; init; } public string? AccessKey { get; init; }
public string? SecretKey { get; init; } public string? SecretKey { get; init; }
public Uri? ServiceUrl { get; init; } public Uri? ServiceUrl { get; init; }
public string? Region { get; init; } public string? Region { get; init; }
public string? BucketName { get; init; } = "void-cat"; public string? BucketName { get; init; } = "void-cat";
public bool Direct { get; init; }
} }
public sealed class VirusScannerSettings public sealed class VirusScannerSettings

View File

@ -82,7 +82,12 @@ public class PrivateVoidUser : VoidUser
/// <summary> /// <summary>
/// Users email address /// Users email address
/// </summary> /// </summary>
public string Email { get; init; } = null!; public string Email { get; set; } = null!;
/// <summary>
/// Users storage system for new uploads
/// </summary>
public string? Storage { get; set; }
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@ -0,0 +1,16 @@
using VoidCat.Model;
namespace VoidCat.Services.Abstractions;
/// <summary>
/// Api key store
/// </summary>
public interface IApiKeyStore : IBasicStore<ApiKey>
{
/// <summary>
/// Return a list of Api keys for a given user
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
ValueTask<IReadOnlyList<ApiKey>> ListKeys(Guid id);
}

View File

@ -2,5 +2,10 @@
public interface ICaptchaVerifier public interface ICaptchaVerifier
{ {
/// <summary>
/// Verify captcha token is valid
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
ValueTask<bool> Verify(string? token); ValueTask<bool> Verify(string? token);
} }

View File

@ -7,6 +7,11 @@ namespace VoidCat.Services.Abstractions;
/// </summary> /// </summary>
public interface IFileStore public interface IFileStore
{ {
/// <summary>
/// Return key for named instance
/// </summary>
string? Key { get; }
/// <summary> /// <summary>
/// Ingress a file into the system (Upload) /// Ingress a file into the system (Upload)
/// </summary> /// </summary>
@ -24,6 +29,13 @@ public interface IFileStore
/// <returns></returns> /// <returns></returns>
ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken cts); ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken cts);
/// <summary>
/// Pre-Egress checks
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
ValueTask<EgressResult> StartEgress(EgressRequest request);
/// <summary> /// <summary>
/// Deletes file data only, metadata must be deleted with <see cref="IFileInfoManager.Delete"/> /// Deletes file data only, metadata must be deleted with <see cref="IFileInfoManager.Delete"/>
/// </summary> /// </summary>

View File

@ -43,4 +43,11 @@ public interface IUserStore : IPublicPrivateStore<VoidUser, InternalVoidUser>
/// <param name="timestamp"></param> /// <param name="timestamp"></param>
/// <returns></returns> /// <returns></returns>
ValueTask UpdateLastLogin(Guid id, DateTime timestamp); ValueTask UpdateLastLogin(Guid id, DateTime timestamp);
/// <summary>
/// Update user account for admin
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
ValueTask AdminUpdateUser(PrivateVoidUser user);
} }

View File

@ -1,5 +1,6 @@
using VoidCat.Model; using VoidCat.Model;
using VoidCat.Services.Abstractions; using VoidCat.Services.Abstractions;
using VoidCat.Services.Files;
namespace VoidCat.Services.Background; namespace VoidCat.Services.Background;
@ -23,7 +24,7 @@ public class DeleteUnverifiedAccounts : BackgroundService
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var userStore = scope.ServiceProvider.GetRequiredService<IUserStore>(); var userStore = scope.ServiceProvider.GetRequiredService<IUserStore>();
var userUploads = scope.ServiceProvider.GetRequiredService<IUserUploadsStore>(); var userUploads = scope.ServiceProvider.GetRequiredService<IUserUploadsStore>();
var fileStore = scope.ServiceProvider.GetRequiredService<IFileStore>(); var fileStore = scope.ServiceProvider.GetRequiredService<FileStoreFactory>();
var fileInfoManager = scope.ServiceProvider.GetRequiredService<IFileInfoManager>(); var fileInfoManager = scope.ServiceProvider.GetRequiredService<IFileInfoManager>();
var accounts = await userStore.ListUsers(new(0, Int32.MaxValue)); var accounts = await userStore.ListUsers(new(0, Int32.MaxValue));

View File

@ -2,10 +2,14 @@
namespace VoidCat.Services.Captcha; namespace VoidCat.Services.Captcha;
/// <summary>
/// No captcha system is configured
/// </summary>
public class NoOpVerifier : ICaptchaVerifier public class NoOpVerifier : ICaptchaVerifier
{ {
/// <inheritdoc />
public ValueTask<bool> Verify(string? token) public ValueTask<bool> Verify(string? token)
{ {
return ValueTask.FromResult(token == null); return ValueTask.FromResult(true);
} }
} }

View File

@ -9,29 +9,44 @@ public static class FileStorageStartup
public static void AddStorage(this IServiceCollection services, VoidSettings settings) public static void AddStorage(this IServiceCollection services, VoidSettings settings)
{ {
services.AddTransient<IFileInfoManager, FileInfoManager>(); services.AddTransient<IFileInfoManager, FileInfoManager>();
services.AddTransient<FileStoreFactory>();
if (settings.CloudStorage != default) if (settings.CloudStorage != default)
{ {
services.AddTransient<IUserUploadsStore, CacheUserUploadStore>(); // S3 storage
foreach (var s3 in settings.CloudStorage.S3 ?? Array.Empty<S3BlobConfig>())
// cloud storage
if (settings.CloudStorage.S3 != default)
{ {
services.AddSingleton<IFileStore, S3FileStore>(); services.AddTransient<IFileStore>((svc) =>
services.AddSingleton<IFileMetadataStore, S3FileMetadataStore>(); new S3FileStore(s3,
svc.GetRequiredService<IAggregateStatsCollector>(),
svc.GetRequiredService<IFileInfoManager>(),
svc.GetRequiredService<ICache>()));
if (settings.MetadataStore == s3.Name)
{
services.AddSingleton<IFileMetadataStore>((svc) =>
new S3FileMetadataStore(s3, svc.GetRequiredService<ILogger<S3FileMetadataStore>>()));
}
} }
} }
else if (!string.IsNullOrEmpty(settings.Postgres))
if (!string.IsNullOrEmpty(settings.Postgres))
{ {
services.AddTransient<IUserUploadsStore, PostgresUserUploadStore>(); services.AddTransient<IUserUploadsStore, PostgresUserUploadStore>();
services.AddTransient<IFileStore, LocalDiskFileStore>(); services.AddTransient<IFileStore, LocalDiskFileStore>();
services.AddTransient<IFileMetadataStore, PostgresFileMetadataStore>(); if (settings.MetadataStore == "postgres")
{
services.AddSingleton<IFileMetadataStore, PostgresFileMetadataStore>();
}
} }
else else
{ {
services.AddTransient<IUserUploadsStore, CacheUserUploadStore>(); services.AddTransient<IUserUploadsStore, CacheUserUploadStore>();
services.AddTransient<IFileStore, LocalDiskFileStore>(); services.AddTransient<IFileStore, LocalDiskFileStore>();
services.AddTransient<IFileMetadataStore, LocalDiskFileMetadataStore>(); if (settings.MetadataStore == "local-disk")
{
services.AddSingleton<IFileMetadataStore, LocalDiskFileMetadataStore>();
}
} }
} }
} }

View File

@ -0,0 +1,96 @@
using VoidCat.Model;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Files;
/// <summary>
/// Primary class for accessing <see cref="IFileStore"/> implementations
/// </summary>
public class FileStoreFactory : IFileStore
{
private readonly IFileMetadataStore _metadataStore;
private readonly IEnumerable<IFileStore> _fileStores;
public FileStoreFactory(IEnumerable<IFileStore> fileStores, IFileMetadataStore metadataStore)
{
_fileStores = fileStores;
_metadataStore = metadataStore;
}
/// <summary>
/// Get files store interface by key
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
public IFileStore? GetFileStore(string? key)
{
if (key == default && _fileStores.Count() == 1)
{
return _fileStores.First();
}
return _fileStores.FirstOrDefault(a => a.Key == key);
}
/// <inheritdoc />
public string? Key => null;
/// <inheritdoc />
public ValueTask<PrivateVoidFile> Ingress(IngressPayload payload, CancellationToken cts)
{
var store = GetFileStore(payload.Meta.Storage!);
if (store == default)
{
throw new InvalidOperationException($"Cannot find store '{payload.Meta.Storage}'");
}
return store.Ingress(payload, cts);
}
/// <inheritdoc />
public async ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken cts)
{
var store = await GetStore(request.Id);
await store.Egress(request, outStream, cts);
}
/// <inheritdoc />
public async ValueTask<EgressResult> StartEgress(EgressRequest request)
{
var store = await GetStore(request.Id);
return await store.StartEgress(request);
}
/// <inheritdoc />
public async ValueTask DeleteFile(Guid id)
{
var store = await GetStore(id);
await store.DeleteFile(id);
}
/// <inheritdoc />
public async ValueTask<Stream> Open(EgressRequest request, CancellationToken cts)
{
var store = await GetStore(request.Id);
return await store.Open(request, cts);
}
/// <summary>
/// Get file store for a file by id
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
private async Task<IFileStore> GetStore(Guid id)
{
var meta = await _metadataStore.Get(id);
var store = GetFileStore(meta?.Storage);
if (store == default)
{
throw new InvalidOperationException($"Cannot find store '{meta?.Storage}'");
}
return store;
}
}

View File

@ -53,6 +53,7 @@ public class LocalDiskFileMetadataStore : IFileMetadataStore
oldMeta.Name = meta.Name ?? oldMeta.Name; oldMeta.Name = meta.Name ?? oldMeta.Name;
oldMeta.MimeType = meta.MimeType ?? oldMeta.MimeType; oldMeta.MimeType = meta.MimeType ?? oldMeta.MimeType;
oldMeta.Expires = meta.Expires ?? oldMeta.Expires; oldMeta.Expires = meta.Expires ?? oldMeta.Expires;
oldMeta.Storage = meta.Storage ?? oldMeta.Storage;
await Set(id, oldMeta); await Set(id, oldMeta);
} }

View File

@ -30,7 +30,16 @@ public class LocalDiskFileStore : StreamFileStore, IFileStore
await using var fs = await Open(request, cts); await using var fs = await Open(request, cts);
await EgressFromStream(fs, request, outStream, cts); await EgressFromStream(fs, request, outStream, cts);
} }
/// <inheritdoc />
public ValueTask<EgressResult> StartEgress(EgressRequest request)
{
return ValueTask.FromResult(new EgressResult());
}
/// <inheritdoc />
public string Key => "local-disk";
/// <inheritdoc /> /// <inheritdoc />
public async ValueTask<PrivateVoidFile> Ingress(IngressPayload payload, CancellationToken cts) public async ValueTask<PrivateVoidFile> Ingress(IngressPayload payload, CancellationToken cts)
{ {

View File

@ -13,7 +13,10 @@ public class PostgresFileMetadataStore : IFileMetadataStore
{ {
_connection = connection; _connection = connection;
} }
/// <inheritdoc />
public string? Key => "postgres";
/// <inheritdoc /> /// <inheritdoc />
public ValueTask<VoidFileMeta?> Get(Guid id) public ValueTask<VoidFileMeta?> Get(Guid id)
{ {
@ -32,9 +35,15 @@ public class PostgresFileMetadataStore : IFileMetadataStore
await using var conn = await _connection.Get(); await using var conn = await _connection.Get();
await conn.ExecuteAsync( await conn.ExecuteAsync(
@"insert into @"insert into
""Files""(""Id"", ""Name"", ""Size"", ""Uploaded"", ""Description"", ""MimeType"", ""Digest"", ""EditSecret"", ""Expires"") ""Files""(""Id"", ""Name"", ""Size"", ""Uploaded"", ""Description"", ""MimeType"", ""Digest"", ""EditSecret"", ""Expires"", ""Storage"")
values(:id, :name, :size, :uploaded, :description, :mimeType, :digest, :editSecret, :expires) values(:id, :name, :size, :uploaded, :description, :mimeType, :digest, :editSecret, :expires, :store)
on conflict (""Id"") do update set ""Name"" = :name, ""Size"" = :size, ""Description"" = :description, ""MimeType"" = :mimeType, ""Expires"" = :expires", on conflict (""Id"") do update set
""Name"" = :name,
""Size"" = :size,
""Description"" = :description,
""MimeType"" = :mimeType,
""Expires"" = :expires,
""Storage"" = :store",
new new
{ {
id, id,
@ -45,7 +54,8 @@ on conflict (""Id"") do update set ""Name"" = :name, ""Size"" = :size, ""Descrip
mimeType = obj.MimeType, mimeType = obj.MimeType,
digest = obj.Digest, digest = obj.Digest,
editSecret = obj.EditSecret, editSecret = obj.EditSecret,
expires = obj.Expires expires = obj.Expires,
store = obj.Storage
}); });
} }
@ -82,6 +92,7 @@ on conflict (""Id"") do update set ""Name"" = :name, ""Size"" = :size, ""Descrip
oldMeta.Name = meta.Name ?? oldMeta.Name; oldMeta.Name = meta.Name ?? oldMeta.Name;
oldMeta.MimeType = meta.MimeType ?? oldMeta.MimeType; oldMeta.MimeType = meta.MimeType ?? oldMeta.MimeType;
oldMeta.Expires = meta.Expires ?? oldMeta.Expires; oldMeta.Expires = meta.Expires ?? oldMeta.Expires;
oldMeta.Storage = meta.Storage ?? oldMeta.Storage;
await Set(id, oldMeta); await Set(id, oldMeta);
} }

View File

@ -11,16 +11,17 @@ public class S3FileMetadataStore : IFileMetadataStore
private readonly ILogger<S3FileMetadataStore> _logger; private readonly ILogger<S3FileMetadataStore> _logger;
private readonly AmazonS3Client _client; private readonly AmazonS3Client _client;
private readonly S3BlobConfig _config; private readonly S3BlobConfig _config;
private readonly bool _includeUrl;
public S3FileMetadataStore(VoidSettings settings, ILogger<S3FileMetadataStore> logger) public S3FileMetadataStore(S3BlobConfig settings, ILogger<S3FileMetadataStore> logger)
{ {
_logger = logger; _logger = logger;
_includeUrl = settings.CloudStorage?.ServeFromCloud ?? false; _config = settings;
_config = settings.CloudStorage!.S3!;
_client = _config.CreateClient(); _client = _config.CreateClient();
} }
/// <inheritdoc />
public string? Key => _config.Name;
/// <inheritdoc /> /// <inheritdoc />
public ValueTask<TMeta?> Get<TMeta>(Guid id) where TMeta : VoidFileMeta public ValueTask<TMeta?> Get<TMeta>(Guid id) where TMeta : VoidFileMeta
{ {
@ -53,6 +54,7 @@ public class S3FileMetadataStore : IFileMetadataStore
oldMeta.Name = meta.Name ?? oldMeta.Name; oldMeta.Name = meta.Name ?? oldMeta.Name;
oldMeta.MimeType = meta.MimeType ?? oldMeta.MimeType; oldMeta.MimeType = meta.MimeType ?? oldMeta.MimeType;
oldMeta.Expires = meta.Expires ?? oldMeta.Expires; oldMeta.Expires = meta.Expires ?? oldMeta.Expires;
oldMeta.Storage = meta.Storage ?? oldMeta.Storage;
await Set(id, oldMeta); await Set(id, oldMeta);
} }
@ -141,15 +143,6 @@ public class S3FileMetadataStore : IFileMetadataStore
if (ret != default) if (ret != default)
{ {
ret.Id = id; ret.Id = id;
if (_includeUrl)
{
var ub = new UriBuilder(_config.ServiceUrl!)
{
Path = $"/{_config.BucketName}/{id}"
};
ret.Url = ub.Uri;
}
} }
return ret; return ret;

View File

@ -1,27 +1,37 @@
using Amazon.S3; using System.Net;
using Amazon.S3;
using Amazon.S3.Model; using Amazon.S3.Model;
using VoidCat.Model; using VoidCat.Model;
using VoidCat.Services.Abstractions; using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Files; namespace VoidCat.Services.Files;
/// <inheritdoc cref="VoidCat.Services.Abstractions.IFileStore" />
public class S3FileStore : StreamFileStore, IFileStore public class S3FileStore : StreamFileStore, IFileStore
{ {
private readonly IFileInfoManager _fileInfo; private readonly IFileInfoManager _fileInfo;
private readonly AmazonS3Client _client; private readonly AmazonS3Client _client;
private readonly S3BlobConfig _config; private readonly S3BlobConfig _config;
private readonly IAggregateStatsCollector _statsCollector; private readonly IAggregateStatsCollector _statsCollector;
private readonly ICache _cache;
public S3FileStore(VoidSettings settings, IAggregateStatsCollector stats, IFileInfoManager fileInfo) : base(stats) public S3FileStore(S3BlobConfig settings, IAggregateStatsCollector stats, IFileInfoManager fileInfo, ICache cache) : base(stats)
{ {
_fileInfo = fileInfo; _fileInfo = fileInfo;
_cache = cache;
_statsCollector = stats; _statsCollector = stats;
_config = settings.CloudStorage!.S3!; _config = settings;
_client = _config.CreateClient(); _client = _config.CreateClient();
} }
/// <inheritdoc />
public string Key => _config.Name;
/// <inheritdoc />
public async ValueTask<PrivateVoidFile> Ingress(IngressPayload payload, CancellationToken cts) public async ValueTask<PrivateVoidFile> Ingress(IngressPayload payload, CancellationToken cts)
{ {
if (payload.IsMultipart) return await IngressMultipart(payload, cts);
var req = new PutObjectRequest var req = new PutObjectRequest
{ {
BucketName = _config.BucketName, BucketName = _config.BucketName,
@ -31,28 +41,46 @@ public class S3FileStore : StreamFileStore, IFileStore
AutoResetStreamPosition = false, AutoResetStreamPosition = false,
AutoCloseStream = false, AutoCloseStream = false,
ChecksumAlgorithm = ChecksumAlgorithm.SHA256, ChecksumAlgorithm = ChecksumAlgorithm.SHA256,
ChecksumSHA256 = payload.Hash != default ? Convert.ToBase64String(payload.Hash!.FromHex()) : null, ChecksumSHA256 = payload.Meta.Digest != default ? Convert.ToBase64String(payload.Meta.Digest!.FromHex()) : null,
StreamTransferProgress = (s, e) =>
{
_statsCollector.TrackIngress(payload.Id, (ulong) e.IncrementTransferred)
.GetAwaiter().GetResult();
},
Headers = Headers =
{ {
ContentLength = (long) payload.Meta.Size ContentLength = (long)payload.Meta.Size
} }
}; };
await _client.PutObjectAsync(req, cts); await _client.PutObjectAsync(req, cts);
await _statsCollector.TrackIngress(payload.Id, payload.Meta.Size);
return HandleCompletedUpload(payload, payload.Meta.Size); return HandleCompletedUpload(payload, payload.Meta.Size);
} }
/// <inheritdoc />
public async ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken cts) public async ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken cts)
{ {
await using var stream = await Open(request, cts); await using var stream = await Open(request, cts);
await EgressFull(request.Id, stream, outStream, cts); await EgressFull(request.Id, stream, outStream, cts);
} }
/// <inheritdoc />
public async ValueTask<EgressResult> StartEgress(EgressRequest request)
{
if (!_config.Direct) return new();
var meta = await _fileInfo.Get(request.Id);
var url = _client.GetPreSignedURL(new()
{
BucketName = _config.BucketName,
Expires = DateTime.UtcNow.AddHours(1),
Key = request.Id.ToString(),
ResponseHeaderOverrides = new()
{
ContentDisposition = $"inline; filename=\"{meta?.Metadata?.Name}\"",
ContentType = meta?.Metadata?.MimeType
}
});
return new(new Uri(url));
}
public async ValueTask<PagedResult<PublicVoidFile>> ListFiles(PagedRequest request) public async ValueTask<PagedResult<PublicVoidFile>> ListFiles(PagedRequest request)
{ {
try try
@ -108,11 +136,13 @@ public class S3FileStore : StreamFileStore, IFileStore
} }
} }
/// <inheritdoc />
public async ValueTask DeleteFile(Guid id) public async ValueTask DeleteFile(Guid id)
{ {
await _client.DeleteObjectAsync(_config.BucketName, id.ToString()); await _client.DeleteObjectAsync(_config.BucketName, id.ToString());
} }
/// <inheritdoc />
public async ValueTask<Stream> Open(EgressRequest request, CancellationToken cts) public async ValueTask<Stream> Open(EgressRequest request, CancellationToken cts)
{ {
var req = new GetObjectRequest() var req = new GetObjectRequest()
@ -120,6 +150,7 @@ public class S3FileStore : StreamFileStore, IFileStore
BucketName = _config.BucketName, BucketName = _config.BucketName,
Key = request.Id.ToString() Key = request.Id.ToString()
}; };
if (request.Ranges.Any()) if (request.Ranges.Any())
{ {
var r = request.Ranges.First(); var r = request.Ranges.First();
@ -129,4 +160,87 @@ public class S3FileStore : StreamFileStore, IFileStore
var obj = await _client.GetObjectAsync(req, cts); var obj = await _client.GetObjectAsync(req, cts);
return obj.ResponseStream; return obj.ResponseStream;
} }
}
private async Task<PrivateVoidFile> IngressMultipart(IngressPayload payload, CancellationToken cts)
{
string? uploadId;
var cacheKey = $"s3:{_config.Name}:multipart-upload-id:{payload.Id}";
var partsCacheKey = $"s3:{_config.Name}:multipart-upload:{payload.Id}";
if (payload.Segment == 1)
{
var mStart = new InitiateMultipartUploadRequest()
{
BucketName = _config.BucketName,
Key = payload.Id.ToString(),
ContentType = "application/octet-stream",
ChecksumAlgorithm = ChecksumAlgorithm.SHA256
};
var mStartResult = await _client.InitiateMultipartUploadAsync(mStart, cts);
uploadId = mStartResult.UploadId;
await _cache.Set(cacheKey, uploadId, TimeSpan.FromHours(1));
}
else
{
uploadId = await _cache.Get<string>(cacheKey);
}
// sadly it seems like we need a tmp file here
var tmpFile = Path.GetTempFileName();
await using var fsTmp = new FileStream(tmpFile, FileMode.Create, FileAccess.ReadWrite);
await payload.InStream.CopyToAsync(fsTmp, cts);
fsTmp.Seek(0, SeekOrigin.Begin);
var segmentLength = (ulong)fsTmp.Length;
var mbody = new UploadPartRequest()
{
UploadId = uploadId,
BucketName = _config.BucketName,
PartNumber = payload.Segment,
Key = payload.Id.ToString(),
InputStream = fsTmp
};
var bodyResponse = await _client.UploadPartAsync(mbody, cts);
if (bodyResponse.HttpStatusCode != HttpStatusCode.OK)
{
await _client.AbortMultipartUploadAsync(new()
{
BucketName = _config.BucketName,
UploadId = uploadId
}, cts);
throw new Exception("Upload aborted");
}
await _statsCollector.TrackIngress(payload.Id, segmentLength);
await _cache.AddToList(partsCacheKey, $"{payload.Segment}|{bodyResponse.ETag.Replace("\"", string.Empty)}");
if (payload.Segment == payload.TotalSegments)
{
var parts = await _cache.GetList(partsCacheKey);
var completeResponse = await _client.CompleteMultipartUploadAsync(new()
{
BucketName = _config.BucketName,
Key = payload.Id.ToString(),
UploadId = uploadId,
PartETags = parts.Select(a =>
{
var pSplit = a.Split('|');
return new PartETag()
{
PartNumber = int.Parse(pSplit[0]),
ETag = pSplit[1]
};
}).ToList()
}, cts);
if (completeResponse.HttpStatusCode != HttpStatusCode.OK)
{
throw new Exception("Upload failed");
}
}
return HandleCompletedUpload(payload, segmentLength);
}
}

View File

@ -0,0 +1,37 @@
using System.Data;
using FluentMigrator;
namespace VoidCat.Services.Migrations.Database;
[Migration(20220725_1137)]
public class MinorVersion1 : Migration
{
public override void Up()
{
Create.Table("ApiKey")
.WithColumn("Id").AsGuid().PrimaryKey()
.WithColumn("UserId").AsGuid().ForeignKey("Users", "Id").OnDelete(Rule.Cascade).Indexed()
.WithColumn("Token").AsString()
.WithColumn("Expiry").AsDateTimeOffset()
.WithColumn("Created").AsDateTimeOffset().WithDefault(SystemMethods.CurrentUTCDateTime);
Create.Column("Storage")
.OnTable("Files")
.AsString().WithDefaultValue("local-disk");
Create.Column("Storage")
.OnTable("Users")
.AsString().WithDefaultValue("local-disk");
}
public override void Down()
{
Delete.Table("ApiKey");
Delete.Column("Storage")
.FromTable("Files");
Delete.Column("Storage")
.FromTable("Users");
}
}

View File

@ -1,5 +1,6 @@
using VoidCat.Model; using VoidCat.Model;
using VoidCat.Services.Abstractions; using VoidCat.Services.Abstractions;
using VoidCat.Services.Files;
namespace VoidCat.Services.Migrations; namespace VoidCat.Services.Migrations;
@ -8,9 +9,9 @@ public class FixSize : IMigration
{ {
private readonly ILogger<FixSize> _logger; private readonly ILogger<FixSize> _logger;
private readonly IFileMetadataStore _fileMetadata; private readonly IFileMetadataStore _fileMetadata;
private readonly IFileStore _fileStore; private readonly FileStoreFactory _fileStore;
public FixSize(ILogger<FixSize> logger, IFileMetadataStore fileMetadata, IFileStore fileStore) public FixSize(ILogger<FixSize> logger, IFileMetadataStore fileMetadata, FileStoreFactory fileStore)
{ {
_logger = logger; _logger = logger;
_fileMetadata = fileMetadata; _fileMetadata = fileMetadata;
@ -26,18 +27,26 @@ public class FixSize : IMigration
var files = await _fileMetadata.ListFiles<SecretVoidFileMeta>(new(0, int.MaxValue)); var files = await _fileMetadata.ListFiles<SecretVoidFileMeta>(new(0, int.MaxValue));
await foreach (var file in files.Results) await foreach (var file in files.Results)
{ {
var fs = await _fileStore.Open(new(file.Id, Enumerable.Empty<RangeRequest>()), CancellationToken.None); try
if (file.Size != (ulong) fs.Length)
{ {
_logger.LogInformation("Updating file size {Id} to {Size}", file.Id, fs.Length); var fs = await _fileStore.Open(new(file.Id, Enumerable.Empty<RangeRequest>()), CancellationToken.None);
var newFile = file with if (file.Size != (ulong)fs.Length)
{ {
Size = (ulong) fs.Length _logger.LogInformation("Updating file size {Id} to {Size}", file.Id, fs.Length);
}; var newFile = file with
await _fileMetadata.Set(newFile.Id, newFile); {
Size = (ulong)fs.Length
};
await _fileMetadata.Set(newFile.Id, newFile);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fix file {id}", file.Id);
} }
} }
return IMigration.MigrationResult.Completed; return IMigration.MigrationResult.Completed;
} }
} }

View File

@ -18,11 +18,11 @@ public class MigrateToPostgres : IMigration
private readonly IPaywallStore _paywallStore; private readonly IPaywallStore _paywallStore;
private readonly IUserStore _userStore; private readonly IUserStore _userStore;
private readonly IUserUploadsStore _userUploads; private readonly IUserUploadsStore _userUploads;
private readonly IFileStore _fileStore; private readonly FileStoreFactory _fileStore;
public MigrateToPostgres(VoidSettings settings, ILogger<MigrateToPostgres> logger, IFileMetadataStore fileMetadata, public MigrateToPostgres(VoidSettings settings, ILogger<MigrateToPostgres> logger, IFileMetadataStore fileMetadata,
ICache cache, IPaywallStore paywallStore, IUserStore userStore, IUserUploadsStore userUploads, ICache cache, IPaywallStore paywallStore, IUserStore userStore, IUserUploadsStore userUploads,
IFileStore fileStore) FileStoreFactory fileStore)
{ {
_logger = logger; _logger = logger;
_settings = settings; _settings = settings;
@ -75,6 +75,7 @@ public class MigrateToPostgres : IMigration
{ {
var fs = await _fileStore.Open(new(file.Id, Enumerable.Empty<RangeRequest>()), var fs = await _fileStore.Open(new(file.Id, Enumerable.Empty<RangeRequest>()),
CancellationToken.None); CancellationToken.None);
var hash = await SHA256.Create().ComputeHashAsync(fs); var hash = await SHA256.Create().ComputeHashAsync(fs);
file.Digest = hash.ToHex(); file.Digest = hash.ToHex();
} }
@ -143,6 +144,7 @@ public class MigrateToPostgres : IMigration
Password = privateUser.Password!, Password = privateUser.Password!,
Roles = privateUser.Roles Roles = privateUser.Roles
}); });
_logger.LogInformation("Migrated user {USer}", user.Id); _logger.LogInformation("Migrated user {USer}", user.Id);
} }
catch (Exception ex) catch (Exception ex)
@ -163,4 +165,4 @@ public class MigrateToPostgres : IMigration
[JsonConverter(typeof(Base58GuidConverter))] [JsonConverter(typeof(Base58GuidConverter))]
public Guid? Uploader { get; set; } public Guid? Uploader { get; set; }
} }
} }

View File

@ -0,0 +1,20 @@
using VoidCat.Model;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Users;
/// <inheritdoc cref="VoidCat.Services.Abstractions.IApiKeyStore" />
public class CacheApiKeyStore : BasicCacheStore<ApiKey>, IApiKeyStore
{
public CacheApiKeyStore(ICache cache) : base(cache)
{
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<ApiKey>> ListKeys(Guid id)
{
throw new NotImplementedException();
}
protected override string MapKey(Guid id) => $"api-key:{id}";
}

View File

@ -109,6 +109,18 @@ public class CacheUserStore : IUserStore
await Set(user.Id, user); await Set(user.Id, user);
} }
} }
/// <inheritdoc />
public async ValueTask AdminUpdateUser(PrivateVoidUser user)
{
var oldUser = await Get<InternalVoidUser>(user.Id);
if (oldUser == null) return;
oldUser.Email = user.Email;
oldUser.Storage = user.Storage;
await Set(oldUser.Id, oldUser);
}
/// <inheritdoc /> /// <inheritdoc />
public async ValueTask Delete(Guid id) public async ValueTask Delete(Guid id)

View File

@ -0,0 +1,59 @@
using Dapper;
using VoidCat.Model;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Users;
/// <inheritdoc />
public class PostgresApiKeyStore : IApiKeyStore
{
private readonly PostgresConnectionFactory _factory;
public PostgresApiKeyStore(PostgresConnectionFactory factory)
{
_factory = factory;
}
/// <inheritdoc />
public async ValueTask<ApiKey?> Get(Guid id)
{
await using var conn = await _factory.Get();
return await conn.QuerySingleOrDefaultAsync<ApiKey>(@"select * from ""ApiKey"" where ""Id"" = :id", new {id});
}
/// <inheritdoc />
public async ValueTask<IReadOnlyList<ApiKey>> Get(Guid[] ids)
{
await using var conn = await _factory.Get();
return (await conn.QueryAsync<ApiKey>(@"select * from ""ApiKey"" where ""Id"" in :ids", new {ids})).ToList();
}
/// <inheritdoc />
public async ValueTask Add(Guid id, ApiKey obj)
{
await using var conn = await _factory.Get();
await conn.ExecuteAsync(@"insert into ""ApiKey""(""Id"", ""UserId"", ""Token"", ""Expiry"")
values(:id, :userId, :token, :expiry)", new
{
id = obj.Id,
userId = obj.UserId,
token = obj.Token,
expiry = obj.Expiry.ToUniversalTime()
});
}
/// <inheritdoc />
public async ValueTask Delete(Guid id)
{
await using var conn = await _factory.Get();
await conn.ExecuteAsync(@"delete from ""ApiKey"" where ""Id"" = :id", new {id});
}
/// <inheritdoc />
public async ValueTask<IReadOnlyList<ApiKey>> ListKeys(Guid id)
{
await using var conn = await _factory.Get();
return (await conn.QueryAsync<ApiKey>(@"select ""Id"", ""UserId"", ""Expiry"", ""Created"" from ""ApiKey"" where ""UserId"" = :id", new {id}))
.ToList();
}
}

View File

@ -43,8 +43,9 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla
displayName = obj.DisplayName, displayName = obj.DisplayName,
lastLogin = obj.LastLogin.ToUniversalTime(), lastLogin = obj.LastLogin.ToUniversalTime(),
avatar = obj.Avatar, avatar = obj.Avatar,
flags = (int) obj.Flags flags = (int)obj.Flags
}); });
if (obj.Roles.Any(a => a != Roles.User)) if (obj.Roles.Any(a => a != Roles.User))
{ {
foreach (var r in obj.Roles.Where(a => a != Roles.User)) foreach (var r in obj.Roles.Where(a => a != Roles.User))
@ -69,11 +70,13 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla
await using var conn = await _connection.Get(); await using var conn = await _connection.Get();
var user = await conn.QuerySingleOrDefaultAsync<T?>(@"select * from ""Users"" where ""Id"" = :id", var user = await conn.QuerySingleOrDefaultAsync<T?>(@"select * from ""Users"" where ""Id"" = :id",
new {id}); new {id});
if (user != default) if (user != default)
{ {
var roles = await conn.QueryAsync<string>( var roles = await conn.QueryAsync<string>(
@"select ""Role"" from ""UserRoles"" where ""User"" = :id", @"select ""Role"" from ""UserRoles"" where ""User"" = :id",
new {id}); new {id});
foreach (var r in roles) foreach (var r in roles)
{ {
user.Roles.Add(r); user.Roles.Add(r);
@ -106,11 +109,13 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla
PagedSortBy.Name => "DisplayName", PagedSortBy.Name => "DisplayName",
_ => "Id" _ => "Id"
}; };
var sortBy = request.SortOrder switch var sortBy = request.SortOrder switch
{ {
PageSortOrder.Dsc => "desc", PageSortOrder.Dsc => "desc",
_ => "asc" _ => "asc"
}; };
await using var iconn = await _connection.Get(); await using var iconn = await _connection.Get();
var users = await iconn.ExecuteReaderAsync( var users = await iconn.ExecuteReaderAsync(
$@"select * from ""Users"" order by ""{orderBy}"" {sortBy} offset :offset limit :limit", $@"select * from ""Users"" order by ""{orderBy}"" {sortBy} offset :offset limit :limit",
@ -119,6 +124,7 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla
offset = request.PageSize * request.Page, offset = request.PageSize * request.Page,
limit = request.PageSize limit = request.PageSize
}); });
var rowParser = users.GetRowParser<PrivateVoidUser>(); var rowParser = users.GetRowParser<PrivateVoidUser>();
while (await users.ReadAsync()) while (await users.ReadAsync())
{ {
@ -144,7 +150,7 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla
var emailFlag = oldUser.Flags.HasFlag(VoidUserFlags.EmailVerified) ? VoidUserFlags.EmailVerified : 0; var emailFlag = oldUser.Flags.HasFlag(VoidUserFlags.EmailVerified) ? VoidUserFlags.EmailVerified : 0;
await using var conn = await _connection.Get(); await using var conn = await _connection.Get();
await conn.ExecuteAsync( await conn.ExecuteAsync(
@"update ""Users"" set ""DisplayName"" = @displayName, ""Avatar"" = @avatar, ""Flags"" = :flags where ""Id"" = :id", @"update ""Users"" set ""DisplayName"" = :displayName, ""Avatar"" = :avatar, ""Flags"" = :flags where ""Id"" = :id",
new new
{ {
id = newUser.Id, id = newUser.Id,
@ -161,4 +167,18 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla
await conn.ExecuteAsync(@"update ""Users"" set ""LastLogin"" = :timestamp where ""Id"" = :id", await conn.ExecuteAsync(@"update ""Users"" set ""LastLogin"" = :timestamp where ""Id"" = :id",
new {id, timestamp}); new {id, timestamp});
} }
}
/// <inheritdoc />
public async ValueTask AdminUpdateUser(PrivateVoidUser user)
{
await using var conn = await _connection.Get();
await conn.ExecuteAsync(
@"update ""Users"" set ""Email"" = :email, ""Storage"" = :storage where ""Id"" = :id",
new
{
id = user.Id,
email = user.Email,
storage = user.Storage
});
}
}

View File

@ -9,15 +9,17 @@ public static class UsersStartup
{ {
services.AddTransient<IUserManager, UserManager>(); services.AddTransient<IUserManager, UserManager>();
if (settings.Postgres != default) if (settings.HasPostgres())
{ {
services.AddTransient<IUserStore, PostgresUserStore>(); services.AddTransient<IUserStore, PostgresUserStore>();
services.AddTransient<IEmailVerification, PostgresEmailVerification>(); services.AddTransient<IEmailVerification, PostgresEmailVerification>();
services.AddTransient<IApiKeyStore, PostgresApiKeyStore>();
} }
else else
{ {
services.AddTransient<IUserStore, CacheUserStore>(); services.AddTransient<IUserStore, CacheUserStore>();
services.AddTransient<IEmailVerification, CacheEmailVerification>(); services.AddTransient<IEmailVerification, CacheEmailVerification>();
services.AddTransient<IApiKeyStore, CacheApiKeyStore>();
} }
} }
} }

View File

@ -1,6 +1,7 @@
using nClam; using nClam;
using VoidCat.Model; using VoidCat.Model;
using VoidCat.Services.Abstractions; using VoidCat.Services.Abstractions;
using VoidCat.Services.Files;
namespace VoidCat.Services.VirusScanner; namespace VoidCat.Services.VirusScanner;
@ -11,13 +12,13 @@ public class ClamAvScanner : IVirusScanner
{ {
private readonly ILogger<ClamAvScanner> _logger; private readonly ILogger<ClamAvScanner> _logger;
private readonly IClamClient _clam; private readonly IClamClient _clam;
private readonly IFileStore _store; private readonly FileStoreFactory _fileSystemFactory;
public ClamAvScanner(ILogger<ClamAvScanner> logger, IClamClient clam, IFileStore store) public ClamAvScanner(ILogger<ClamAvScanner> logger, IClamClient clam, FileStoreFactory fileSystemFactory)
{ {
_logger = logger; _logger = logger;
_clam = clam; _clam = clam;
_store = store; _fileSystemFactory = fileSystemFactory;
} }
/// <inheritdoc /> /// <inheritdoc />
@ -25,7 +26,7 @@ public class ClamAvScanner : IVirusScanner
{ {
_logger.LogInformation("Starting scan of {Filename}", id); _logger.LogInformation("Starting scan of {Filename}", id);
await using var fs = await _store.Open(new(id, Enumerable.Empty<RangeRequest>()), cts); await using var fs = await _fileSystemFactory.Open(new(id, Enumerable.Empty<RangeRequest>()), cts);
var result = await _clam.SendAndScanFileAsync(fs, cts); var result = await _clam.SendAndScanFileAsync(fs, cts);
if (result.Result == ClamScanResults.Error) if (result.Result == ClamScanResults.Error)

View File

@ -10,7 +10,7 @@
<HostSPA>True</HostSPA> <HostSPA>True</HostSPA>
<DefineConstants Condition="'$(HostSPA)' == 'True'">$(DefineConstants);HostSPA</DefineConstants> <DefineConstants Condition="'$(HostSPA)' == 'True'">$(DefineConstants);HostSPA</DefineConstants>
<DocumentationFile>$(AssemblyName).xml</DocumentationFile> <DocumentationFile>$(AssemblyName).xml</DocumentationFile>
<Version>4.0.0</Version> <Version>4.1.0</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@ -9,23 +9,6 @@
padding: 10px; padding: 10px;
} }
.admin table {
width: 100%;
word-break: keep-all;
text-overflow: ellipsis;
white-space: nowrap;
border-collapse: collapse;
}
.admin table th {
background-color: #222;
text-align: start;
}
.admin table tr:nth-child(2n) {
background-color: #111;
}
.admin .btn { .admin .btn {
padding: 5px 8px; padding: 5px 8px;
border-radius: 3px; border-radius: 3px;

View File

@ -5,17 +5,20 @@ import {UserList} from "./UserList";
import {Navigate} from "react-router-dom"; import {Navigate} from "react-router-dom";
import {useApi} from "../Api"; import {useApi} from "../Api";
import {VoidButton} from "../VoidButton"; import {VoidButton} from "../VoidButton";
import {useState} from "react";
import VoidModal from "../VoidModal";
import EditUser from "./EditUser";
export function Admin() { export function Admin() {
const auth = useSelector((state) => state.login.jwt); const auth = useSelector((state) => state.login.jwt);
const {AdminApi} = useApi(); const {AdminApi} = useApi();
const [editUser, setEditUser] = useState(null);
async function deleteFile(e, id) { async function deleteFile(e, id) {
if (window.confirm(`Are you sure you want to delete: ${id}?`)) { if (window.confirm(`Are you sure you want to delete: ${id}?`)) {
let req = await AdminApi.deleteFile(id); let req = await AdminApi.deleteFile(id);
if (req.ok) { if (req.ok) {
} else { } else {
alert("Failed to delete file!"); alert("Failed to delete file!");
} }
@ -28,7 +31,10 @@ export function Admin() {
return ( return (
<div className="admin"> <div className="admin">
<h2>Users</h2> <h2>Users</h2>
<UserList/> <UserList actions={(i) => [
<VoidButton key={`delete-${i.id}`}>Delete</VoidButton>,
<VoidButton key={`edit-${i.id}`} onClick={(e) => setEditUser(i)}>Edit</VoidButton>
]}/>
<h2>Files</h2> <h2>Files</h2>
<FileList loadPage={AdminApi.fileList} actions={(i) => { <FileList loadPage={AdminApi.fileList} actions={(i) => {
@ -36,6 +42,11 @@ export function Admin() {
<VoidButton onClick={(e) => deleteFile(e, i.id)}>Delete</VoidButton> <VoidButton onClick={(e) => deleteFile(e, i.id)}>Delete</VoidButton>
</td> </td>
}}/> }}/>
{editUser !== null ?
<VoidModal title="Edit user">
<EditUser user={editUser} onClose={() => setEditUser(null)}/>
</VoidModal> : null}
</div> </div>
); );
} }

View File

@ -0,0 +1,46 @@
import {VoidButton} from "../VoidButton";
import {useState} from "react";
import {useSelector} from "react-redux";
import {useApi} from "../Api";
export default function EditUser(props) {
const user = props.user;
const onClose = props.onClose;
const adminApi = useApi().AdminApi;
const fileStores = useSelector((state) => state.info?.stats?.fileStores ?? ["local-disk"])
const [storage, setStorage] = useState(user.storage);
const [email, setEmail] = useState(user.email);
async function updateUser() {
await adminApi.updateUser({
id: user.id,
email,
storage
});
onClose();
}
return (
<>
Editing user '{user.displayName}' ({user.id})
<dl>
<dt>Email:</dt>
<dd><input type="text" value={email} onChange={(e) => setEmail(e.target.value)}/></dd>
<dt>File storage:</dt>
<dd>
<select value={storage} onChange={(e) => setStorage(e.target.value)}>
<option disabled={true}>Current: {storage}</option>
{fileStores.map(e => <option key={e}>{e}</option>)}
</select>
</dd>
<dt>Roles:</dt>
<dd>{user.roles.map(e => <span className="btn" key={e}>{e}</span>)}</dd>
</dl>
<VoidButton onClick={(e) => updateUser()}>Save</VoidButton>
<VoidButton onClick={(e) => onClose()}>Cancel</VoidButton>
</>
);
}

View File

@ -5,15 +5,15 @@ import {useApi} from "../Api";
import {logout} from "../LoginState"; import {logout} from "../LoginState";
import {PageSelector} from "../PageSelector"; import {PageSelector} from "../PageSelector";
import moment from "moment"; import moment from "moment";
import {VoidButton} from "../VoidButton";
export function UserList() { export function UserList(props) {
const {AdminApi} = useApi(); const {AdminApi} = useApi();
const dispatch = useDispatch(); const dispatch = useDispatch();
const [users, setUsers] = useState(); const [users, setUsers] = useState();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const pageSize = 10; const pageSize = 10;
const [accessDenied, setAccessDenied] = useState(); const [accessDenied, setAccessDenied] = useState();
const actions = props.actions;
async function loadUserList() { async function loadUserList() {
let pageReq = { let pageReq = {
@ -40,10 +40,7 @@ export function UserList() {
<td>{moment(user.created).fromNow()}</td> <td>{moment(user.created).fromNow()}</td>
<td>{moment(user.lastLogin).fromNow()}</td> <td>{moment(user.lastLogin).fromNow()}</td>
<td>{obj.uploads}</td> <td>{obj.uploads}</td>
<td> <td>{actions(user)}</td>
<VoidButton>Delete</VoidButton>
<VoidButton>SetRoles</VoidButton>
</td>
</tr> </tr>
); );
} }

View File

@ -22,12 +22,13 @@ export function useApi() {
body: body ? JSON.stringify(body) : undefined body: body ? JSON.stringify(body) : undefined
}); });
} }
return { return {
AdminApi: { AdminApi: {
fileList: (pageReq) => getJson("POST", "/admin/file", pageReq, auth), fileList: (pageReq) => getJson("POST", "/admin/file", pageReq, auth),
deleteFile: (id) => getJson("DELETE", `/admin/file/${id}`, undefined, auth), deleteFile: (id) => getJson("DELETE", `/admin/file/${id}`, undefined, auth),
userList: (pageReq) => getJson("POST", `/admin/user`, pageReq, auth) userList: (pageReq) => getJson("POST", `/admin/users`, pageReq, auth),
updateUser: (user) => getJson("POST", `/admin/update-user`, user, auth)
}, },
Api: { Api: {
info: () => getJson("GET", "/info"), info: () => getJson("GET", "/info"),
@ -42,7 +43,9 @@ export function useApi() {
listUserFiles: (uid, pageReq) => getJson("POST", `/user/${uid}/files`, pageReq, auth), listUserFiles: (uid, pageReq) => getJson("POST", `/user/${uid}/files`, pageReq, auth),
submitVerifyCode: (uid, code) => getJson("POST", `/user/${uid}/verify`, code, auth), submitVerifyCode: (uid, code) => getJson("POST", `/user/${uid}/verify`, code, auth),
sendNewCode: (uid) => getJson("GET", `/user/${uid}/verify`, undefined, auth), sendNewCode: (uid) => getJson("GET", `/user/${uid}/verify`, undefined, auth),
updateMetadata: (id, meta) => getJson("POST", `/upload/${id}/meta`, meta, auth) updateMetadata: (id, meta) => getJson("POST", `/upload/${id}/meta`, meta, auth),
listApiKeys: () => getJson("GET", `/auth/api-key`, undefined, auth),
createApiKey: (req) => getJson("POST", `/auth/api-key`, req, auth)
} }
}; };
} }

View File

@ -0,0 +1,71 @@
import {useApi} from "./Api";
import {useEffect, useState} from "react";
import {VoidButton} from "./VoidButton";
import moment from "moment";
import VoidModal from "./VoidModal";
export default function ApiKeyList() {
const {Api} = useApi();
const [apiKeys, setApiKeys] = useState([]);
const [newApiKey, setNewApiKey] = useState();
const DefaultExpiry = 1000 * 60 * 60 * 24 * 90;
async function loadApiKeys() {
let keys = await Api.listApiKeys();
setApiKeys(await keys.json());
}
async function createApiKey() {
let rsp = await Api.createApiKey({
expiry: new Date(new Date().getTime() + DefaultExpiry)
});
if (rsp.ok) {
setNewApiKey(await rsp.json());
}
}
useEffect(() => {
if (Api) {
loadApiKeys();
}
}, []);
return (
<>
<div className="flex flex-center">
<div className="flx-grow">
<h1>API Keys</h1>
</div>
<div>
<VoidButton onClick={(e) => createApiKey()}>+New</VoidButton>
</div>
</div>
<table>
<thead>
<tr>
<th>Id</th>
<th>Created</th>
<th>Expiry</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{apiKeys.map(e => <tr key={e.id}>
<td>{e.id}</td>
<td>{moment(e.created).fromNow()}</td>
<td>{moment(e.expiry).fromNow()}</td>
<td>
<VoidButton>Delete</VoidButton>
</td>
</tr>)}
</tbody>
</table>
{newApiKey ?
<VoidModal title="New Api Key" style={{maxWidth: "50vw"}}>
Please save this now as it will not be shown again:
<pre className="copy">{newApiKey.token}</pre>
<VoidButton onClick={(e) => setNewApiKey(undefined)}>Close</VoidButton>
</VoidModal> : null}
</>
);
}

View File

@ -1,16 +0,0 @@
table.file-list {
width: 100%;
word-break: keep-all;
text-overflow: ellipsis;
white-space: nowrap;
border-collapse: collapse;
}
table.file-list tr:nth-child(2n) {
background-color: #111;
}
table.file-list th {
background-color: #222;
text-align: start;
}

View File

@ -1,4 +1,3 @@
import "./FileList.css";
import moment from "moment"; import moment from "moment";
import {Link} from "react-router-dom"; import {Link} from "react-router-dom";
import {useDispatch} from "react-redux"; import {useDispatch} from "react-redux";
@ -59,7 +58,7 @@ export function FileList(props) {
} }
return ( return (
<table className="file-list"> <table>
<thead> <thead>
<tr> <tr>
<th>Id</th> <th>Id</th>

View File

@ -87,11 +87,11 @@ export function FileUpload(props) {
* @param id {string} * @param id {string}
* @param editSecret {string?} * @param editSecret {string?}
* @param fullDigest {string?} Full file hash * @param fullDigest {string?} Full file hash
* @param part {int?} Segment number
* @param partOf {int?} Total number of segments
* @returns {Promise<any>} * @returns {Promise<any>}
*/ */
async function xhrSegment(segment, id, editSecret, fullDigest) { async function xhrSegment(segment, id, editSecret, fullDigest, part, partOf) {
setUState(UploadState.Hashing);
const digest = await crypto.subtle.digest(DigestAlgo, segment);
setUState(UploadState.Uploading); setUState(UploadState.Uploading);
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
@ -114,10 +114,10 @@ export function FileUpload(props) {
req.upload.onprogress = handleProgress; req.upload.onprogress = handleProgress;
req.open("POST", typeof (id) === "string" ? `${ApiHost}/upload/${id}` : `${ApiHost}/upload`); req.open("POST", typeof (id) === "string" ? `${ApiHost}/upload/${id}` : `${ApiHost}/upload`);
req.setRequestHeader("Content-Type", "application/octet-stream"); req.setRequestHeader("Content-Type", "application/octet-stream");
req.setRequestHeader("V-Content-Type", props.file.type); req.setRequestHeader("V-Content-Type", props.file.type.length === 0 ? "application/octet-stream" : props.file.type);
req.setRequestHeader("V-Filename", props.file.name); req.setRequestHeader("V-Filename", props.file.name);
req.setRequestHeader("V-Digest", buf2hex(digest));
req.setRequestHeader("V-Full-Digest", fullDigest); req.setRequestHeader("V-Full-Digest", fullDigest);
req.setRequestHeader("V-Segment", `${part}/${partOf}`)
if (auth) { if (auth) {
req.setRequestHeader("Authorization", `Bearer ${auth}`); req.setRequestHeader("Authorization", `Bearer ${auth}`);
} }
@ -136,14 +136,16 @@ export function FileUpload(props) {
// upload file in segments of 50MB // upload file in segments of 50MB
const UploadSize = 50_000_000; const UploadSize = 50_000_000;
setUState(UploadState.Hashing);
let digest = await crypto.subtle.digest(DigestAlgo, await props.file.arrayBuffer()); let digest = await crypto.subtle.digest(DigestAlgo, await props.file.arrayBuffer());
let xhr = null; let xhr = null;
const segments = props.file.size / UploadSize; const segments = Math.ceil(props.file.size / UploadSize);
for (let s = 0; s < segments; s++) { for (let s = 0; s < segments; s++) {
calc.ResetLastLoaded();
let offset = s * UploadSize; let offset = s * UploadSize;
let slice = props.file.slice(offset, offset + UploadSize, props.file.type); let slice = props.file.slice(offset, offset + UploadSize, props.file.type);
let segment = await slice.arrayBuffer(); let segment = await slice.arrayBuffer();
xhr = await xhrSegment(segment, xhr?.file?.id, xhr?.file?.metadata?.editSecret, buf2hex(digest)); xhr = await xhrSegment(segment, xhr?.file?.id, xhr?.file?.metadata?.editSecret, buf2hex(digest), s + 1, segments);
if (!xhr.ok) { if (!xhr.ok) {
break; break;
} }

View File

@ -10,6 +10,7 @@ import {buf2hex, hasFlag} from "./Util";
import moment from "moment"; import moment from "moment";
import {FileList} from "./FileList"; import {FileList} from "./FileList";
import {VoidButton} from "./VoidButton"; import {VoidButton} from "./VoidButton";
import ApiKeyList from "./ApiKeyList";
export function Profile() { export function Profile() {
const [profile, setProfile] = useState(); const [profile, setProfile] = useState();
@ -210,6 +211,7 @@ export function Profile() {
{needsEmailVerify ? renderEmailVerify() : null} {needsEmailVerify ? renderEmailVerify() : null}
<h1>Uploads</h1> <h1>Uploads</h1>
<FileList loadPage={(req) => Api.listUserFiles(profile.id, req)}/> <FileList loadPage={(req) => Api.listUserFiles(profile.id, req)}/>
{cantEditProfile ? <ApiKeyList/> : null}
</div> </div>
</div> </div>
); );

View File

@ -4,6 +4,10 @@ export class RateCalculator {
this.lastLoaded = 0; this.lastLoaded = 0;
} }
ResetLastLoaded() {
this.lastLoaded = 0;
}
ReportProgress(amount) { ReportProgress(amount) {
this.reports.push({ this.reports.push({
time: new Date().getTime(), time: new Date().getTime(),

View File

@ -0,0 +1,35 @@
.modal-bg {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
}
.modal-bg .modal {
min-height: 100px;
min-width: 300px;
background-color: #bbb;
color: #000;
border-radius: 10px;
overflow: hidden;
}
.modal-bg .modal .modal-header {
text-align: center;
border-bottom: 1px solid;
margin: 0;
line-height: 2em;
background-color: #222;
color: #bbb;
font-weight: bold;
text-transform: uppercase;
}
.modal-bg .modal .modal-body {
padding: 10px;
}

View File

@ -0,0 +1,19 @@
import "./VoidModal.css";
export default function VoidModal(props) {
const title = props.title;
const style = props.style;
return (
<div className="modal-bg">
<div className="modal" style={style}>
<div className="modal-header">
{title ?? "Unknown modal"}
</div>
<div className="modal-body">
{props.children ?? "Missing body"}
</div>
</div>
</div>
)
}

View File

@ -67,4 +67,29 @@ input[type="text"], input[type="number"], input[type="password"], select {
padding: 10px 20px; padding: 10px 20px;
margin: 5px; margin: 5px;
border: 0; border: 0;
}
table {
width: 100%;
word-break: keep-all;
text-overflow: ellipsis;
white-space: nowrap;
border-collapse: collapse;
}
table tr:nth-child(2n) {
background-color: #111;
}
table th {
background-color: #222;
text-align: start;
}
pre.copy {
user-select: all;
width: fit-content;
border-radius: 4px;
border: 1px solid;
padding: 5px;
} }