mirror of
https://git.v0l.io/Kieran/void.cat.git
synced 2025-03-27 00:31:44 +01:00
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 VoidCat.Model;
|
||||
using VoidCat.Services.Abstractions;
|
||||
using VoidCat.Services.Files;
|
||||
|
||||
namespace VoidCat.Controllers.Admin;
|
||||
|
||||
@ -9,13 +10,13 @@ namespace VoidCat.Controllers.Admin;
|
||||
[Authorize(Policy = Policies.RequireAdmin)]
|
||||
public class AdminController : Controller
|
||||
{
|
||||
private readonly IFileStore _fileStore;
|
||||
private readonly FileStoreFactory _fileStore;
|
||||
private readonly IFileMetadataStore _fileMetadata;
|
||||
private readonly IFileInfoManager _fileInfo;
|
||||
private readonly IUserStore _userStore;
|
||||
private readonly IUserUploadsStore _userUploads;
|
||||
|
||||
public AdminController(IFileStore fileStore, IUserStore userStore, IFileInfoManager fileInfo,
|
||||
public AdminController(FileStoreFactory fileStore, IUserStore userStore, IFileInfoManager fileInfo,
|
||||
IFileMetadataStore fileMetadata, IUserUploadsStore userUploads)
|
||||
{
|
||||
_fileStore = fileStore;
|
||||
@ -74,6 +75,7 @@ public class AdminController : Controller
|
||||
var uploads = await _userUploads.ListFiles(a.Id, new(0, int.MaxValue));
|
||||
return new AdminListedUser(a, uploads.TotalResults);
|
||||
}).ToListAsync();
|
||||
|
||||
return new()
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -15,12 +15,17 @@ public class AuthController : Controller
|
||||
private readonly IUserManager _manager;
|
||||
private readonly VoidSettings _settings;
|
||||
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;
|
||||
_settings = settings;
|
||||
_captchaVerifier = captchaVerifier;
|
||||
_apiKeyStore = apiKeyStore;
|
||||
_userStore = userStore1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -39,13 +44,13 @@ public class AuthController : Controller
|
||||
var error = ControllerContext.ModelState.FirstOrDefault().Value?.Errors.FirstOrDefault()?.ErrorMessage;
|
||||
return new(null, error);
|
||||
}
|
||||
|
||||
|
||||
// check captcha
|
||||
if (!await _captchaVerifier.Verify(req.Captcha))
|
||||
{
|
||||
return new(null, "Captcha verification failed");
|
||||
}
|
||||
|
||||
|
||||
var user = await _manager.Login(req.Username, req.Password);
|
||||
var token = CreateToken(user);
|
||||
var tokenWriter = new JwtSecurityTokenHandler();
|
||||
@ -73,13 +78,13 @@ public class AuthController : Controller
|
||||
var error = ControllerContext.ModelState.FirstOrDefault().Value?.Errors.FirstOrDefault()?.ErrorMessage;
|
||||
return new(null, error);
|
||||
}
|
||||
|
||||
|
||||
// check captcha
|
||||
if (!await _captchaVerifier.Verify(req.Captcha))
|
||||
{
|
||||
return new(null, "Captcha verification failed");
|
||||
}
|
||||
|
||||
|
||||
var newUser = await _manager.Register(req.Username, req.Password);
|
||||
var token = CreateToken(newUser);
|
||||
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)
|
||||
{
|
||||
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.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);
|
||||
}
|
||||
|
||||
|
||||
public sealed class LoginRequest
|
||||
{
|
||||
public LoginRequest(string username, string password)
|
||||
@ -120,7 +193,7 @@ public class AuthController : Controller
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Username { get; }
|
||||
|
||||
|
||||
[Required]
|
||||
[MinLength(6)]
|
||||
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 CreateApiKeyRequest(DateTime Expiry);
|
||||
}
|
||||
|
@ -3,18 +3,19 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using VoidCat.Model;
|
||||
using VoidCat.Model.Paywall;
|
||||
using VoidCat.Services.Abstractions;
|
||||
using VoidCat.Services.Files;
|
||||
|
||||
namespace VoidCat.Controllers;
|
||||
|
||||
[Route("d")]
|
||||
public class DownloadController : Controller
|
||||
{
|
||||
private readonly IFileStore _storage;
|
||||
private readonly FileStoreFactory _storage;
|
||||
private readonly IFileInfoManager _fileInfo;
|
||||
private readonly IPaywallOrderStore _paywallOrders;
|
||||
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)
|
||||
{
|
||||
_storage = storage;
|
||||
|
@ -11,14 +11,16 @@ public class InfoController : Controller
|
||||
private readonly IFileMetadataStore _fileMetadata;
|
||||
private readonly VoidSettings _settings;
|
||||
private readonly ITimeSeriesStatsReporter _timeSeriesStats;
|
||||
private readonly IEnumerable<string?> _fileStores;
|
||||
|
||||
public InfoController(IStatsReporter statsReporter, IFileMetadataStore fileMetadata, VoidSettings settings,
|
||||
ITimeSeriesStatsReporter stats)
|
||||
ITimeSeriesStatsReporter stats, IEnumerable<IFileStore> fileStores)
|
||||
{
|
||||
_statsReporter = statsReporter;
|
||||
_fileMetadata = fileMetadata;
|
||||
_settings = settings;
|
||||
_timeSeriesStats = stats;
|
||||
_fileStores = fileStores.Select(a => a.Key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -34,9 +36,10 @@ public class InfoController : Controller
|
||||
|
||||
return new(bw, storeStats.Size, storeStats.Files, BuildInfo.GetBuildInfo(),
|
||||
_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,
|
||||
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.Paywall;
|
||||
using VoidCat.Services.Abstractions;
|
||||
using VoidCat.Services.Files;
|
||||
|
||||
namespace VoidCat.Controllers
|
||||
{
|
||||
[Route("upload")]
|
||||
public class UploadController : Controller
|
||||
{
|
||||
private readonly IFileStore _storage;
|
||||
private readonly FileStoreFactory _storage;
|
||||
private readonly IFileMetadataStore _metadata;
|
||||
private readonly IPaywallStore _paywall;
|
||||
private readonly IPaywallFactory _paywallFactory;
|
||||
private readonly IFileInfoManager _fileInfo;
|
||||
private readonly IUserUploadsStore _userUploads;
|
||||
private readonly IUserStore _userStore;
|
||||
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,
|
||||
ITimeSeriesStatsReporter timeSeriesStats)
|
||||
ITimeSeriesStatsReporter timeSeriesStats, IUserStore userStore, VoidSettings settings)
|
||||
{
|
||||
_storage = storage;
|
||||
_metadata = metadata;
|
||||
@ -31,6 +34,8 @@ namespace VoidCat.Controllers
|
||||
_fileInfo = fileInfo;
|
||||
_userUploads = userUploads;
|
||||
_timeSeriesStats = timeSeriesStats;
|
||||
_userStore = userStore;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
MimeType = mime,
|
||||
Name = filename,
|
||||
Description = Request.Headers.GetHeader("V-Description"),
|
||||
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");
|
||||
@ -160,6 +177,7 @@ namespace VoidCat.Controllers
|
||||
public async Task<IActionResult> GetInfo([FromRoute] string id)
|
||||
{
|
||||
if (!id.TryFromBase58Guid(out var fid)) return StatusCode(404);
|
||||
|
||||
var uid = HttpContext.GetUserId();
|
||||
var isOwner = uid.HasValue && await _userUploads.Uploader(fid) == uid;
|
||||
|
||||
@ -240,6 +258,7 @@ namespace VoidCat.Controllers
|
||||
Handle = req.Strike.Handle,
|
||||
Cost = req.Strike.Cost
|
||||
});
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@ -303,4 +322,4 @@ namespace VoidCat.Controllers
|
||||
|
||||
public StrikePaywallConfig? Strike { get; init; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,7 +12,8 @@ public class UserController : Controller
|
||||
private readonly IEmailVerification _emailVerification;
|
||||
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;
|
||||
_userUploads = userUploads;
|
||||
@ -42,6 +43,7 @@ public class UserController : Controller
|
||||
{
|
||||
var pUser = await _store.Get<PrivateVoidUser>(requestedId);
|
||||
if (pUser == default) return NotFound();
|
||||
|
||||
return Json(pUser);
|
||||
}
|
||||
|
||||
@ -161,4 +163,4 @@ public class UserController : Controller
|
||||
var gid = id.FromBase58Guid();
|
||||
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
|
||||
/// </summary>
|
||||
public DateTimeOffset? Expires { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// What storage system the file is on
|
||||
/// </summary>
|
||||
public string? Storage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -75,6 +75,16 @@ namespace VoidCat.Model
|
||||
/// Prometheus server for querying metrics
|
||||
/// </summary>
|
||||
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
|
||||
@ -99,17 +109,18 @@ namespace VoidCat.Model
|
||||
|
||||
public sealed class CloudStorageSettings
|
||||
{
|
||||
public bool ServeFromCloud { get; init; }
|
||||
public S3BlobConfig? S3 { get; set; }
|
||||
public S3BlobConfig[]? S3 { get; init; }
|
||||
}
|
||||
|
||||
public sealed class S3BlobConfig
|
||||
{
|
||||
public string Name { get; init; } = null!;
|
||||
public string? AccessKey { get; init; }
|
||||
public string? SecretKey { get; init; }
|
||||
public Uri? ServiceUrl { get; init; }
|
||||
public string? Region { get; init; }
|
||||
public string? BucketName { get; init; } = "void-cat";
|
||||
public bool Direct { get; init; }
|
||||
}
|
||||
|
||||
public sealed class VirusScannerSettings
|
||||
|
@ -82,7 +82,12 @@ public class PrivateVoidUser : VoidUser
|
||||
/// <summary>
|
||||
/// Users email address
|
||||
/// </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 />
|
||||
|
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>
|
||||
public interface IFileStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Return key for named instance
|
||||
/// </summary>
|
||||
string? Key { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Ingress a file into the system (Upload)
|
||||
/// </summary>
|
||||
|
@ -43,4 +43,11 @@ public interface IUserStore : IPublicPrivateStore<VoidUser, InternalVoidUser>
|
||||
/// <param name="timestamp"></param>
|
||||
/// <returns></returns>
|
||||
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.Services.Abstractions;
|
||||
using VoidCat.Services.Files;
|
||||
|
||||
namespace VoidCat.Services.Background;
|
||||
|
||||
@ -23,7 +24,7 @@ public class DeleteUnverifiedAccounts : BackgroundService
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var userStore = scope.ServiceProvider.GetRequiredService<IUserStore>();
|
||||
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 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)
|
||||
{
|
||||
services.AddTransient<IFileInfoManager, FileInfoManager>();
|
||||
|
||||
services.AddTransient<FileStoreFactory>();
|
||||
|
||||
if (settings.CloudStorage != default)
|
||||
{
|
||||
services.AddTransient<IUserUploadsStore, CacheUserUploadStore>();
|
||||
|
||||
// cloud storage
|
||||
if (settings.CloudStorage.S3 != default)
|
||||
// S3 storage
|
||||
foreach (var s3 in settings.CloudStorage.S3 ?? Array.Empty<S3BlobConfig>())
|
||||
{
|
||||
services.AddSingleton<IFileStore, S3FileStore>();
|
||||
services.AddSingleton<IFileMetadataStore, S3FileMetadataStore>();
|
||||
services.AddTransient<IFileStore>((svc) =>
|
||||
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<IFileStore, LocalDiskFileStore>();
|
||||
services.AddTransient<IFileMetadataStore, PostgresFileMetadataStore>();
|
||||
if (settings.MetadataStore == "postgres")
|
||||
{
|
||||
services.AddSingleton<IFileMetadataStore, PostgresFileMetadataStore>();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddTransient<IUserUploadsStore, CacheUserUploadStore>();
|
||||
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.MimeType = meta.MimeType ?? oldMeta.MimeType;
|
||||
oldMeta.Expires = meta.Expires ?? oldMeta.Expires;
|
||||
oldMeta.Storage = meta.Storage ?? oldMeta.Storage;
|
||||
|
||||
await Set(id, oldMeta);
|
||||
}
|
||||
|
@ -31,6 +31,9 @@ public class LocalDiskFileStore : StreamFileStore, IFileStore
|
||||
await EgressFromStream(fs, request, outStream, cts);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => "local-disk";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<PrivateVoidFile> Ingress(IngressPayload payload, CancellationToken cts)
|
||||
{
|
||||
|
@ -13,7 +13,10 @@ public class PostgresFileMetadataStore : IFileMetadataStore
|
||||
{
|
||||
_connection = connection;
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? Key => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<VoidFileMeta?> Get(Guid id)
|
||||
{
|
||||
@ -32,9 +35,15 @@ public class PostgresFileMetadataStore : IFileMetadataStore
|
||||
await using var conn = await _connection.Get();
|
||||
await conn.ExecuteAsync(
|
||||
@"insert into
|
||||
""Files""(""Id"", ""Name"", ""Size"", ""Uploaded"", ""Description"", ""MimeType"", ""Digest"", ""EditSecret"", ""Expires"")
|
||||
values(:id, :name, :size, :uploaded, :description, :mimeType, :digest, :editSecret, :expires)
|
||||
on conflict (""Id"") do update set ""Name"" = :name, ""Size"" = :size, ""Description"" = :description, ""MimeType"" = :mimeType, ""Expires"" = :expires",
|
||||
""Files""(""Id"", ""Name"", ""Size"", ""Uploaded"", ""Description"", ""MimeType"", ""Digest"", ""EditSecret"", ""Expires"", ""Storage"")
|
||||
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,
|
||||
""Storage"" = :store",
|
||||
new
|
||||
{
|
||||
id,
|
||||
@ -45,7 +54,8 @@ on conflict (""Id"") do update set ""Name"" = :name, ""Size"" = :size, ""Descrip
|
||||
mimeType = obj.MimeType,
|
||||
digest = obj.Digest,
|
||||
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.MimeType = meta.MimeType ?? oldMeta.MimeType;
|
||||
oldMeta.Expires = meta.Expires ?? oldMeta.Expires;
|
||||
oldMeta.Storage = meta.Storage ?? oldMeta.Storage;
|
||||
|
||||
await Set(id, oldMeta);
|
||||
}
|
||||
|
@ -11,16 +11,17 @@ public class S3FileMetadataStore : IFileMetadataStore
|
||||
private readonly ILogger<S3FileMetadataStore> _logger;
|
||||
private readonly AmazonS3Client _client;
|
||||
private readonly S3BlobConfig _config;
|
||||
private readonly bool _includeUrl;
|
||||
|
||||
public S3FileMetadataStore(VoidSettings settings, ILogger<S3FileMetadataStore> logger)
|
||||
public S3FileMetadataStore(S3BlobConfig settings, ILogger<S3FileMetadataStore> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_includeUrl = settings.CloudStorage?.ServeFromCloud ?? false;
|
||||
_config = settings.CloudStorage!.S3!;
|
||||
_config = settings;
|
||||
_client = _config.CreateClient();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? Key => _config.Name;
|
||||
|
||||
/// <inheritdoc />
|
||||
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.MimeType = meta.MimeType ?? oldMeta.MimeType;
|
||||
oldMeta.Expires = meta.Expires ?? oldMeta.Expires;
|
||||
oldMeta.Storage = meta.Storage ?? oldMeta.Storage;
|
||||
|
||||
await Set(id, oldMeta);
|
||||
}
|
||||
@ -141,7 +143,7 @@ public class S3FileMetadataStore : IFileMetadataStore
|
||||
if (ret != default)
|
||||
{
|
||||
ret.Id = id;
|
||||
if (_includeUrl)
|
||||
if (_config.Direct)
|
||||
{
|
||||
var ub = new UriBuilder(_config.ServiceUrl!)
|
||||
{
|
||||
|
@ -5,6 +5,7 @@ using VoidCat.Services.Abstractions;
|
||||
|
||||
namespace VoidCat.Services.Files;
|
||||
|
||||
/// <inheritdoc cref="VoidCat.Services.Abstractions.IFileStore" />
|
||||
public class S3FileStore : StreamFileStore, IFileStore
|
||||
{
|
||||
private readonly IFileInfoManager _fileInfo;
|
||||
@ -12,22 +13,28 @@ public class S3FileStore : StreamFileStore, IFileStore
|
||||
private readonly S3BlobConfig _config;
|
||||
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;
|
||||
_statsCollector = stats;
|
||||
_config = settings.CloudStorage!.S3!;
|
||||
_config = settings;
|
||||
_client = _config.CreateClient();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => _config.Name;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<PrivateVoidFile> Ingress(IngressPayload payload, CancellationToken cts)
|
||||
{
|
||||
if (payload.IsAppend) throw new InvalidOperationException("Cannot append to S3 store");
|
||||
|
||||
var req = new PutObjectRequest
|
||||
{
|
||||
BucketName = _config.BucketName,
|
||||
Key = payload.Id.ToString(),
|
||||
InputStream = payload.InStream,
|
||||
ContentType = "application/octet-stream",
|
||||
ContentType = payload.Meta.MimeType ?? "application/octet-stream",
|
||||
AutoResetStreamPosition = false,
|
||||
AutoCloseStream = false,
|
||||
ChecksumAlgorithm = ChecksumAlgorithm.SHA256,
|
||||
@ -47,6 +54,7 @@ public class S3FileStore : StreamFileStore, IFileStore
|
||||
return HandleCompletedUpload(payload, payload.Meta.Size);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken 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)
|
||||
{
|
||||
await _client.DeleteObjectAsync(_config.BucketName, id.ToString());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<Stream> Open(EgressRequest request, CancellationToken cts)
|
||||
{
|
||||
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.Services.Abstractions;
|
||||
using VoidCat.Services.Files;
|
||||
|
||||
namespace VoidCat.Services.Migrations;
|
||||
|
||||
@ -8,9 +9,9 @@ public class FixSize : IMigration
|
||||
{
|
||||
private readonly ILogger<FixSize> _logger;
|
||||
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;
|
||||
_fileMetadata = fileMetadata;
|
||||
|
@ -18,11 +18,11 @@ public class MigrateToPostgres : IMigration
|
||||
private readonly IPaywallStore _paywallStore;
|
||||
private readonly IUserStore _userStore;
|
||||
private readonly IUserUploadsStore _userUploads;
|
||||
private readonly IFileStore _fileStore;
|
||||
private readonly FileStoreFactory _fileStore;
|
||||
|
||||
public MigrateToPostgres(VoidSettings settings, ILogger<MigrateToPostgres> logger, IFileMetadataStore fileMetadata,
|
||||
ICache cache, IPaywallStore paywallStore, IUserStore userStore, IUserUploadsStore userUploads,
|
||||
IFileStore fileStore)
|
||||
FileStoreFactory fileStore)
|
||||
{
|
||||
_logger = logger;
|
||||
_settings = settings;
|
||||
@ -75,6 +75,7 @@ public class MigrateToPostgres : IMigration
|
||||
{
|
||||
var fs = await _fileStore.Open(new(file.Id, Enumerable.Empty<RangeRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
var hash = await SHA256.Create().ComputeHashAsync(fs);
|
||||
file.Digest = hash.ToHex();
|
||||
}
|
||||
@ -143,6 +144,7 @@ public class MigrateToPostgres : IMigration
|
||||
Password = privateUser.Password!,
|
||||
Roles = privateUser.Roles
|
||||
});
|
||||
|
||||
_logger.LogInformation("Migrated user {USer}", user.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@ -163,4 +165,4 @@ public class MigrateToPostgres : IMigration
|
||||
[JsonConverter(typeof(Base58GuidConverter))]
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 />
|
||||
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,
|
||||
lastLogin = obj.LastLogin.ToUniversalTime(),
|
||||
avatar = obj.Avatar,
|
||||
flags = (int) obj.Flags
|
||||
flags = (int)obj.Flags
|
||||
});
|
||||
|
||||
if (obj.Roles.Any(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();
|
||||
var user = await conn.QuerySingleOrDefaultAsync<T?>(@"select * from ""Users"" where ""Id"" = :id",
|
||||
new {id});
|
||||
|
||||
if (user != default)
|
||||
{
|
||||
var roles = await conn.QueryAsync<string>(
|
||||
@"select ""Role"" from ""UserRoles"" where ""User"" = :id",
|
||||
new {id});
|
||||
|
||||
foreach (var r in roles)
|
||||
{
|
||||
user.Roles.Add(r);
|
||||
@ -106,11 +109,13 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla
|
||||
PagedSortBy.Name => "DisplayName",
|
||||
_ => "Id"
|
||||
};
|
||||
|
||||
var sortBy = request.SortOrder switch
|
||||
{
|
||||
PageSortOrder.Dsc => "desc",
|
||||
_ => "asc"
|
||||
};
|
||||
|
||||
await using var iconn = await _connection.Get();
|
||||
var users = await iconn.ExecuteReaderAsync(
|
||||
$@"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,
|
||||
limit = request.PageSize
|
||||
});
|
||||
|
||||
var rowParser = users.GetRowParser<PrivateVoidUser>();
|
||||
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;
|
||||
await using var conn = await _connection.Get();
|
||||
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
|
||||
{
|
||||
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",
|
||||
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>();
|
||||
|
||||
if (settings.Postgres != default)
|
||||
if (settings.HasPostgres())
|
||||
{
|
||||
services.AddTransient<IUserStore, PostgresUserStore>();
|
||||
services.AddTransient<IEmailVerification, PostgresEmailVerification>();
|
||||
services.AddTransient<IApiKeyStore, PostgresApiKeyStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -1,6 +1,7 @@
|
||||
using nClam;
|
||||
using VoidCat.Model;
|
||||
using VoidCat.Services.Abstractions;
|
||||
using VoidCat.Services.Files;
|
||||
|
||||
namespace VoidCat.Services.VirusScanner;
|
||||
|
||||
@ -11,13 +12,13 @@ public class ClamAvScanner : IVirusScanner
|
||||
{
|
||||
private readonly ILogger<ClamAvScanner> _logger;
|
||||
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;
|
||||
_clam = clam;
|
||||
_store = store;
|
||||
_fileSystemFactory = fileSystemFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@ -25,7 +26,7 @@ public class ClamAvScanner : IVirusScanner
|
||||
{
|
||||
_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);
|
||||
|
||||
if (result.Result == ClamScanResults.Error)
|
||||
|
@ -10,7 +10,7 @@
|
||||
<HostSPA>True</HostSPA>
|
||||
<DefineConstants Condition="'$(HostSPA)' == 'True'">$(DefineConstants);HostSPA</DefineConstants>
|
||||
<DocumentationFile>$(AssemblyName).xml</DocumentationFile>
|
||||
<Version>4.0.0</Version>
|
||||
<Version>4.1.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -9,23 +9,6 @@
|
||||
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 {
|
||||
padding: 5px 8px;
|
||||
border-radius: 3px;
|
||||
|
@ -5,17 +5,20 @@ import {UserList} from "./UserList";
|
||||
import {Navigate} from "react-router-dom";
|
||||
import {useApi} from "../Api";
|
||||
import {VoidButton} from "../VoidButton";
|
||||
import {useState} from "react";
|
||||
import VoidModal from "../VoidModal";
|
||||
import EditUser from "./EditUser";
|
||||
|
||||
export function Admin() {
|
||||
const auth = useSelector((state) => state.login.jwt);
|
||||
const {AdminApi} = useApi();
|
||||
|
||||
const [editUser, setEditUser] = useState(null);
|
||||
|
||||
async function deleteFile(e, id) {
|
||||
if (window.confirm(`Are you sure you want to delete: ${id}?`)) {
|
||||
let req = await AdminApi.deleteFile(id);
|
||||
if (req.ok) {
|
||||
|
||||
|
||||
} else {
|
||||
alert("Failed to delete file!");
|
||||
}
|
||||
@ -28,7 +31,10 @@ export function Admin() {
|
||||
return (
|
||||
<div className="admin">
|
||||
<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>
|
||||
<FileList loadPage={AdminApi.fileList} actions={(i) => {
|
||||
@ -36,6 +42,11 @@ export function Admin() {
|
||||
<VoidButton onClick={(e) => deleteFile(e, i.id)}>Delete</VoidButton>
|
||||
</td>
|
||||
}}/>
|
||||
|
||||
{editUser !== null ?
|
||||
<VoidModal title="Edit user">
|
||||
<EditUser user={editUser} onClose={() => setEditUser(null)}/>
|
||||
</VoidModal> : null}
|
||||
</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 {PageSelector} from "../PageSelector";
|
||||
import moment from "moment";
|
||||
import {VoidButton} from "../VoidButton";
|
||||
|
||||
export function UserList() {
|
||||
export function UserList(props) {
|
||||
const {AdminApi} = useApi();
|
||||
const dispatch = useDispatch();
|
||||
const [users, setUsers] = useState();
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 10;
|
||||
const [accessDenied, setAccessDenied] = useState();
|
||||
const actions = props.actions;
|
||||
|
||||
async function loadUserList() {
|
||||
let pageReq = {
|
||||
@ -40,10 +40,7 @@ export function UserList() {
|
||||
<td>{moment(user.created).fromNow()}</td>
|
||||
<td>{moment(user.lastLogin).fromNow()}</td>
|
||||
<td>{obj.uploads}</td>
|
||||
<td>
|
||||
<VoidButton>Delete</VoidButton>
|
||||
<VoidButton>SetRoles</VoidButton>
|
||||
</td>
|
||||
<td>{actions(user)}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
@ -27,7 +27,8 @@ export function useApi() {
|
||||
AdminApi: {
|
||||
fileList: (pageReq) => getJson("POST", "/admin/file", pageReq, 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: {
|
||||
info: () => getJson("GET", "/info"),
|
||||
@ -42,7 +43,9 @@ export function useApi() {
|
||||
listUserFiles: (uid, pageReq) => getJson("POST", `/user/${uid}/files`, pageReq, auth),
|
||||
submitVerifyCode: (uid, code) => getJson("POST", `/user/${uid}/verify`, code, 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 {Link} from "react-router-dom";
|
||||
import {useDispatch} from "react-redux";
|
||||
@ -59,7 +58,7 @@ export function FileList(props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="file-list">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
|
@ -10,6 +10,7 @@ import {buf2hex, hasFlag} from "./Util";
|
||||
import moment from "moment";
|
||||
import {FileList} from "./FileList";
|
||||
import {VoidButton} from "./VoidButton";
|
||||
import ApiKeyList from "./ApiKeyList";
|
||||
|
||||
export function Profile() {
|
||||
const [profile, setProfile] = useState();
|
||||
@ -210,6 +211,7 @@ export function Profile() {
|
||||
{needsEmailVerify ? renderEmailVerify() : null}
|
||||
<h1>Uploads</h1>
|
||||
<FileList loadPage={(req) => Api.listUserFiles(profile.id, req)}/>
|
||||
{cantEditProfile ? <ApiKeyList/> : null}
|
||||
</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;
|
||||
margin: 5px;
|
||||
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…
x
Reference in New Issue
Block a user