Compare commits

...

6 Commits

Author SHA1 Message Date
defnotken
134268c422 Does this work? 2026-01-15 13:03:49 -06:00
e8c56bb3bc Merge pull request 'Updated links for Xivapi sources' (#54) from xivapi-link-changes into master
Reviewed-on: #54
2026-01-05 12:03:09 +00:00
8e0dcc6792 Updated links for Xivapi sources 2026-01-05 12:01:53 +00:00
ac4174f6e0 Fix moderator status on broadcast (#51)
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #51
Co-authored-by: cake <cake@noreply.git.lightless-sync.org>
Co-committed-by: cake <cake@noreply.git.lightless-sync.org>
2026-01-04 05:14:37 +00:00
9fffaf7df2 fix-prune (#50)
Co-authored-by: Tsubasahane <wozaiha@gmail.com>
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #50
Co-authored-by: cake <cake@noreply.git.lightless-sync.org>
Co-committed-by: cake <cake@noreply.git.lightless-sync.org>
2026-01-04 05:14:22 +00:00
ca0c548373 AddBan changes for ban admin panel rework (#52)
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #52
Co-authored-by: cake <cake@noreply.git.lightless-sync.org>
Co-committed-by: cake <cake@noreply.git.lightless-sync.org>
2026-01-04 05:12:00 +00:00
7 changed files with 259 additions and 119 deletions

View File

@@ -28,25 +28,72 @@ public partial class LightlessHub
var (userHasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false); var (userHasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!userHasRights) return; if (!userHasRights) return;
var (userExists, groupPair) = await TryValidateUserInGroup(dto.Group.GID, dto.User.UID).ConfigureAwait(false); var targetUid = dto.User.UID?.Trim();
if (!userExists) return; if (string.IsNullOrWhiteSpace(targetUid)) return;
if (groupPair.IsModerator || string.Equals(group.OwnerUID, dto.User.UID, StringComparison.Ordinal)) return; if (string.Equals(group.OwnerUID, targetUid, StringComparison.Ordinal))
return;
var alias = string.IsNullOrEmpty(groupPair.GroupUser.Alias) ? "-" : groupPair.GroupUser.Alias; var groupPair = await DbContext.GroupPairs
var ban = new GroupBan() .Include(p => p.GroupUser)
.SingleOrDefaultAsync(p => p.GroupGID == dto.Group.GID && p.GroupUserUID == targetUid, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
if (groupPair?.IsModerator == true)
return;
var now = DateTime.UtcNow;
var existingBan = await DbContext.Set<GroupBan>().SingleOrDefaultAsync(b => b.GroupGID == dto.Group.GID && b.BannedUserUID == targetUid, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
var userExists = await DbContext.Users.AsNoTracking().AnyAsync(u => u.UID == targetUid || u.Alias == targetUid, RequestAbortedToken).ConfigureAwait(false);
if (!userExists && existingBan == null)
return;
const string marker = " (Alias at time of ban:";
string suffix;
if (existingBan?.BannedReason is { } existingReason)
{ {
BannedByUID = UserUID, var idx = existingReason.IndexOf(marker, StringComparison.Ordinal);
BannedReason = $"{reason} (Alias at time of ban: {alias})", suffix = idx >= 0 ? existingReason.Substring(startIndex: idx) : string.Empty;
BannedOn = DateTime.UtcNow, }
BannedUserUID = dto.User.UID, else
GroupGID = dto.Group.GID, {
}; var alias = groupPair?.GroupUser?.Alias;
alias = string.IsNullOrWhiteSpace(alias) ? "-" : alias;
suffix = $" (Alias at time of ban: {alias})";
}
DbContext.Add(ban); var baseReason = (reason ?? string.Empty).Trim();
await DbContext.SaveChangesAsync().ConfigureAwait(false); var finalReason = string.IsNullOrEmpty(suffix) ? baseReason : (baseReason + suffix);
await GroupRemoveUser(dto).ConfigureAwait(false); if (existingBan != null)
{
existingBan.BannedByUID = UserUID;
existingBan.BannedReason = finalReason;
DbContext.Update(existingBan);
}
else
{
var ban = new GroupBan
{
BannedByUID = UserUID,
BannedReason = finalReason,
BannedOn = now,
BannedUserUID = targetUid,
GroupGID = dto.Group.GID,
};
DbContext.Add(ban);
}
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
if (groupPair != null)
{
await GroupRemoveUser(dto).ConfigureAwait(false);
}
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success")); _logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
} }
@@ -326,7 +373,7 @@ public partial class LightlessHub
await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(newGroup.ToGroupData(), self.ToUserData(), await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(newGroup.ToGroupData(), self.ToUserData(),
newGroup.ToEnum(), initialPrefPermissions.ToEnum(), initialPair.ToEnum(), new(StringComparer.Ordinal), 1)) newGroup.ToEnum(), initialPrefPermissions.ToEnum(), initialPair.ToEnum(), new(StringComparer.Ordinal), 1))
.ConfigureAwait(false); .ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(gid)); _logger.LogCallInfo(LightlessHubLogger.Args(gid));
return new GroupJoinDto(newGroup.ToGroupData(), passwd, initialPrefPermissions.ToEnum()); return new GroupJoinDto(newGroup.ToGroupData(), passwd, initialPrefPermissions.ToEnum());
@@ -400,9 +447,9 @@ public partial class LightlessHub
var banEntries = await DbContext.GroupBans.Include(b => b.BannedUser).Where(g => g.GroupGID == dto.Group.GID).AsNoTracking().ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false); var banEntries = await DbContext.GroupBans.Include(b => b.BannedUser).Where(g => g.GroupGID == dto.Group.GID).AsNoTracking().ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false);
List<BannedGroupUserDto> bannedGroupUsers = banEntries.Select(b => List<BannedGroupUserDto> bannedGroupUsers = [.. banEntries.Select(b =>
new BannedGroupUserDto(group.ToGroupData(), b.BannedUser.ToUserData(), b.BannedReason, b.BannedOn, new BannedGroupUserDto(group.ToGroupData(), b.BannedUser.ToUserData(), b.BannedReason, b.BannedOn,
b.BannedByUID)).ToList(); b.BannedByUID))];
_logger.LogCallInfo(LightlessHubLogger.Args(dto, bannedGroupUsers.Count)); _logger.LogCallInfo(LightlessHubLogger.Args(dto, bannedGroupUsers.Count));
@@ -831,7 +878,7 @@ public partial class LightlessHub
} }
var data = await DbContext.GroupProfiles var data = await DbContext.GroupProfiles
.Include(gp => gp.Group) .Include(gp => gp.Group)
.FirstOrDefaultAsync( .FirstOrDefaultAsync(
g => g.Group.GID == dto.Group.GID || g.Group.Alias == dto.Group.AliasOrGID, g => g.Group.GID == dto.Group.GID || g.Group.Alias == dto.Group.AliasOrGID,
cancellationToken cancellationToken
@@ -862,85 +909,85 @@ public partial class LightlessHub
[Authorize(Policy = "Identified")] [Authorize(Policy = "Identified")]
public async Task GroupSetProfile(GroupProfileDto dto) public async Task GroupSetProfile(GroupProfileDto dto)
{ {
_logger.LogCallInfo(LightlessHubLogger.Args(dto)); _logger.LogCallInfo(LightlessHubLogger.Args(dto));
var cancellationToken = RequestAbortedToken; var cancellationToken = RequestAbortedToken;
if (dto.Group == null) return; if (dto.Group == null) return;
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false); var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return; if (!hasRights) return;
var groupProfileDb = await DbContext.GroupProfiles var groupProfileDb = await DbContext.GroupProfiles
.Include(g => g.Group) .Include(g => g.Group)
.FirstOrDefaultAsync(g => g.GroupGID == dto.Group.GID, cancellationToken) .FirstOrDefaultAsync(g => g.GroupGID == dto.Group.GID, cancellationToken)
.ConfigureAwait(false);
ImageCheckService.ImageLoadResult profileResult = new();
ImageCheckService.ImageLoadResult bannerResult = new();
//Avatar image validation
if (!string.IsNullOrEmpty(dto.PictureBase64))
{
profileResult = await ImageCheckService.ValidateImageAsync(dto.PictureBase64, banner: false, RequestAbortedToken).ConfigureAwait(false);
if (!profileResult.Success)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, profileResult.ErrorMessage).ConfigureAwait(false);
return;
}
}
//Banner image validation
if (!string.IsNullOrEmpty(dto.BannerBase64))
{
bannerResult = await ImageCheckService.ValidateImageAsync(dto.BannerBase64, banner: true, RequestAbortedToken).ConfigureAwait(false);
if (!bannerResult.Success)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, bannerResult.ErrorMessage).ConfigureAwait(false);
return;
}
}
var sanitizedProfileImage = profileResult?.Base64Image;
var sanitizedBannerImage = bannerResult?.Base64Image;
if (groupProfileDb == null)
{
groupProfileDb = new GroupProfile
{
GroupGID = dto.Group.GID,
Group = group,
ProfileDisabled = dto.IsDisabled ?? false,
IsNSFW = dto.IsNsfw ?? false,
};
groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage);
await DbContext.GroupProfiles.AddAsync(groupProfileDb, cancellationToken).ConfigureAwait(false);
}
else
{
groupProfileDb.Group ??= group;
groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage);
}
var userIds = await DbContext.GroupPairs
.Where(p => p.GroupGID == groupProfileDb.GroupGID)
.Select(p => p.GroupUserUID)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (userIds.Count > 0)
{
var profileDto = groupProfileDb.ToDTO();
await Clients.Users(userIds).Client_GroupSendProfile(profileDto)
.ConfigureAwait(false); .ConfigureAwait(false);
}
ImageCheckService.ImageLoadResult profileResult = new(); await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
ImageCheckService.ImageLoadResult bannerResult = new();
//Avatar image validation
if (!string.IsNullOrEmpty(dto.PictureBase64))
{
profileResult = await ImageCheckService.ValidateImageAsync(dto.PictureBase64, banner: false, RequestAbortedToken).ConfigureAwait(false);
if (!profileResult.Success)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, profileResult.ErrorMessage).ConfigureAwait(false);
return;
}
}
//Banner image validation
if (!string.IsNullOrEmpty(dto.BannerBase64))
{
bannerResult = await ImageCheckService.ValidateImageAsync(dto.BannerBase64, banner: true, RequestAbortedToken).ConfigureAwait(false);
if (!bannerResult.Success)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, bannerResult.ErrorMessage).ConfigureAwait(false);
return;
}
}
var sanitizedProfileImage = profileResult?.Base64Image;
var sanitizedBannerImage = bannerResult?.Base64Image;
if (groupProfileDb == null)
{
groupProfileDb = new GroupProfile
{
GroupGID = dto.Group.GID,
Group = group,
ProfileDisabled = dto.IsDisabled ?? false,
IsNSFW = dto.IsNsfw ?? false,
};
groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage);
await DbContext.GroupProfiles.AddAsync(groupProfileDb, cancellationToken).ConfigureAwait(false);
}
else
{
groupProfileDb.Group ??= group;
groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage);
}
var userIds = await DbContext.GroupPairs
.Where(p => p.GroupGID == groupProfileDb.GroupGID)
.Select(p => p.GroupUserUID)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (userIds.Count > 0)
{
var profileDto = groupProfileDb.ToDTO();
await Clients.Users(userIds).Client_GroupSendProfile(profileDto)
.ConfigureAwait(false);
}
await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
} }
[Authorize(Policy = "Identified")] [Authorize(Policy = "Identified")]
@@ -1103,11 +1150,11 @@ public partial class LightlessHub
return false; return false;
} }
var (isOwner, _) = await TryValidateOwner(dto.GID).ConfigureAwait(false); var (isOwnerOrMod, _) = await TryValidateGroupModeratorOrOwner(dto.GID).ConfigureAwait(false);
if (!isOwner) if (!isOwnerOrMod)
{ {
_logger.LogCallWarning(LightlessHubLogger.Args("Unauthorized syncshell broadcast change", "User", UserUID, "GID", dto.GID)); _logger.LogCallWarning(LightlessHubLogger.Args("Unauthorized syncshell broadcast change", "User", UserUID, "GID", dto.GID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "You must be the owner of the syncshell to broadcast it."); await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "You must be the owner or moderator of the syncshell to broadcast it.");
return false; return false;
} }

