diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs index cf64d96..efa8c09 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs @@ -72,7 +72,7 @@ public partial class LightlessHub var existingData = await GetPairInfo(UserUID, otherUser.UID).ConfigureAwait(false); var permissions = existingData?.OwnPermissions; - if (permissions == null || !permissions.Sticky) + if (permissions == null || !permissions.Sticky) { var ownDefaultPermissions = await DbContext.UserDefaultPreferredPermissions.AsNoTracking().SingleOrDefaultAsync(f => f.UserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); @@ -523,6 +523,52 @@ public partial class LightlessHub } } + private async Task<(BroadcastRedisEntry? Entry, TimeSpan? Expiry)> TryGetBroadcastEntryAsync(string hashedCid) + { + var key = _broadcastConfiguration.BuildRedisKey(hashedCid); + RedisValueWithExpiry value; + + try + { + value = await _redis.Database.StringGetWithExpiryAsync(key).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogCallWarning(LightlessHubLogger.Args("LightfinderProfileLookupFailed", "CID", hashedCid, "Error", ex)); + return (null, null); + } + + if (value.Value.IsNullOrEmpty || value.Expiry is null || value.Expiry <= TimeSpan.Zero) + { + return (null, value.Expiry); + } + + BroadcastRedisEntry? entry; + try + { + entry = JsonSerializer.Deserialize(value.Value!); + } + catch (Exception ex) + { + _logger.LogCallWarning(LightlessHubLogger.Args("LightfinderProfileDeserializeFailed", "CID", hashedCid, "Raw", value.Value.ToString(), "Error", ex)); + return (null, value.Expiry); + } + + if (entry is null || !string.Equals(entry.HashedCID, hashedCid, StringComparison.Ordinal)) + { + _logger.LogCallWarning(LightlessHubLogger.Args("LightfinderProfileEntryMismatch", "CID", hashedCid, "EntryCID", entry?.HashedCID ?? "null")); + return (null, value.Expiry); + } + + return (entry, value.Expiry); + } + + private static bool HasActiveBroadcast(BroadcastRedisEntry? entry, TimeSpan? expiry) => + entry?.HasOwner() == true && expiry.HasValue && expiry.Value > TimeSpan.Zero; + + private static bool IsActiveBroadcastForUser(BroadcastRedisEntry? entry, TimeSpan? expiry, string userUid) => + HasActiveBroadcast(entry, expiry) && entry!.OwnedBy(userUid); + private static bool IsValidHashedCid(string? cid) { if (string.IsNullOrWhiteSpace(cid)) @@ -792,6 +838,97 @@ public partial class LightlessHub return new UserProfileDto(user.User, false, data.IsNSFW, data.Base64ProfileImage, data.UserDescription); } + [Authorize(Policy = "Identified")] + public async Task UserGetLightfinderProfile(string hashedCid) + { + _logger.LogCallInfo(LightlessHubLogger.Args("LightfinderProfile", hashedCid)); + + if (!_broadcastConfiguration.EnableBroadcasting) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Lightfinder is currently disabled.").ConfigureAwait(false); + return null; + } + + if (!IsValidHashedCid(hashedCid)) + { + _logger.LogCallWarning(LightlessHubLogger.Args("LightfinderProfileInvalidCid", hashedCid)); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "Invalid Lightfinder target.").ConfigureAwait(false); + return null; + } + + var viewerCid = UserCharaIdent; + if (!IsValidHashedCid(viewerCid)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You must be using Lightfinder to open player profiles.").ConfigureAwait(false); + return null; + } + + var (viewerEntry, viewerExpiry) = await TryGetBroadcastEntryAsync(viewerCid).ConfigureAwait(false); + if (!IsActiveBroadcastForUser(viewerEntry, viewerExpiry, UserUID)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You must be using Lightfinder to open player profiles.").ConfigureAwait(false); + return null; + } + + var (targetEntry, targetExpiry) = await TryGetBroadcastEntryAsync(hashedCid).ConfigureAwait(false); + if (!HasActiveBroadcast(targetEntry, targetExpiry)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "That player is not currently using Lightfinder.").ConfigureAwait(false); + return null; + } + + if (string.IsNullOrEmpty(targetEntry!.OwnerUID)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "That player is not currently using Lightfinder.").ConfigureAwait(false); + return null; + } + + var targetUser = await DbContext.Users.AsNoTracking() + .SingleOrDefaultAsync(u => u.UID == targetEntry.OwnerUID, cancellationToken: RequestAbortedToken) + .ConfigureAwait(false); + + if (targetUser == null) + { + _logger.LogCallWarning(LightlessHubLogger.Args("LightfinderProfileMissingUser", hashedCid, "OwnerUID", targetEntry.OwnerUID)); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "Unable to load the players profile at this time.").ConfigureAwait(false); + return null; + } + + var displayAlias = string.IsNullOrWhiteSpace(targetUser.Alias) + ? "LightfinderUser" + : targetUser.Alias; + + var userData = new UserData( + UID: hashedCid, + Alias: displayAlias, + IsAdmin: false, + IsModerator: false, + HasVanity: false, + TextColorHex: targetUser.TextColorHex, + TextGlowColorHex: targetUser.TextGlowColorHex); + + var profile = await DbContext.UserProfileData.AsNoTracking() + .SingleOrDefaultAsync(u => u.UserUID == targetEntry.OwnerUID, cancellationToken: RequestAbortedToken) + .ConfigureAwait(false); + + if (profile == null) + { + return new UserProfileDto(userData, false, null, null, null); + } + + if (profile.FlaggedForReport) + { + return new UserProfileDto(userData, true, null, null, "This profile is flagged for report and pending evaluation"); + } + + if (profile.ProfileDisabled) + { + return new UserProfileDto(userData, true, null, null, "This profile was permanently disabled"); + } + + return new UserProfileDto(userData, false, profile.IsNSFW, profile.Base64ProfileImage, profile.UserDescription); + } + [Authorize(Policy = "Identified")] public async Task UserPushData(UserCharaDataMessageDto dto) { diff --git a/LightlessSyncServer/LightlessSyncServer/Services/SystemInfoService.cs b/LightlessSyncServer/LightlessSyncServer/Services/SystemInfoService.cs index 449510a..255fdb0 100644 --- a/LightlessSyncServer/LightlessSyncServer/Services/SystemInfoService.cs +++ b/LightlessSyncServer/LightlessSyncServer/Services/SystemInfoService.cs @@ -7,7 +7,9 @@ using LightlessSyncShared.Services; using LightlessSyncShared.Utils.Configuration; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; +using StackExchange.Redis; using StackExchange.Redis.Extensions.Core.Abstractions; +using static LightlessSyncServer.Hubs.LightlessHub; namespace LightlessSyncServer.Services; @@ -52,6 +54,13 @@ public sealed class SystemInfoService : BackgroundService _lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeAvailableIOWorkerThreads, ioThreads); var onlineUsers = (_redis.SearchKeysAsync("UID:*").GetAwaiter().GetResult()).Count(); + + var allLightfinderKeys = _redis.SearchKeysAsync("broadcast:*").GetAwaiter().GetResult().Where(c => !c.Contains("owner", StringComparison.Ordinal)).ToHashSet(StringComparer.Ordinal); + var allLightfinderItems = _redis.GetAllAsync(allLightfinderKeys).GetAwaiter().GetResult(); + + var countLightFinderUsers = allLightfinderItems.Count; + var countLightFinderSyncshells = allLightfinderItems.Count(static l => !string.IsNullOrEmpty(l.Value.GID)); + SystemInfoDto = new SystemInfoDto() { OnlineUsers = onlineUsers, @@ -66,10 +75,12 @@ public sealed class SystemInfoService : BackgroundService using var db = await _dbContextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); _lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeAuthorizedConnections, onlineUsers); + _lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeLightFinderConnections, countLightFinderUsers); _lightlessMetrics.SetGaugeTo(MetricsAPI.GaugePairs, db.ClientPairs.AsNoTracking().Count()); - _lightlessMetrics.SetGaugeTo(MetricsAPI.GaugePairsPaused, db.Permissions.AsNoTracking().Where(p => p.IsPaused).Count()); + _lightlessMetrics.SetGaugeTo(MetricsAPI.GaugePairsPaused, db.Permissions.AsNoTracking().Count(p => p.IsPaused)); _lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeGroups, db.Groups.AsNoTracking().Count()); _lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeGroupPairs, db.GroupPairs.AsNoTracking().Count()); + _lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeLightFinderGroups, countLightFinderSyncshells); _lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeUsersRegistered, db.Users.AsNoTracking().Count()); } diff --git a/LightlessSyncServer/LightlessSyncServer/Startup.cs b/LightlessSyncServer/LightlessSyncServer/Startup.cs index b647e85..3b9ba70 100644 --- a/LightlessSyncServer/LightlessSyncServer/Startup.cs +++ b/LightlessSyncServer/LightlessSyncServer/Startup.cs @@ -295,6 +295,8 @@ public class Startup }, new List { MetricsAPI.GaugeAuthorizedConnections, + MetricsAPI.GaugeLightFinderConnections, + MetricsAPI.GaugeLightFinderGroups, MetricsAPI.GaugeConnections, MetricsAPI.GaugePairs, MetricsAPI.GaugePairsPaused, diff --git a/LightlessSyncServer/LightlessSyncShared/Metrics/MetricsAPI.cs b/LightlessSyncServer/LightlessSyncShared/Metrics/MetricsAPI.cs index f3b09a6..fe97f72 100644 --- a/LightlessSyncServer/LightlessSyncShared/Metrics/MetricsAPI.cs +++ b/LightlessSyncServer/LightlessSyncShared/Metrics/MetricsAPI.cs @@ -9,6 +9,8 @@ public class MetricsAPI public const string GaugeAvailableIOWorkerThreads = "lightless_available_threadpool_io"; public const string GaugeUsersRegistered = "lightless_users_registered"; public const string CounterUsersRegisteredDeleted = "lightless_users_registered_deleted"; + public const string GaugeLightFinderConnections = "lightless_lightfinder_connections"; + public const string GaugeLightFinderGroups = "lightless_lightfinder_groups"; public const string GaugePairs = "lightless_pairs"; public const string GaugePairsPaused = "lightless_pairs_paused"; public const string GaugeFilesTotal = "lightless_files"; diff --git a/LightlessSyncServer/LightlessSyncStaticFilesServer/Controllers/ServerFilesController.cs b/LightlessSyncServer/LightlessSyncStaticFilesServer/Controllers/ServerFilesController.cs index 6ec924b..d0b5d95 100644 --- a/LightlessSyncServer/LightlessSyncStaticFilesServer/Controllers/ServerFilesController.cs +++ b/LightlessSyncServer/LightlessSyncStaticFilesServer/Controllers/ServerFilesController.cs @@ -34,12 +34,14 @@ public class ServerFilesController : ControllerBase private readonly LightlessMetrics _metricsClient; private readonly MainServerShardRegistrationService _shardRegistrationService; private readonly CDNDownloadUrlService _cdnDownloadUrlService; + private readonly CDNDownloadsService _cdnDownloadsService; public ServerFilesController(ILogger logger, CachedFileProvider cachedFileProvider, IConfigurationService configuration, IHubContext hubContext, IDbContextFactory lightlessDbContext, LightlessMetrics metricsClient, - MainServerShardRegistrationService shardRegistrationService, CDNDownloadUrlService cdnDownloadUrlService) : base(logger) + MainServerShardRegistrationService shardRegistrationService, CDNDownloadUrlService cdnDownloadUrlService, + CDNDownloadsService cdnDownloadsService) : base(logger) { _basePath = configuration.GetValueOrDefault(nameof(StaticFilesServerConfiguration.UseColdStorage), false) ? configuration.GetValue(nameof(StaticFilesServerConfiguration.ColdStorageDirectory)) @@ -51,6 +53,7 @@ public class ServerFilesController : ControllerBase _metricsClient = metricsClient; _shardRegistrationService = shardRegistrationService; _cdnDownloadUrlService = cdnDownloadUrlService; + _cdnDownloadsService = cdnDownloadsService; } [HttpPost(LightlessFiles.ServerFiles_DeleteAll)] @@ -145,24 +148,16 @@ public class ServerFilesController : ControllerBase [AllowAnonymous] public async Task DownloadFileDirect(string hash, [FromQuery] long expires, [FromQuery] string signature) { - if (!_cdnDownloadUrlService.DirectDownloadsEnabled) - { - return NotFound(); - } + var result = await _cdnDownloadsService.GetDownloadAsync(hash, expires, signature).ConfigureAwait(false); - hash = hash.ToUpperInvariant(); - if (!_cdnDownloadUrlService.TryValidateSignature(hash, expires, signature)) + return result.Status switch { - return Unauthorized(); - } - - var fileInfo = await _cachedFileProvider.DownloadAndGetLocalFileInfo(hash).ConfigureAwait(false); - if (fileInfo == null) - { - return NotFound(); - } - - return PhysicalFile(fileInfo.FullName, "application/octet-stream"); + CDNDownloadsService.ResultStatus.Disabled => NotFound(), + CDNDownloadsService.ResultStatus.Unauthorized => Unauthorized(), + CDNDownloadsService.ResultStatus.NotFound => NotFound(), + CDNDownloadsService.ResultStatus.Success => PhysicalFile(result.File!.FullName, "application/octet-stream"), + _ => NotFound() + }; } [HttpPost(LightlessFiles.ServerFiles_FilesSend)] diff --git a/LightlessSyncServer/LightlessSyncStaticFilesServer/Controllers/ShardServerFilesController.cs b/LightlessSyncServer/LightlessSyncStaticFilesServer/Controllers/ShardServerFilesController.cs new file mode 100644 index 0000000..819597e --- /dev/null +++ b/LightlessSyncServer/LightlessSyncStaticFilesServer/Controllers/ShardServerFilesController.cs @@ -0,0 +1,34 @@ +using LightlessSync.API.Routes; +using LightlessSyncStaticFilesServer.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LightlessSyncStaticFilesServer.Controllers; + +[Route(LightlessFiles.ServerFiles)] +public class ShardServerFilesController : ControllerBase +{ + private readonly CDNDownloadsService _cdnDownloadsService; + + public ShardServerFilesController(ILogger logger, + CDNDownloadsService cdnDownloadsService) : base(logger) + { + _cdnDownloadsService = cdnDownloadsService; + } + + [HttpGet(LightlessFiles.ServerFiles_DirectDownload + "/{hash}")] + [AllowAnonymous] + public async Task DownloadFileDirect(string hash, [FromQuery] long expires, [FromQuery] string signature) + { + var result = await _cdnDownloadsService.GetDownloadAsync(hash, expires, signature).ConfigureAwait(false); + + return result.Status switch + { + CDNDownloadsService.ResultStatus.Disabled => NotFound(), + CDNDownloadsService.ResultStatus.Unauthorized => Unauthorized(), + CDNDownloadsService.ResultStatus.NotFound => NotFound(), + CDNDownloadsService.ResultStatus.Success => PhysicalFile(result.File!.FullName, "application/octet-stream"), + _ => NotFound() + }; + } +} diff --git a/LightlessSyncServer/LightlessSyncStaticFilesServer/Services/CDNDownloadsService.cs b/LightlessSyncServer/LightlessSyncStaticFilesServer/Services/CDNDownloadsService.cs new file mode 100644 index 0000000..3cbd661 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncStaticFilesServer/Services/CDNDownloadsService.cs @@ -0,0 +1,56 @@ +using System.IO; +using System.Threading.Tasks; + +namespace LightlessSyncStaticFilesServer.Services; + +public class CDNDownloadsService +{ + public enum ResultStatus + { + Disabled, + Unauthorized, + NotFound, + Success + } + + public readonly record struct Result(ResultStatus Status, FileInfo? File); + + private readonly CDNDownloadUrlService _cdnDownloadUrlService; + private readonly CachedFileProvider _cachedFileProvider; + + public CDNDownloadsService(CDNDownloadUrlService cdnDownloadUrlService, CachedFileProvider cachedFileProvider) + { + _cdnDownloadUrlService = cdnDownloadUrlService; + _cachedFileProvider = cachedFileProvider; + } + + public bool DownloadsEnabled => _cdnDownloadUrlService.DirectDownloadsEnabled; + + public async Task GetDownloadAsync(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 = await _cachedFileProvider.DownloadAndGetLocalFileInfo(hash).ConfigureAwait(false); + if (fileInfo == null) + { + return new Result(ResultStatus.NotFound, null); + } + + return new Result(ResultStatus.Success, fileInfo); + } +} diff --git a/LightlessSyncServer/LightlessSyncStaticFilesServer/Startup.cs b/LightlessSyncServer/LightlessSyncStaticFilesServer/Startup.cs index f4034a5..a3f2469 100644 --- a/LightlessSyncServer/LightlessSyncStaticFilesServer/Startup.cs +++ b/LightlessSyncServer/LightlessSyncStaticFilesServer/Startup.cs @@ -88,6 +88,7 @@ public class Startup services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddHostedService(p => p.GetService()); services.AddHostedService(m => m.GetService()); services.AddSingleton, LightlessConfigurationServiceClient>(); @@ -205,7 +206,8 @@ public class Startup } else if (_isDistributionNode) { - a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(CacheController), typeof(RequestController), typeof(DistributionController), typeof(SpeedTestController))); + a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(CacheController), typeof(RequestController), + typeof(DistributionController), typeof(ShardServerFilesController), typeof(SpeedTestController))); } else {