forked from Kieran/void.cat
Multi-Storage backend support
Api Keys support
This commit is contained in:
parent
677c3593f1
commit
99907fce8b
@ -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;
|
||||||
@ -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("user/{id}")]
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
@ -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,13 +44,13 @@ 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);
|
||||||
var tokenWriter = new JwtSecurityTokenHandler();
|
var tokenWriter = new JwtSecurityTokenHandler();
|
||||||
@ -73,13 +78,13 @@ 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);
|
||||||
var tokenWriter = new JwtSecurityTokenHandler();
|
var tokenWriter = new JwtSecurityTokenHandler();
|
||||||
@ -91,6 +96,74 @@ public class AuthController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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(CreateApiToken(user, expiry)),
|
||||||
|
Expiry = expiry
|
||||||
|
};
|
||||||
|
|
||||||
|
await _apiKeyStore.Add(key.Id, key);
|
||||||
|
return Json(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JwtSecurityToken CreateApiToken(VoidUser user, DateTime expiry)
|
||||||
|
{
|
||||||
|
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.JwtSettings.Key));
|
||||||
|
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
|
var claims = new List<Claim>()
|
||||||
|
{
|
||||||
|
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.Aud, "API"),
|
||||||
|
new(JwtRegisteredClaimNames.Exp, new DateTimeOffset(expiry).ToUnixTimeSeconds().ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString())
|
||||||
|
};
|
||||||
|
|
||||||
|
claims.AddRange(user.Roles.Select(a => new Claim(ClaimTypes.Role, a)));
|
||||||
|
|
||||||
|
return new JwtSecurityToken(_settings.JwtSettings.Issuer, claims: claims,
|
||||||
|
signingCredentials: credentials);
|
||||||
|
}
|
||||||
|
|
||||||
private JwtSecurityToken CreateToken(VoidUser user)
|
private JwtSecurityToken CreateToken(VoidUser user)
|
||||||
{
|
{
|
||||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.JwtSettings.Key));
|
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.JwtSettings.Key));
|
||||||
@ -102,13 +175,13 @@ public class AuthController : Controller
|
|||||||
new(JwtRegisteredClaimNames.Exp, DateTimeOffset.UtcNow.AddHours(6).ToUnixTimeSeconds().ToString()),
|
new(JwtRegisteredClaimNames.Exp, DateTimeOffset.UtcNow.AddHours(6).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 +193,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 +202,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);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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,13 +70,25 @@ 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 digest = Request.Headers.GetHeader("V-Digest");
|
||||||
@ -160,6 +177,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 +258,7 @@ namespace VoidCat.Controllers
|
|||||||
Handle = req.Strike.Handle,
|
Handle = req.Strike.Handle,
|
||||||
Cost = req.Strike.Cost
|
Cost = req.Strike.Cost
|
||||||
});
|
});
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -303,4 +322,4 @@ namespace VoidCat.Controllers
|
|||||||
|
|
||||||
public StrikePaywallConfig? Strike { get; init; }
|
public StrikePaywallConfig? Strike { get; init; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
18
VoidCat/Model/ApiKey.cs
Normal 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; }
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
@ -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 />
|
||||||
|
16
VoidCat/Services/Abstractions/IApiKeyStore.cs
Normal file
16
VoidCat/Services/Abstractions/IApiKeyStore.cs
Normal 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);
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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);
|
||||||
}
|
}
|
@ -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));
|
||||||
|
@ -9,29 +9,42 @@ 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>()));
|
||||||
|
|
||||||
|
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>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
89
VoidCat/Services/Files/FileSystemFactory.cs
Normal file
89
VoidCat/Services/Files/FileSystemFactory.cs
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
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 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -31,6 +31,9 @@ public class LocalDiskFileStore : StreamFileStore, IFileStore
|
|||||||
await EgressFromStream(fs, request, outStream, cts);
|
await EgressFromStream(fs, request, outStream, cts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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)
|
||||||
{
|
{
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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,7 +143,7 @@ public class S3FileMetadataStore : IFileMetadataStore
|
|||||||
if (ret != default)
|
if (ret != default)
|
||||||
{
|
{
|
||||||
ret.Id = id;
|
ret.Id = id;
|
||||||
if (_includeUrl)
|
if (_config.Direct)
|
||||||
{
|
{
|
||||||
var ub = new UriBuilder(_config.ServiceUrl!)
|
var ub = new UriBuilder(_config.ServiceUrl!)
|
||||||
{
|
{
|
||||||
|
@ -5,6 +5,7 @@ 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;
|
||||||
@ -12,22 +13,28 @@ public class S3FileStore : StreamFileStore, IFileStore
|
|||||||
private readonly S3BlobConfig _config;
|
private readonly S3BlobConfig _config;
|
||||||
private readonly IAggregateStatsCollector _statsCollector;
|
private readonly IAggregateStatsCollector _statsCollector;
|
||||||
|
|
||||||
public S3FileStore(VoidSettings settings, IAggregateStatsCollector stats, IFileInfoManager fileInfo) : base(stats)
|
public S3FileStore(S3BlobConfig settings, IAggregateStatsCollector stats, IFileInfoManager fileInfo) : base(stats)
|
||||||
{
|
{
|
||||||
_fileInfo = fileInfo;
|
_fileInfo = fileInfo;
|
||||||
_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.IsAppend) throw new InvalidOperationException("Cannot append to S3 store");
|
||||||
|
|
||||||
var req = new PutObjectRequest
|
var req = new PutObjectRequest
|
||||||
{
|
{
|
||||||
BucketName = _config.BucketName,
|
BucketName = _config.BucketName,
|
||||||
Key = payload.Id.ToString(),
|
Key = payload.Id.ToString(),
|
||||||
InputStream = payload.InStream,
|
InputStream = payload.InStream,
|
||||||
ContentType = "application/octet-stream",
|
ContentType = payload.Meta.MimeType ?? "application/octet-stream",
|
||||||
AutoResetStreamPosition = false,
|
AutoResetStreamPosition = false,
|
||||||
AutoCloseStream = false,
|
AutoCloseStream = false,
|
||||||
ChecksumAlgorithm = ChecksumAlgorithm.SHA256,
|
ChecksumAlgorithm = ChecksumAlgorithm.SHA256,
|
||||||
@ -47,6 +54,7 @@ public class S3FileStore : StreamFileStore, IFileStore
|
|||||||
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);
|
||||||
@ -108,11 +116,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()
|
||||||
|
37
VoidCat/Services/Migrations/Database/02-MinorVersion1.cs
Normal file
37
VoidCat/Services/Migrations/Database/02-MinorVersion1.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
20
VoidCat/Services/Users/CacheApiKeyStore.cs
Normal file
20
VoidCat/Services/Users/CacheApiKeyStore.cs
Normal 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}";
|
||||||
|
}
|
@ -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)
|
||||||
|
59
VoidCat/Services/Users/PostgresApiKeyStore.cs
Normal file
59
VoidCat/Services/Users/PostgresApiKeyStore.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -9,10 +9,11 @@ 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
|
||||||
{
|
{
|
||||||
|
@ -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)
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
46
VoidCat/spa/src/Admin/EditUser.js
Normal file
46
VoidCat/spa/src/Admin/EditUser.js
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -27,7 +27,8 @@ export function useApi() {
|
|||||||
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/user`, pageReq, auth),
|
||||||
|
updateUser: (user) => getJson("POST", `/admin/user/${user.id}`, 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)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
69
VoidCat/spa/src/ApiKeyList.js
Normal file
69
VoidCat/spa/src/ApiKeyList.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
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)
|
||||||
|
});
|
||||||
|
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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -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;
|
|
||||||
}
|
|
@ -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>
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
35
VoidCat/spa/src/VoidModal.css
Normal file
35
VoidCat/spa/src/VoidModal.css
Normal 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;
|
||||||
|
}
|
19
VoidCat/spa/src/VoidModal.js
Normal file
19
VoidCat/spa/src/VoidModal.js
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user