View File

@@ -72,7 +72,7 @@ public class LightlessCensus : IHostedService
Dictionary<ushort, short> worldDcs = new(); Dictionary<ushort, short> worldDcs = new();
var dcs = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/WorldDCGroupType.csv", cancellationToken).ConfigureAwait(false); var dcs = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/en/WorldDCGroupType.csv", cancellationToken).ConfigureAwait(false);
// dc: https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/WorldDCGroupType.csv // dc: https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/WorldDCGroupType.csv
// id, name, region // id, name, region
@@ -92,7 +92,7 @@ public class LightlessCensus : IHostedService
_dcs[id] = name; _dcs[id] = name;
} }
var worlds = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/World.csv", cancellationToken).ConfigureAwait(false); var worlds = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/en/World.csv", cancellationToken).ConfigureAwait(false);
// world: https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/World.csv // world: https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/World.csv
// id, internalname, name, region, usertype, datacenter, ispublic // id, internalname, name, region, usertype, datacenter, ispublic
@@ -114,7 +114,7 @@ public class LightlessCensus : IHostedService
_logger.LogInformation("World: ID: {id}, Name: {name}, DC: {dc}", id, name, dc); _logger.LogInformation("World: ID: {id}, Name: {name}, DC: {dc}", id, name, dc);
} }
var races = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/Race.csv", cancellationToken).ConfigureAwait(false); var races = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/en/Race.csv", cancellationToken).ConfigureAwait(false);
// race: https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/Race.csv // race: https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/Race.csv
// id, masc name, fem name, other crap I don't care about // id, masc name, fem name, other crap I don't care about
@@ -134,7 +134,7 @@ public class LightlessCensus : IHostedService
_logger.LogInformation("Race: ID: {id}, Name: {name}", id, name); _logger.LogInformation("Race: ID: {id}, Name: {name}", id, name);
} }
var tribe = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/Tribe.csv", cancellationToken).ConfigureAwait(false); var tribe = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/en/Tribe.csv", cancellationToken).ConfigureAwait(false);
// tribe: https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/Tribe.csv // tribe: https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/Tribe.csv
// id masc name, fem name, other crap I don't care about // id masc name, fem name, other crap I don't care about

View File

@@ -2,15 +2,19 @@
using LightlessSyncShared.Data; using LightlessSyncShared.Data;
using LightlessSyncShared.Models; using LightlessSyncShared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using StackExchange.Redis.Extensions.Core.Abstractions;
namespace LightlessSyncServer.Services namespace LightlessSyncServer.Services
{ {
public class PruneService(LightlessDbContext dbContext) : IPruneService public class PruneService(LightlessDbContext dbContext, IRedisDatabase redis) : IPruneService
{ {
private readonly LightlessDbContext _dbContext = dbContext; private readonly LightlessDbContext _dbContext = dbContext;
private readonly IRedisDatabase _redis = redis;
public async Task<int> CountPrunableUsersAsync(string groupGid, int days, CancellationToken ct) public async Task<int> CountPrunableUsersAsync(string groupGid, int days, CancellationToken ct)
{ {
var onlineUids = await GetOnlineUidsAsync().ConfigureAwait(false);
var allGroupUsers = await _dbContext.GroupPairs var allGroupUsers = await _dbContext.GroupPairs
.Include(p => p.GroupUser) .Include(p => p.GroupUser)
.Include(p => p.Group) .Include(p => p.Group)
@@ -20,17 +24,14 @@ namespace LightlessSyncServer.Services
var inactivitySpan = GetInactivitySpan(days); var inactivitySpan = GetInactivitySpan(days);
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var usersToPrune = allGroupUsers.Where(p => var usersToPrune = GetPruneUserList(allGroupUsers, onlineUids, inactivitySpan, now);
!p.IsPinned && return usersToPrune.Count;
!p.IsModerator &&
!string.Equals(p.Group.OwnerUID, p.GroupUserUID, StringComparison.Ordinal) &&
p.GroupUser.LastLoggedIn < now - inactivitySpan);
return usersToPrune.Count();
} }
public async Task<IReadOnlyList<GroupPair>> ExecutePruneAsync(string groupGid, int days, CancellationToken ct) public async Task<IReadOnlyList<GroupPair>> ExecutePruneAsync(string groupGid, int days, CancellationToken ct)
{ {
var onlineUids = await GetOnlineUidsAsync().ConfigureAwait(false);
var allGroupUsers = await _dbContext.GroupPairs var allGroupUsers = await _dbContext.GroupPairs
.Include(p => p.GroupUser) .Include(p => p.GroupUser)
.Include(p => p.Group) .Include(p => p.Group)
@@ -40,12 +41,7 @@ namespace LightlessSyncServer.Services
var inactivitySpan = GetInactivitySpan(days); var inactivitySpan = GetInactivitySpan(days);
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var usersToPrune = allGroupUsers.Where(p => var usersToPrune = GetPruneUserList(allGroupUsers, onlineUids, inactivitySpan, now);
!p.IsPinned &&
!p.IsModerator &&
!string.Equals(p.Group.OwnerUID, p.GroupUserUID, StringComparison.Ordinal) &&
p.GroupUser.LastLoggedIn < now - inactivitySpan)
.ToList();
_dbContext.GroupPairs.RemoveRange(usersToPrune); _dbContext.GroupPairs.RemoveRange(usersToPrune);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
@@ -53,8 +49,52 @@ namespace LightlessSyncServer.Services
return usersToPrune; return usersToPrune;
} }
private static TimeSpan GetInactivitySpan(int days) => days == 0 private static List<GroupPair> GetPruneUserList(
? TimeSpan.FromMinutes(15) List<GroupPair> allGroupUsers,
: TimeSpan.FromDays(days); HashSet<string> onlineUids,
TimeSpan inactivitySpan,
DateTime now)
{
return
[
.. allGroupUsers.Where(p =>
!p.IsPinned &&
!p.IsModerator &&
!string.Equals(p.Group.OwnerUID, p.GroupUserUID, StringComparison.Ordinal) &&
!onlineUids.Contains(p.GroupUserUID) &&
p.GroupUser.LastLoggedIn < now - inactivitySpan),
];
}
private async Task<HashSet<string>> GetOnlineUidsAsync()
{
var keys = await _redis.SearchKeysAsync("UID:*").ConfigureAwait(false);
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var k in keys)
{
if (string.IsNullOrEmpty(k)) continue;
const string prefix = "UID:";
if (k.StartsWith(prefix, StringComparison.Ordinal))
{
var uid = k.Substring(prefix.Length);
if (!string.IsNullOrEmpty(uid))
set.Add(uid);
}
else
{
var idx = k.IndexOf(':', StringComparison.Ordinal);
if (idx >= 0 && idx < k.Length - 1)
set.Add(k[(idx + 1)..]);
}
}
return set;
}
private static TimeSpan GetInactivitySpan(int days) =>
days == 0 ? TimeSpan.FromHours(2) : TimeSpan.FromDays(days);
} }
} }

View File

@@ -24,7 +24,7 @@ namespace LightlessSyncServer.Worker
var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<LightlessHub>>(); var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<LightlessHub>>();
var groups = await db.Groups var groups = await db.Groups
.Where(g => g.AutoPruneEnabled && g.AutoPruneDays > 0) .Where(g => g.AutoPruneEnabled)
.ToListAsync(stoppingToken).ConfigureAwait(false); .ToListAsync(stoppingToken).ConfigureAwait(false);
foreach (var group in groups) foreach (var group in groups)

View File

@@ -20,13 +20,14 @@ public class ShardServerFilesController : ControllerBase
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> DownloadFileDirect(string hash, [FromQuery] long expires, [FromQuery] string signature) public async Task<IActionResult> DownloadFileDirect(string hash, [FromQuery] long expires, [FromQuery] string signature)
{ {
var result = await _cdnDownloadsService.GetDownloadAsync(hash, expires, signature).ConfigureAwait(false); var result = _cdnDownloadsService.GetDownloadWithCacheCheck(hash, expires, signature);
return result.Status switch return result.Status switch
{ {
CDNDownloadsService.ResultStatus.Disabled => NotFound(), CDNDownloadsService.ResultStatus.Disabled => NotFound(),
CDNDownloadsService.ResultStatus.Unauthorized => Unauthorized(), CDNDownloadsService.ResultStatus.Unauthorized => Unauthorized(),
CDNDownloadsService.ResultStatus.NotFound => NotFound(), CDNDownloadsService.ResultStatus.NotFound => NotFound(),
CDNDownloadsService.ResultStatus.Downloading => StatusCode(503),
CDNDownloadsService.ResultStatus.Success => PhysicalFile(result.File!.FullName, "application/octet-stream"), CDNDownloadsService.ResultStatus.Success => PhysicalFile(result.File!.FullName, "application/octet-stream"),
_ => NotFound() _ => NotFound()
}; };

View File

@@ -10,7 +10,8 @@ public class CDNDownloadsService
Disabled, Disabled,
Unauthorized, Unauthorized,
NotFound, NotFound,
Success Success,
Downloading
} }
public readonly record struct Result(ResultStatus Status, FileInfo? File); public readonly record struct Result(ResultStatus Status, FileInfo? File);
@@ -53,4 +54,32 @@ public class CDNDownloadsService
return new Result(ResultStatus.Success, fileInfo); return new Result(ResultStatus.Success, fileInfo);
} }
public Result GetDownloadWithCacheCheck(string hash, long expiresUnixSeconds, string signature)
{
if (!_cdnDownloadUrlService.DirectDownloadsEnabled)
{
return new Result(ResultStatus.Disabled, null);
}
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(hash))
{
return new Result(ResultStatus.Unauthorized, null);
}
hash = hash.ToUpperInvariant();
if (!_cdnDownloadUrlService.TryValidateSignature(hash, expiresUnixSeconds, signature))
{
return new Result(ResultStatus.Unauthorized, null);
}
var fileInfo = _cachedFileProvider.TryGetLocalFileInfo(hash);
if (fileInfo == null)
{
return new Result(ResultStatus.Downloading, null);
}
return new Result(ResultStatus.Success, fileInfo);
}
} }

View File

@@ -219,4 +219,27 @@ public sealed class CachedFileProvider : IDisposable
{ {
return hashes.Exists(_currentTransfers.Keys.Contains); return hashes.Exists(_currentTransfers.Keys.Contains);
} }
public FileInfo? TryGetLocalFileInfo(string hash)
{
var fi = FilePathUtil.GetFileInfoForHash(_hotStoragePath, hash);
if (fi != null)
{
return GetLocalFilePath(hash);
}
_ = Task.Run(async () =>
{
try
{
await DownloadFileWhenRequired(hash).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Download failed for {hash}", hash);
}
});
return null;
}
} }