Compare commits
19 Commits
b2352514ee
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
b231d80101
|
|||
|
f5d2a95b97
|
|||
|
97c009aef6
|
|||
|
8527e67af1
|
|||
|
073464e6c8
|
|||
|
b2b9ecad7b
|
|||
|
e484616ecd
|
|||
|
331cb612ad
|
|||
|
f696c1d400
|
|||
|
cd5248e4e6
|
|||
|
79ff8fad7a
|
|||
|
f9df60dc88
|
|||
|
74dac9f506
|
|||
|
54e66b2387
|
|||
|
6852869313
|
|||
|
693d3c6af7
|
|||
|
8d0c3aafb3
|
|||
|
5366cfbc60
|
|||
|
c23a43c84c
|
2
.gitmodules
vendored
2
.gitmodules
vendored
@@ -1,4 +1,4 @@
|
||||
[submodule "MareAPI"]
|
||||
path = MareAPI
|
||||
url = https://git.umbra-sync.net/SirConstance/UmbraAPI.git
|
||||
url = git@git.umbra-sync.net:Keda/UmbraAPI.git
|
||||
branch = main
|
||||
|
||||
2
MareAPI
2
MareAPI
Submodule MareAPI updated: fb71135026...d105d20507
@@ -0,0 +1,174 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MareSynchronosAuthService.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MareSynchronosAuthService.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("discovery")]
|
||||
public class DiscoveryController : Controller
|
||||
{
|
||||
private readonly DiscoveryWellKnownProvider _provider;
|
||||
private readonly DiscoveryPresenceService _presence;
|
||||
|
||||
public DiscoveryController(DiscoveryWellKnownProvider provider, DiscoveryPresenceService presence)
|
||||
{
|
||||
_provider = provider;
|
||||
_presence = presence;
|
||||
}
|
||||
|
||||
public sealed class QueryRequest
|
||||
{
|
||||
[JsonPropertyName("hashes")] public string[] Hashes { get; set; } = Array.Empty<string>();
|
||||
[JsonPropertyName("salt")] public string SaltB64 { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class QueryResponseEntry
|
||||
{
|
||||
[JsonPropertyName("hash")] public string Hash { get; set; } = string.Empty;
|
||||
[JsonPropertyName("token")] public string? Token { get; set; }
|
||||
[JsonPropertyName("uid")] public string Uid { get; set; } = string.Empty;
|
||||
[JsonPropertyName("displayName")] public string? DisplayName { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("query")]
|
||||
public IActionResult Query([FromBody] QueryRequest req)
|
||||
{
|
||||
if (_provider.IsExpired(req.SaltB64))
|
||||
{
|
||||
return BadRequest(new { code = "DISCOVERY_SALT_EXPIRED" });
|
||||
}
|
||||
|
||||
var uid = User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Uid)?.Value ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(uid) || req?.Hashes == null || req.Hashes.Length == 0)
|
||||
return Json(Array.Empty<QueryResponseEntry>());
|
||||
|
||||
List<QueryResponseEntry> matches = new();
|
||||
foreach (var h in req.Hashes.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
var (found, token, targetUid, displayName) = _presence.TryMatchAndIssueToken(uid, h);
|
||||
if (found)
|
||||
{
|
||||
matches.Add(new QueryResponseEntry { Hash = h, Token = token, Uid = targetUid, DisplayName = displayName });
|
||||
}
|
||||
}
|
||||
|
||||
return Json(matches);
|
||||
}
|
||||
|
||||
public sealed class RequestDto
|
||||
{
|
||||
[JsonPropertyName("token")] public string Token { get; set; } = string.Empty;
|
||||
[JsonPropertyName("displayName")] public string? DisplayName { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("request")]
|
||||
public async Task<IActionResult> RequestPair([FromBody] RequestDto req)
|
||||
{
|
||||
if (string.IsNullOrEmpty(req.Token)) return BadRequest();
|
||||
if (_presence.ValidateToken(req.Token, out var targetUid))
|
||||
{
|
||||
// Phase 3 (minimal): notify target via mare-server internal controller
|
||||
try
|
||||
{
|
||||
var fromUid = User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Uid)?.Value ?? string.Empty;
|
||||
var fromAlias = string.IsNullOrEmpty(req.DisplayName)
|
||||
? (User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Alias)?.Value ?? string.Empty)
|
||||
: req.DisplayName;
|
||||
|
||||
using var http = new HttpClient();
|
||||
// Use same host as public (goes through nginx)
|
||||
var baseUrl = $"{Request.Scheme}://{Request.Host.Value}";
|
||||
var url = new Uri(new Uri(baseUrl), "/main/discovery/notifyRequest");
|
||||
|
||||
// Generate internal JWT
|
||||
var serverToken = HttpContext.RequestServices.GetRequiredService<MareSynchronosShared.Utils.ServerTokenGenerator>().Token;
|
||||
http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", serverToken);
|
||||
var payload = System.Text.Json.JsonSerializer.Serialize(new { targetUid, fromUid, fromAlias });
|
||||
var resp = await http.PostAsync(url, new StringContent(payload, System.Text.Encoding.UTF8, "application/json"));
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var txt = await resp.Content.ReadAsStringAsync();
|
||||
HttpContext.RequestServices.GetRequiredService<ILogger<DiscoveryController>>()
|
||||
.LogWarning("notifyRequest failed: {code} {reason} {body}", (int)resp.StatusCode, resp.ReasonPhrase, txt);
|
||||
}
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
|
||||
return Accepted();
|
||||
}
|
||||
return BadRequest(new { code = "INVALID_TOKEN" });
|
||||
}
|
||||
|
||||
public sealed class AcceptNotifyDto
|
||||
{
|
||||
[JsonPropertyName("targetUid")] public string TargetUid { get; set; } = string.Empty;
|
||||
[JsonPropertyName("displayName")] public string? DisplayName { get; set; }
|
||||
}
|
||||
|
||||
// Accept notification relay (sender -> auth -> main)
|
||||
[HttpPost("acceptNotify")]
|
||||
public async Task<IActionResult> AcceptNotify([FromBody] AcceptNotifyDto req)
|
||||
{
|
||||
if (string.IsNullOrEmpty(req.TargetUid)) return BadRequest();
|
||||
try
|
||||
{
|
||||
var fromUid = User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Uid)?.Value ?? string.Empty;
|
||||
var fromAlias = string.IsNullOrEmpty(req.DisplayName)
|
||||
? (User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Alias)?.Value ?? string.Empty)
|
||||
: req.DisplayName;
|
||||
|
||||
using var http = new HttpClient();
|
||||
var baseUrl = $"{Request.Scheme}://{Request.Host.Value}";
|
||||
var url = new Uri(new Uri(baseUrl), "/main/discovery/notifyAccept");
|
||||
var serverToken = HttpContext.RequestServices.GetRequiredService<MareSynchronosShared.Utils.ServerTokenGenerator>().Token;
|
||||
http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", serverToken);
|
||||
var payload = System.Text.Json.JsonSerializer.Serialize(new { targetUid = req.TargetUid, fromUid, fromAlias });
|
||||
var resp = await http.PostAsync(url, new StringContent(payload, System.Text.Encoding.UTF8, "application/json"));
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var txt = await resp.Content.ReadAsStringAsync();
|
||||
HttpContext.RequestServices.GetRequiredService<ILogger<DiscoveryController>>()
|
||||
.LogWarning("notifyAccept failed: {code} {reason} {body}", (int)resp.StatusCode, resp.ReasonPhrase, txt);
|
||||
}
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
public sealed class PublishRequest
|
||||
{
|
||||
[JsonPropertyName("hashes")] public string[] Hashes { get; set; } = Array.Empty<string>();
|
||||
[JsonPropertyName("displayName")] public string? DisplayName { get; set; }
|
||||
[JsonPropertyName("salt")] public string SaltB64 { get; set; } = string.Empty;
|
||||
[JsonPropertyName("allowRequests")] public bool AllowRequests { get; set; } = true;
|
||||
}
|
||||
|
||||
[HttpPost("disable")]
|
||||
public IActionResult Disable()
|
||||
{
|
||||
var uid = User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Uid)?.Value ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(uid)) return Accepted();
|
||||
_presence.Unpublish(uid);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
[HttpPost("publish")]
|
||||
public IActionResult Publish([FromBody] PublishRequest req)
|
||||
{
|
||||
if (_provider.IsExpired(req.SaltB64))
|
||||
{
|
||||
return BadRequest(new { code = "DISCOVERY_SALT_EXPIRED" });
|
||||
}
|
||||
var uid = User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Uid)?.Value ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(uid) || req?.Hashes == null || req.Hashes.Length == 0)
|
||||
return Accepted();
|
||||
|
||||
_presence.Publish(uid, req.Hashes, req.DisplayName, req.AllowRequests);
|
||||
return Accepted();
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,8 @@ public class JwtController : Controller
|
||||
}
|
||||
|
||||
var existingIdent = await _redis.GetAsync<string>("UID:" + authResult.Uid);
|
||||
if (!string.IsNullOrEmpty(existingIdent)) return Unauthorized("Already logged in to this account. Reconnect in 60 seconds. If you keep seeing this issue, restart your game.");
|
||||
if (!string.IsNullOrEmpty(existingIdent) && !string.Equals(existingIdent, charaIdent, StringComparison.Ordinal))
|
||||
return Unauthorized("Already logged in to this account. Reconnect in 60 seconds. If you keep seeing this issue, restart your game.");
|
||||
|
||||
var token = CreateToken(new List<Claim>()
|
||||
{
|
||||
@@ -134,10 +135,13 @@ public class JwtController : Controller
|
||||
var tokenContent = tokenResponse as ContentResult;
|
||||
if (tokenContent == null)
|
||||
return tokenResponse;
|
||||
var provider = HttpContext.RequestServices.GetService<DiscoveryWellKnownProvider>();
|
||||
var wk = provider?.GetWellKnownJson(Request.Scheme, Request.Host.Value)
|
||||
?? _configuration.GetValueOrDefault(nameof(AuthServiceConfiguration.WellKnown), string.Empty);
|
||||
return Json(new AuthReplyDto
|
||||
{
|
||||
Token = tokenContent.Content,
|
||||
WellKnown = _configuration.GetValueOrDefault(nameof(AuthServiceConfiguration.WellKnown), string.Empty),
|
||||
WellKnown = wk,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -190,4 +194,3 @@ public class JwtController : Controller
|
||||
return handler.CreateJwtSecurityToken(token);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using MareSynchronosAuthService.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MareSynchronosAuthService.Controllers;
|
||||
|
||||
[AllowAnonymous]
|
||||
[ApiController]
|
||||
public class WellKnownController : Controller
|
||||
{
|
||||
private readonly DiscoveryWellKnownProvider _provider;
|
||||
|
||||
public WellKnownController(DiscoveryWellKnownProvider provider)
|
||||
{
|
||||
_provider = provider;
|
||||
}
|
||||
|
||||
[HttpGet("/.well-known/Umbra/client")]
|
||||
public IActionResult Get()
|
||||
{
|
||||
var json = _provider.GetWellKnownJson(Request.Scheme, Request.Host.Value);
|
||||
return Content(json, "application/json");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,30 +4,16 @@
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<EnableDefaultContentItems>false</EnableDefaultContentItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Remove="appsettings.Development.json" />
|
||||
<Content Remove="appsettings.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="appsettings.Development.json" />
|
||||
<None Include="appsettings.json">
|
||||
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Remove="appsettings.Development.json" />
|
||||
<Content Remove="appsettings.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="appsettings.Development.json" />
|
||||
<None Include="appsettings.json">
|
||||
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
|
||||
</None>
|
||||
<Content Include="appsettings.Development.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="appsettings.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -41,6 +27,9 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="8.0.1" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.9.11" />
|
||||
<PackageReference Include="StackExchange.Redis.Extensions.Core" Version="10.2.0" />
|
||||
<PackageReference Include="StackExchange.Redis.Extensions.System.Text.Json" Version="10.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace MareSynchronosAuthService.Services.Discovery;
|
||||
|
||||
public interface IDiscoveryPresenceStore : IDisposable
|
||||
{
|
||||
void Publish(string uid, IEnumerable<string> hashes, string? displayName = null, bool allowRequests = true);
|
||||
void Unpublish(string uid);
|
||||
(bool Found, string? Token, string TargetUid, string? DisplayName) TryMatchAndIssueToken(string requesterUid, string hash);
|
||||
bool ValidateToken(string token, out string targetUid);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace MareSynchronosAuthService.Services.Discovery;
|
||||
|
||||
public sealed class InMemoryPresenceStore : IDiscoveryPresenceStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, (string Uid, DateTimeOffset ExpiresAt, string? DisplayName, bool AllowRequests)> _presence = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, (string TargetUid, DateTimeOffset ExpiresAt)> _tokens = new(StringComparer.Ordinal);
|
||||
private readonly TimeSpan _presenceTtl;
|
||||
private readonly TimeSpan _tokenTtl;
|
||||
private readonly Timer _cleanupTimer;
|
||||
|
||||
public InMemoryPresenceStore(TimeSpan presenceTtl, TimeSpan tokenTtl)
|
||||
{
|
||||
_presenceTtl = presenceTtl;
|
||||
_tokenTtl = tokenTtl;
|
||||
_cleanupTimer = new Timer(_ => Cleanup(), null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cleanupTimer.Dispose();
|
||||
}
|
||||
|
||||
private void Cleanup()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var kv in _presence.ToArray())
|
||||
{
|
||||
if (kv.Value.ExpiresAt <= now) _presence.TryRemove(kv.Key, out _);
|
||||
}
|
||||
foreach (var kv in _tokens.ToArray())
|
||||
{
|
||||
if (kv.Value.ExpiresAt <= now) _tokens.TryRemove(kv.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void Publish(string uid, IEnumerable<string> hashes, string? displayName = null, bool allowRequests = true)
|
||||
{
|
||||
var exp = DateTimeOffset.UtcNow.Add(_presenceTtl);
|
||||
foreach (var h in hashes.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
_presence[h] = (uid, exp, displayName, allowRequests);
|
||||
}
|
||||
}
|
||||
|
||||
public void Unpublish(string uid)
|
||||
{
|
||||
// Remove all presence hashes owned by this uid
|
||||
foreach (var kv in _presence.ToArray())
|
||||
{
|
||||
if (string.Equals(kv.Value.Uid, uid, StringComparison.Ordinal))
|
||||
{
|
||||
_presence.TryRemove(kv.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public (bool Found, string? Token, string TargetUid, string? DisplayName) TryMatchAndIssueToken(string requesterUid, string hash)
|
||||
{
|
||||
if (_presence.TryGetValue(hash, out var entry))
|
||||
{
|
||||
// Refresh TTL for this presence whenever it is matched (regardless of AllowRequests)
|
||||
var refreshed = (entry.Uid, DateTimeOffset.UtcNow.Add(_presenceTtl), entry.DisplayName, entry.AllowRequests);
|
||||
_presence[hash] = refreshed;
|
||||
|
||||
if (string.Equals(entry.Uid, requesterUid, StringComparison.Ordinal))
|
||||
return (false, null, string.Empty, null);
|
||||
|
||||
// Visible but requests disabled → no token
|
||||
if (!entry.AllowRequests)
|
||||
return (true, null, entry.Uid, entry.DisplayName);
|
||||
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
_tokens[token] = (entry.Uid, DateTimeOffset.UtcNow.Add(_tokenTtl));
|
||||
return (true, token, entry.Uid, entry.DisplayName);
|
||||
}
|
||||
return (false, null, string.Empty, null);
|
||||
}
|
||||
|
||||
public bool ValidateToken(string token, out string targetUid)
|
||||
{
|
||||
targetUid = string.Empty;
|
||||
if (_tokens.TryGetValue(token, out var info))
|
||||
{
|
||||
if (info.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
targetUid = info.TargetUid;
|
||||
|
||||
// Optional robustness: refresh TTL for all presence entries of this target
|
||||
var newExp = DateTimeOffset.UtcNow.Add(_presenceTtl);
|
||||
foreach (var kv in _presence.ToArray())
|
||||
{
|
||||
if (string.Equals(kv.Value.Uid, targetUid, StringComparison.Ordinal))
|
||||
{
|
||||
var v = kv.Value;
|
||||
_presence[kv.Key] = (v.Uid, newExp, v.DisplayName, v.AllowRequests);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
_tokens.TryRemove(token, out _);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace MareSynchronosAuthService.Services.Discovery;
|
||||
|
||||
public sealed class RedisPresenceStore : IDiscoveryPresenceStore
|
||||
{
|
||||
private readonly ILogger<RedisPresenceStore> _logger;
|
||||
private readonly IDatabase _db;
|
||||
private readonly TimeSpan _presenceTtl;
|
||||
private readonly TimeSpan _tokenTtl;
|
||||
private readonly JsonSerializerOptions _jsonOpts = new() { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull };
|
||||
|
||||
public RedisPresenceStore(ILogger<RedisPresenceStore> logger, IConnectionMultiplexer mux, TimeSpan presenceTtl, TimeSpan tokenTtl)
|
||||
{
|
||||
_logger = logger;
|
||||
_db = mux.GetDatabase();
|
||||
_presenceTtl = presenceTtl;
|
||||
_tokenTtl = tokenTtl;
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
|
||||
private static string KeyForHash(string hash) => $"nd:hash:{hash}";
|
||||
private static string KeyForToken(string token) => $"nd:token:{token}";
|
||||
private static string KeyForUidSet(string uid) => $"nd:uid:{uid}";
|
||||
|
||||
public void Publish(string uid, IEnumerable<string> hashes, string? displayName = null, bool allowRequests = true)
|
||||
{
|
||||
var entries = hashes.Distinct(StringComparer.Ordinal).ToArray();
|
||||
if (entries.Length == 0) return;
|
||||
var batch = _db.CreateBatch();
|
||||
foreach (var h in entries)
|
||||
{
|
||||
var key = KeyForHash(h);
|
||||
var payload = JsonSerializer.Serialize(new Presence(uid, displayName, allowRequests), _jsonOpts);
|
||||
batch.StringSetAsync(key, payload, _presenceTtl);
|
||||
// Index this hash under the publisher uid for fast unpublish
|
||||
batch.SetAddAsync(KeyForUidSet(uid), h);
|
||||
batch.KeyExpireAsync(KeyForUidSet(uid), _presenceTtl);
|
||||
}
|
||||
batch.Execute();
|
||||
_logger.LogDebug("RedisPresenceStore: published {count} hashes", entries.Length);
|
||||
}
|
||||
|
||||
public void Unpublish(string uid)
|
||||
{
|
||||
try
|
||||
{
|
||||
var setKey = KeyForUidSet(uid);
|
||||
var members = _db.SetMembers(setKey);
|
||||
if (members is { Length: > 0 })
|
||||
{
|
||||
var batch = _db.CreateBatch();
|
||||
foreach (var m in members)
|
||||
{
|
||||
var hash = (string)m;
|
||||
var key = KeyForHash(hash);
|
||||
// Defensive: only delete if the hash is still owned by this uid
|
||||
var val = _db.StringGet(key);
|
||||
if (val.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = JsonSerializer.Deserialize<Presence>(val!);
|
||||
if (p != null && string.Equals(p.Uid, uid, StringComparison.Ordinal))
|
||||
{
|
||||
batch.KeyDeleteAsync(key);
|
||||
}
|
||||
}
|
||||
catch { /* ignore corrupted */ }
|
||||
}
|
||||
}
|
||||
// Remove the uid index set itself
|
||||
batch.KeyDeleteAsync(setKey);
|
||||
batch.Execute();
|
||||
}
|
||||
else
|
||||
{
|
||||
// No index set: best-effort, just delete the set key in case it exists
|
||||
_db.KeyDelete(setKey);
|
||||
}
|
||||
_logger.LogDebug("RedisPresenceStore: unpublished all hashes for uid {uid}", uid);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "RedisPresenceStore: Unpublish failed for uid {uid}", uid);
|
||||
}
|
||||
}
|
||||
|
||||
public (bool Found, string? Token, string TargetUid, string? DisplayName) TryMatchAndIssueToken(string requesterUid, string hash)
|
||||
{
|
||||
var key = KeyForHash(hash);
|
||||
var val = _db.StringGet(key);
|
||||
if (!val.HasValue) return (false, null, string.Empty, null);
|
||||
try
|
||||
{
|
||||
var p = JsonSerializer.Deserialize<Presence>(val!);
|
||||
if (p == null || string.IsNullOrEmpty(p.Uid)) return (false, null, string.Empty, null);
|
||||
if (string.Equals(p.Uid, requesterUid, StringComparison.Ordinal)) return (false, null, string.Empty, null);
|
||||
|
||||
// Refresh TTLs for this presence whenever it is matched
|
||||
_db.KeyExpire(KeyForHash(hash), _presenceTtl);
|
||||
_db.KeyExpire(KeyForUidSet(p.Uid), _presenceTtl);
|
||||
|
||||
// Visible but requests disabled → return without token
|
||||
if (!p.AllowRequests)
|
||||
{
|
||||
return (true, null, p.Uid, p.DisplayName);
|
||||
}
|
||||
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
_db.StringSet(KeyForToken(token), p.Uid, _tokenTtl);
|
||||
return (true, token, p.Uid, p.DisplayName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (false, null, string.Empty, null);
|
||||
}
|
||||
}
|
||||
|
||||
public bool ValidateToken(string token, out string targetUid)
|
||||
{
|
||||
targetUid = string.Empty;
|
||||
var key = KeyForToken(token);
|
||||
var val = _db.StringGet(key);
|
||||
if (!val.HasValue) return false;
|
||||
targetUid = val!;
|
||||
try
|
||||
{
|
||||
var setKey = KeyForUidSet(targetUid);
|
||||
var members = _db.SetMembers(setKey);
|
||||
if (members is { Length: > 0 })
|
||||
{
|
||||
var batch = _db.CreateBatch();
|
||||
foreach (var m in members)
|
||||
{
|
||||
var h = (string)m;
|
||||
batch.KeyExpireAsync(KeyForHash(h), _presenceTtl);
|
||||
}
|
||||
batch.KeyExpireAsync(setKey, _presenceTtl);
|
||||
batch.Execute();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Still try to extend the set TTL even if empty
|
||||
_db.KeyExpire(setKey, _presenceTtl);
|
||||
}
|
||||
}
|
||||
catch { /* ignore TTL refresh issues */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed record Presence(string Uid, string? DisplayName, bool AllowRequests);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MareSynchronosAuthService.Services.Discovery;
|
||||
|
||||
namespace MareSynchronosAuthService.Services;
|
||||
|
||||
public class DiscoveryPresenceService : IHostedService, IDisposable
|
||||
{
|
||||
private readonly ILogger<DiscoveryPresenceService> _logger;
|
||||
private readonly IDiscoveryPresenceStore _store;
|
||||
private readonly TimeSpan _presenceTtl = TimeSpan.FromMinutes(5);
|
||||
private readonly TimeSpan _tokenTtl = TimeSpan.FromMinutes(2);
|
||||
|
||||
public DiscoveryPresenceService(ILogger<DiscoveryPresenceService> logger, IDiscoveryPresenceStore store)
|
||||
{
|
||||
_logger = logger;
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Publish(string uid, IEnumerable<string> hashes, string? displayName = null, bool allowRequests = true)
|
||||
{
|
||||
_store.Publish(uid, hashes, displayName, allowRequests);
|
||||
_logger.LogDebug("Discovery presence published for {uid} with {n} hashes", uid, hashes.Count());
|
||||
}
|
||||
|
||||
public void Unpublish(string uid)
|
||||
{
|
||||
_store.Unpublish(uid);
|
||||
_logger.LogDebug("Discovery presence unpublished for {uid}", uid);
|
||||
}
|
||||
|
||||
public (bool Found, string? Token, string TargetUid, string? DisplayName) TryMatchAndIssueToken(string requesterUid, string hash)
|
||||
{
|
||||
var res = _store.TryMatchAndIssueToken(requesterUid, hash);
|
||||
return (res.Found, res.Token, res.TargetUid, res.DisplayName);
|
||||
}
|
||||
|
||||
public bool ValidateToken(string token, out string targetUid)
|
||||
{
|
||||
return _store.ValidateToken(token, out targetUid);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
(_store as IDisposable)?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MareSynchronosAuthService.Services;
|
||||
|
||||
public class DiscoveryWellKnownProvider : IHostedService
|
||||
{
|
||||
private readonly ILogger<DiscoveryWellKnownProvider> _logger;
|
||||
private readonly object _lock = new();
|
||||
private byte[] _currentSalt = Array.Empty<byte>();
|
||||
private DateTimeOffset _currentSaltExpiresAt;
|
||||
private byte[] _previousSalt = Array.Empty<byte>();
|
||||
private DateTimeOffset _previousSaltExpiresAt;
|
||||
private readonly TimeSpan _gracePeriod = TimeSpan.FromMinutes(5);
|
||||
private Timer? _rotationTimer;
|
||||
private readonly TimeSpan _saltTtl = TimeSpan.FromDays(30 * 6);
|
||||
private readonly int _refreshSec = 86400; // 24h
|
||||
|
||||
public DiscoveryWellKnownProvider(ILogger<DiscoveryWellKnownProvider> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RotateSalt();
|
||||
var period = _saltTtl;
|
||||
if (period.TotalMilliseconds > uint.MaxValue - 1)
|
||||
{
|
||||
_logger.LogInformation("DiscoveryWellKnownProvider: salt TTL {ttl} exceeds timer limit, skipping rotation timer in beta", period);
|
||||
_rotationTimer = new Timer(_ => { }, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
else
|
||||
{
|
||||
_rotationTimer = new Timer(_ => RotateSalt(), null, period, period);
|
||||
}
|
||||
_logger.LogInformation("DiscoveryWellKnownProvider started. Salt expires at {exp}", _currentSaltExpiresAt);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_rotationTimer?.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void RotateSalt()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentSalt.Length > 0)
|
||||
{
|
||||
_previousSalt = _currentSalt;
|
||||
_previousSaltExpiresAt = DateTimeOffset.UtcNow.Add(_gracePeriod);
|
||||
}
|
||||
_currentSalt = RandomNumberGenerator.GetBytes(32);
|
||||
_currentSaltExpiresAt = DateTimeOffset.UtcNow.Add(_saltTtl);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsExpired(string providedSaltB64)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var provided = Convert.FromBase64String(providedSaltB64);
|
||||
|
||||
if (_currentSalt.SequenceEqual(provided) && now <= _currentSaltExpiresAt)
|
||||
return false;
|
||||
|
||||
if (_previousSalt.Length > 0 && _previousSalt.SequenceEqual(provided) && now <= _previousSaltExpiresAt)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public string GetWellKnownJson(string scheme, string host)
|
||||
{
|
||||
var isHttps = string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase);
|
||||
var wsScheme = isHttps ? "wss" : "ws";
|
||||
var httpScheme = isHttps ? "https" : "http";
|
||||
|
||||
byte[] salt;
|
||||
DateTimeOffset exp;
|
||||
lock (_lock)
|
||||
{
|
||||
salt = _currentSalt.ToArray();
|
||||
exp = _currentSaltExpiresAt;
|
||||
}
|
||||
|
||||
var root = new WellKnownRoot
|
||||
{
|
||||
ApiUrl = $"{wsScheme}://{host}",
|
||||
HubUrl = $"{wsScheme}://{host}/mare",
|
||||
Features = new() { NearbyDiscovery = true },
|
||||
NearbyDiscovery = new()
|
||||
{
|
||||
Enabled = true,
|
||||
HashAlgo = "sha256",
|
||||
SaltB64 = Convert.ToBase64String(salt),
|
||||
SaltExpiresAt = exp,
|
||||
RefreshSec = _refreshSec,
|
||||
GraceSec = (int)_gracePeriod.TotalSeconds,
|
||||
Endpoints = new()
|
||||
{
|
||||
Publish = $"{httpScheme}://{host}/discovery/publish",
|
||||
Query = $"{httpScheme}://{host}/discovery/query",
|
||||
Request = $"{httpScheme}://{host}/discovery/request",
|
||||
Accept = $"{httpScheme}://{host}/discovery/acceptNotify"
|
||||
},
|
||||
Policies = new()
|
||||
{
|
||||
MaxQueryBatch = 100,
|
||||
MinQueryIntervalMs = 2000,
|
||||
RateLimitPerMin = 30,
|
||||
TokenTtlSec = 120
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(root, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull });
|
||||
}
|
||||
|
||||
private sealed class WellKnownRoot
|
||||
{
|
||||
[JsonPropertyName("api_url")] public string ApiUrl { get; set; } = string.Empty;
|
||||
[JsonPropertyName("hub_url")] public string HubUrl { get; set; } = string.Empty;
|
||||
[JsonPropertyName("skip_negotiation")] public bool SkipNegotiation { get; set; } = true;
|
||||
[JsonPropertyName("transports")] public string[] Transports { get; set; } = new[] { "websockets" };
|
||||
[JsonPropertyName("features")] public Features Features { get; set; } = new();
|
||||
[JsonPropertyName("nearby_discovery")] public Nearby NearbyDiscovery { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class Features
|
||||
{
|
||||
[JsonPropertyName("nearby_discovery")] public bool NearbyDiscovery { get; set; }
|
||||
}
|
||||
|
||||
private sealed class Nearby
|
||||
{
|
||||
[JsonPropertyName("enabled")] public bool Enabled { get; set; }
|
||||
[JsonPropertyName("hash_algo")] public string HashAlgo { get; set; } = "sha256";
|
||||
[JsonPropertyName("salt_b64")] public string SaltB64 { get; set; } = string.Empty;
|
||||
[JsonPropertyName("salt_expires_at")] public DateTimeOffset SaltExpiresAt { get; set; }
|
||||
[JsonPropertyName("refresh_sec")] public int RefreshSec { get; set; }
|
||||
[JsonPropertyName("grace_sec")] public int GraceSec { get; set; }
|
||||
[JsonPropertyName("endpoints")] public Endpoints Endpoints { get; set; } = new();
|
||||
[JsonPropertyName("policies")] public Policies Policies { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class Endpoints
|
||||
{
|
||||
[JsonPropertyName("publish")] public string? Publish { get; set; }
|
||||
[JsonPropertyName("query")] public string? Query { get; set; }
|
||||
[JsonPropertyName("request")] public string? Request { get; set; }
|
||||
[JsonPropertyName("accept")] public string? Accept { get; set; }
|
||||
}
|
||||
|
||||
private sealed class Policies
|
||||
{
|
||||
[JsonPropertyName("max_query_batch")] public int MaxQueryBatch { get; set; }
|
||||
[JsonPropertyName("min_query_interval_ms")] public int MinQueryIntervalMs { get; set; }
|
||||
[JsonPropertyName("rate_limit_per_min")] public int RateLimitPerMin { get; set; }
|
||||
[JsonPropertyName("token_ttl_sec")] public int TokenTtlSec { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ using System.Text;
|
||||
using MareSynchronosShared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prometheus;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using MareSynchronosShared.Utils.Configuration;
|
||||
|
||||
namespace MareSynchronosAuthService;
|
||||
@@ -35,6 +36,12 @@ public class Startup
|
||||
{
|
||||
var config = app.ApplicationServices.GetRequiredService<IConfigurationService<MareConfigurationBase>>();
|
||||
|
||||
// Respect X-Forwarded-* headers from the reverse proxy so generated links use the public scheme/host
|
||||
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost | ForwardedHeaders.XForwardedFor
|
||||
});
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
app.UseHttpMetrics();
|
||||
@@ -48,6 +55,7 @@ public class Startup
|
||||
app.UseEndpoints(endpoints =>
|
||||
{
|
||||
endpoints.MapControllers();
|
||||
endpoints.MapHealthChecks("/healthz").WithMetadata(new AllowAnonymousAttribute());
|
||||
|
||||
foreach (var source in endpoints.DataSources.SelectMany(e => e.Endpoints).Cast<RouteEndpoint>())
|
||||
{
|
||||
@@ -65,8 +73,8 @@ public class Startup
|
||||
|
||||
ConfigureRedis(services, mareConfig);
|
||||
|
||||
services.AddSingleton<SecretKeyAuthenticatorService>();
|
||||
services.AddSingleton<AccountRegistrationService>();
|
||||
services.AddScoped<SecretKeyAuthenticatorService>();
|
||||
services.AddScoped<AccountRegistrationService>();
|
||||
services.AddSingleton<GeoIPService>();
|
||||
|
||||
services.AddHostedService(provider => provider.GetRequiredService<GeoIPService>());
|
||||
@@ -75,6 +83,30 @@ public class Startup
|
||||
services.Configure<MareConfigurationBase>(_configuration.GetRequiredSection("MareSynchronos"));
|
||||
|
||||
services.AddSingleton<ServerTokenGenerator>();
|
||||
// Nearby discovery services (well-known + presence)
|
||||
services.AddSingleton<DiscoveryWellKnownProvider>();
|
||||
services.AddHostedService(p => p.GetRequiredService<DiscoveryWellKnownProvider>());
|
||||
|
||||
// Presence store selection
|
||||
var discoveryStore = _configuration.GetValue<string>("NearbyDiscovery:Store") ?? "memory";
|
||||
TimeSpan presenceTtl = TimeSpan.FromMinutes(_configuration.GetValue<int>("NearbyDiscovery:PresenceTtlMinutes", 5));
|
||||
TimeSpan tokenTtl = TimeSpan.FromSeconds(_configuration.GetValue<int>("NearbyDiscovery:TokenTtlSeconds", 120));
|
||||
if (string.Equals(discoveryStore, "redis", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
services.AddSingleton<MareSynchronosAuthService.Services.Discovery.IDiscoveryPresenceStore>(sp =>
|
||||
{
|
||||
var logger = sp.GetRequiredService<ILogger<MareSynchronosAuthService.Services.Discovery.RedisPresenceStore>>();
|
||||
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
|
||||
return new MareSynchronosAuthService.Services.Discovery.RedisPresenceStore(logger, mux, presenceTtl, tokenTtl);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<MareSynchronosAuthService.Services.Discovery.IDiscoveryPresenceStore>(sp => new MareSynchronosAuthService.Services.Discovery.InMemoryPresenceStore(presenceTtl, tokenTtl));
|
||||
}
|
||||
|
||||
services.AddSingleton<DiscoveryPresenceService>();
|
||||
services.AddHostedService(p => p.GetRequiredService<DiscoveryPresenceService>());
|
||||
|
||||
ConfigureAuthorization(services);
|
||||
|
||||
@@ -88,8 +120,11 @@ public class Startup
|
||||
services.AddControllers().ConfigureApplicationPartManager(a =>
|
||||
{
|
||||
a.FeatureProviders.Remove(a.FeatureProviders.OfType<ControllerFeatureProvider>().First());
|
||||
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(JwtController)));
|
||||
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(JwtController), typeof(WellKnownController), typeof(DiscoveryController)));
|
||||
});
|
||||
|
||||
services.AddSingleton<DiscoveryWellKnownProvider>();
|
||||
services.AddHostedService(p => p.GetRequiredService<DiscoveryWellKnownProvider>());
|
||||
}
|
||||
|
||||
private static void ConfigureAuthorization(IServiceCollection services)
|
||||
@@ -195,6 +230,8 @@ public class Startup
|
||||
};
|
||||
|
||||
services.AddStackExchangeRedisExtensions<SystemTextJsonSerializer>(redisConfiguration);
|
||||
// Also expose raw multiplexer for custom Redis usage (discovery presence)
|
||||
services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(options));
|
||||
}
|
||||
private void ConfigureConfigServices(IServiceCollection services)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Username=postgres;Password=postgres;Database=umbra_dev"
|
||||
},
|
||||
"MareSynchronos": {
|
||||
"Jwt": "dev-secret-umbra-abcdefghijklmnopqrstuvwxyz123456",
|
||||
"RedisConnectionString": "localhost:6379,connectTimeout=5000,syncTimeout=5000",
|
||||
"MetricsPort": 4985
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using MareSynchronos.API.SignalR;
|
||||
using MareSynchronosShared.Utils;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MareSynchronosServer.Controllers;
|
||||
|
||||
[Route("/main/discovery")]
|
||||
[Authorize(Policy = "Internal")]
|
||||
public class DiscoveryNotifyController : Controller
|
||||
{
|
||||
private readonly ILogger<DiscoveryNotifyController> _logger;
|
||||
private readonly IHubContext<Hubs.MareHub, IMareHub> _hub;
|
||||
|
||||
public DiscoveryNotifyController(ILogger<DiscoveryNotifyController> logger, IHubContext<Hubs.MareHub, IMareHub> hub)
|
||||
{
|
||||
_logger = logger;
|
||||
_hub = hub;
|
||||
}
|
||||
|
||||
public sealed class NotifyRequestDto
|
||||
{
|
||||
[JsonPropertyName("targetUid")] public string TargetUid { get; set; } = string.Empty;
|
||||
[JsonPropertyName("fromUid")] public string FromUid { get; set; } = string.Empty;
|
||||
[JsonPropertyName("fromAlias")] public string? FromAlias { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("notifyRequest")]
|
||||
public async Task<IActionResult> NotifyRequest([FromBody] NotifyRequestDto dto)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dto.TargetUid)) return BadRequest();
|
||||
var name = string.IsNullOrEmpty(dto.FromAlias) ? dto.FromUid : dto.FromAlias;
|
||||
var msg = $"Nearby Request: {name} [{dto.FromUid}]";
|
||||
_logger.LogInformation("Discovery notify request to {target} from {from}", dto.TargetUid, name);
|
||||
await _hub.Clients.User(dto.TargetUid).Client_ReceiveServerMessage(MareSynchronos.API.Data.Enum.MessageSeverity.Information, msg);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
public sealed class NotifyAcceptDto
|
||||
{
|
||||
[JsonPropertyName("targetUid")] public string TargetUid { get; set; } = string.Empty;
|
||||
[JsonPropertyName("fromUid")] public string FromUid { get; set; } = string.Empty;
|
||||
[JsonPropertyName("fromAlias")] public string? FromAlias { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("notifyAccept")]
|
||||
public async Task<IActionResult> NotifyAccept([FromBody] NotifyAcceptDto dto)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dto.TargetUid)) return BadRequest();
|
||||
var name = string.IsNullOrEmpty(dto.FromAlias) ? dto.FromUid : dto.FromAlias;
|
||||
var msg = $"Nearby Accept: {name} [{dto.FromUid}]";
|
||||
_logger.LogInformation("Discovery notify accept to {target} from {from}", dto.TargetUid, name);
|
||||
await _hub.Clients.User(dto.TargetUid).Client_ReceiveServerMessage(MareSynchronos.API.Data.Enum.MessageSeverity.Information, msg);
|
||||
return Accepted();
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ namespace MareSynchronosServer.Hubs
|
||||
|
||||
public Task Client_UserUpdateSelfPairPermissions(UserPermissionsDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
|
||||
|
||||
public Task Client_UserTypingState(TypingStateDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
|
||||
|
||||
public Task Client_GposeLobbyJoin(UserData userData) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
|
||||
public Task Client_GposeLobbyLeave(UserData userData) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
|
||||
public Task Client_GposeLobbyPushCharacterData(CharaDataDownloadDto charaDownloadDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
|
||||
|
||||
@@ -259,7 +259,13 @@ public partial class MareHub
|
||||
var user = await DbContext.Users.SingleAsync(u => u.UID == groupHasMigrated.Item2).ConfigureAwait(false);
|
||||
|
||||
await Clients.Users(groupPairsWithoutSelf.Select(p => p.GroupUserUID)).Client_GroupSendInfo(new GroupInfoDto(group.ToGroupData(),
|
||||
user.ToUserData(), group.GetGroupPermissions())).ConfigureAwait(false);
|
||||
user.ToUserData(), group.GetGroupPermissions())
|
||||
{
|
||||
IsTemporary = group.IsTemporary,
|
||||
ExpiresAt = group.ExpiresAt,
|
||||
AutoDetectVisible = group.AutoDetectVisible,
|
||||
PasswordTemporarilyDisabled = group.PasswordTemporarilyDisabled,
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -144,7 +144,13 @@ public partial class MareHub
|
||||
|
||||
var groupPairs = await DbContext.GroupPairs.Where(p => p.GroupGID == dto.Group.GID).Select(p => p.GroupUserUID).AsNoTracking().ToListAsync().ConfigureAwait(false);
|
||||
|
||||
await Clients.Users(groupPairs).Client_GroupSendInfo(new GroupInfoDto(group.ToGroupData(), newOwnerPair.GroupUser.ToUserData(), group.GetGroupPermissions())).ConfigureAwait(false);
|
||||
await Clients.Users(groupPairs).Client_GroupSendInfo(new GroupInfoDto(group.ToGroupData(), newOwnerPair.GroupUser.ToUserData(), group.GetGroupPermissions())
|
||||
{
|
||||
IsTemporary = group.IsTemporary,
|
||||
ExpiresAt = group.ExpiresAt,
|
||||
AutoDetectVisible = group.AutoDetectVisible,
|
||||
PasswordTemporarilyDisabled = group.PasswordTemporarilyDisabled,
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
@@ -202,7 +208,7 @@ public partial class MareHub
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task<GroupPasswordDto> GroupCreate()
|
||||
public async Task<GroupPasswordDto> GroupCreate(string? alias)
|
||||
{
|
||||
_logger.LogCallInfo();
|
||||
var existingGroupsByUser = await DbContext.Groups.CountAsync(u => u.OwnerUID == UserUID).ConfigureAwait(false);
|
||||
@@ -223,12 +229,114 @@ public partial class MareHub
|
||||
var sha = SHA256.Create();
|
||||
var hashedPw = StringUtils.Sha256String(passwd);
|
||||
|
||||
string? sanitizedAlias = null;
|
||||
if (!string.IsNullOrWhiteSpace(alias))
|
||||
{
|
||||
sanitizedAlias = alias.Trim();
|
||||
if (sanitizedAlias.Length > 50)
|
||||
{
|
||||
sanitizedAlias = sanitizedAlias[..50];
|
||||
}
|
||||
|
||||
var aliasExists = await DbContext.Groups
|
||||
.AnyAsync(g => g.Alias != null && EF.Functions.ILike(g.Alias!, sanitizedAlias))
|
||||
.ConfigureAwait(false);
|
||||
if (aliasExists)
|
||||
{
|
||||
throw new System.Exception("Syncshell name is already in use.");
|
||||
}
|
||||
}
|
||||
|
||||
Group newGroup = new()
|
||||
{
|
||||
GID = gid,
|
||||
HashedPassword = hashedPw,
|
||||
InvitesEnabled = true,
|
||||
OwnerUID = UserUID,
|
||||
Alias = sanitizedAlias,
|
||||
IsTemporary = false,
|
||||
ExpiresAt = null,
|
||||
};
|
||||
|
||||
GroupPair initialPair = new()
|
||||
{
|
||||
GroupGID = newGroup.GID,
|
||||
GroupUserUID = UserUID,
|
||||
IsPaused = false,
|
||||
IsPinned = true,
|
||||
};
|
||||
|
||||
await DbContext.Groups.AddAsync(newGroup).ConfigureAwait(false);
|
||||
await DbContext.GroupPairs.AddAsync(initialPair).ConfigureAwait(false);
|
||||
await DbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
|
||||
var self = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
|
||||
|
||||
await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(newGroup.ToGroupData(), self.ToUserData(), GroupPermissions.NoneSet, GroupUserPermissions.NoneSet, GroupUserInfo.None)
|
||||
{
|
||||
IsTemporary = newGroup.IsTemporary,
|
||||
ExpiresAt = newGroup.ExpiresAt,
|
||||
AutoDetectVisible = newGroup.AutoDetectVisible,
|
||||
PasswordTemporarilyDisabled = newGroup.PasswordTemporarilyDisabled,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
_logger.LogCallInfo(MareHubLogger.Args(gid));
|
||||
|
||||
return new GroupPasswordDto(newGroup.ToGroupData(), passwd)
|
||||
{
|
||||
IsTemporary = newGroup.IsTemporary,
|
||||
ExpiresAt = newGroup.ExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task<GroupPasswordDto> GroupCreateTemporary(DateTime expiresAtUtc)
|
||||
{
|
||||
_logger.LogCallInfo();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (expiresAtUtc.Kind == DateTimeKind.Unspecified)
|
||||
{
|
||||
expiresAtUtc = DateTime.SpecifyKind(expiresAtUtc, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
if (expiresAtUtc <= now)
|
||||
{
|
||||
throw new System.Exception("Expiration must be in the future.");
|
||||
}
|
||||
|
||||
if (expiresAtUtc > now.AddDays(7))
|
||||
{
|
||||
throw new System.Exception("Temporary syncshells may not last longer than 7 days.");
|
||||
}
|
||||
|
||||
var existingGroupsByUser = await DbContext.Groups.CountAsync(u => u.OwnerUID == UserUID).ConfigureAwait(false);
|
||||
var existingJoinedGroups = await DbContext.GroupPairs.CountAsync(u => u.GroupUserUID == UserUID).ConfigureAwait(false);
|
||||
if (existingGroupsByUser >= _maxExistingGroupsByUser || existingJoinedGroups >= _maxJoinedGroupsByUser)
|
||||
{
|
||||
throw new System.Exception($"Max groups for user is {_maxExistingGroupsByUser}, max joined groups is {_maxJoinedGroupsByUser}.");
|
||||
}
|
||||
|
||||
var gid = StringUtils.GenerateRandomString(9);
|
||||
while (await DbContext.Groups.AnyAsync(g => g.GID == "UMB-" + gid).ConfigureAwait(false))
|
||||
{
|
||||
gid = StringUtils.GenerateRandomString(9);
|
||||
}
|
||||
gid = "UMB-" + gid;
|
||||
|
||||
var passwd = StringUtils.GenerateRandomString(16);
|
||||
var sha = SHA256.Create();
|
||||
var hashedPw = StringUtils.Sha256String(passwd);
|
||||
|
||||
Group newGroup = new()
|
||||
{
|
||||
GID = gid,
|
||||
HashedPassword = hashedPw,
|
||||
InvitesEnabled = true,
|
||||
OwnerUID = UserUID,
|
||||
Alias = null,
|
||||
IsTemporary = true,
|
||||
ExpiresAt = expiresAtUtc,
|
||||
};
|
||||
|
||||
GroupPair initialPair = new()
|
||||
@@ -245,12 +353,21 @@ public partial class MareHub
|
||||
|
||||
var self = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
|
||||
|
||||
await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(newGroup.ToGroupData(), self.ToUserData(), GroupPermissions.NoneSet, GroupUserPermissions.NoneSet, GroupUserInfo.None))
|
||||
.ConfigureAwait(false);
|
||||
await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(newGroup.ToGroupData(), self.ToUserData(), GroupPermissions.NoneSet, GroupUserPermissions.NoneSet, GroupUserInfo.None)
|
||||
{
|
||||
IsTemporary = newGroup.IsTemporary,
|
||||
ExpiresAt = newGroup.ExpiresAt,
|
||||
AutoDetectVisible = newGroup.AutoDetectVisible,
|
||||
PasswordTemporarilyDisabled = newGroup.PasswordTemporarilyDisabled,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
_logger.LogCallInfo(MareHubLogger.Args(gid));
|
||||
_logger.LogCallInfo(MareHubLogger.Args(gid, "Temporary", expiresAtUtc));
|
||||
|
||||
return new GroupPasswordDto(newGroup.ToGroupData(), passwd);
|
||||
return new GroupPasswordDto(newGroup.ToGroupData(), passwd)
|
||||
{
|
||||
IsTemporary = newGroup.IsTemporary,
|
||||
ExpiresAt = newGroup.ExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
@@ -338,22 +455,130 @@ public partial class MareHub
|
||||
_logger.LogCallInfo(MareHubLogger.Args(dto.Group));
|
||||
|
||||
var group = await DbContext.Groups.Include(g => g.Owner).AsNoTracking().SingleOrDefaultAsync(g => g.GID == aliasOrGid || g.Alias == aliasOrGid).ConfigureAwait(false);
|
||||
var groupGid = group?.GID ?? string.Empty;
|
||||
var existingPair = await DbContext.GroupPairs.AsNoTracking().SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.GroupUserUID == UserUID).ConfigureAwait(false);
|
||||
var hashedPw = StringUtils.Sha256String(dto.Password);
|
||||
|
||||
return await JoinGroupInternal(group, aliasOrGid, hashedPw, allowPasswordless: false, skipInviteCheck: false).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task<bool> SyncshellDiscoveryJoin(GroupDto dto)
|
||||
{
|
||||
var gid = dto.Group.GID.Trim();
|
||||
|
||||
_logger.LogCallInfo(MareHubLogger.Args(dto.Group));
|
||||
|
||||
var group = await DbContext.Groups.Include(g => g.Owner).AsNoTracking().SingleOrDefaultAsync(g => g.GID == gid).ConfigureAwait(false);
|
||||
|
||||
return await JoinGroupInternal(group, gid, hashedPassword: null, allowPasswordless: true, skipInviteCheck: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task<List<SyncshellDiscoveryEntryDto>> SyncshellDiscoveryList()
|
||||
{
|
||||
_logger.LogCallInfo();
|
||||
|
||||
var groups = await DbContext.Groups.AsNoTracking()
|
||||
.Include(g => g.Owner)
|
||||
.Where(g => g.AutoDetectVisible && (!g.IsTemporary || g.ExpiresAt == null || g.ExpiresAt > DateTime.UtcNow))
|
||||
.ToListAsync().ConfigureAwait(false);
|
||||
|
||||
var groupIds = groups.Select(g => g.GID).ToArray();
|
||||
var memberCounts = await DbContext.GroupPairs.AsNoTracking()
|
||||
.Where(p => groupIds.Contains(p.GroupGID))
|
||||
.GroupBy(p => p.GroupGID)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(k => k.Key, k => k.Count, StringComparer.OrdinalIgnoreCase)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return groups.Select(g => new SyncshellDiscoveryEntryDto
|
||||
{
|
||||
GID = g.GID,
|
||||
Alias = g.Alias,
|
||||
OwnerUID = g.OwnerUID,
|
||||
OwnerAlias = g.Owner.Alias,
|
||||
MemberCount = memberCounts.TryGetValue(g.GID, out var count) ? count : 0,
|
||||
AutoAcceptPairs = g.InvitesEnabled,
|
||||
Description = null,
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task<SyncshellDiscoveryStateDto?> SyncshellDiscoveryGetState(GroupDto dto)
|
||||
{
|
||||
_logger.LogCallInfo(MareHubLogger.Args(dto.Group));
|
||||
|
||||
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
|
||||
if (!hasRights) return null;
|
||||
|
||||
return new SyncshellDiscoveryStateDto
|
||||
{
|
||||
GID = group.GID,
|
||||
AutoDetectVisible = group.AutoDetectVisible,
|
||||
PasswordTemporarilyDisabled = group.PasswordTemporarilyDisabled,
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task<bool> SyncshellDiscoverySetVisibility(SyncshellDiscoveryVisibilityRequestDto dto)
|
||||
{
|
||||
_logger.LogCallInfo(MareHubLogger.Args(dto));
|
||||
|
||||
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.GID).ConfigureAwait(false);
|
||||
if (!hasRights) return false;
|
||||
|
||||
group.AutoDetectVisible = dto.AutoDetectVisible;
|
||||
group.PasswordTemporarilyDisabled = dto.AutoDetectVisible;
|
||||
|
||||
await DbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
|
||||
await DbContext.Entry(group).Reference(g => g.Owner).LoadAsync().ConfigureAwait(false);
|
||||
|
||||
var groupPairs = await DbContext.GroupPairs.AsNoTracking().Where(p => p.GroupGID == group.GID).Select(p => p.GroupUserUID).ToListAsync().ConfigureAwait(false);
|
||||
await Clients.Users(groupPairs).Client_GroupSendInfo(new GroupInfoDto(group.ToGroupData(), group.Owner.ToUserData(), group.GetGroupPermissions())
|
||||
{
|
||||
IsTemporary = group.IsTemporary,
|
||||
ExpiresAt = group.ExpiresAt,
|
||||
AutoDetectVisible = group.AutoDetectVisible,
|
||||
PasswordTemporarilyDisabled = group.PasswordTemporarilyDisabled,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> JoinGroupInternal(Group? group, string aliasOrGid, string? hashedPassword, bool allowPasswordless, bool skipInviteCheck)
|
||||
{
|
||||
if (group == null) return false;
|
||||
|
||||
var groupGid = group.GID;
|
||||
var existingPair = await DbContext.GroupPairs.AsNoTracking().SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.GroupUserUID == UserUID).ConfigureAwait(false);
|
||||
var existingUserCount = await DbContext.GroupPairs.AsNoTracking().CountAsync(g => g.GroupGID == groupGid).ConfigureAwait(false);
|
||||
var joinedGroups = await DbContext.GroupPairs.CountAsync(g => g.GroupUserUID == UserUID).ConfigureAwait(false);
|
||||
var isBanned = await DbContext.GroupBans.AnyAsync(g => g.GroupGID == groupGid && g.BannedUserUID == UserUID).ConfigureAwait(false);
|
||||
var oneTimeInvite = await DbContext.GroupTempInvites.SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.Invite == hashedPw).ConfigureAwait(false);
|
||||
|
||||
if (group == null
|
||||
|| (!string.Equals(group.HashedPassword, hashedPw, StringComparison.Ordinal) && oneTimeInvite == null)
|
||||
GroupTempInvite? oneTimeInvite = null;
|
||||
if (!string.IsNullOrEmpty(hashedPassword))
|
||||
{
|
||||
oneTimeInvite = await DbContext.GroupTempInvites.SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.Invite == hashedPassword).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (allowPasswordless && !group.AutoDetectVisible)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool passwordBypass = group.PasswordTemporarilyDisabled || allowPasswordless;
|
||||
bool passwordMatches = !string.IsNullOrEmpty(hashedPassword) && string.Equals(group.HashedPassword, hashedPassword, StringComparison.Ordinal);
|
||||
bool hasValidCredential = passwordBypass || passwordMatches || oneTimeInvite != null;
|
||||
|
||||
if (!hasValidCredential
|
||||
|| existingPair != null
|
||||
|| existingUserCount >= _maxGroupUserCount
|
||||
|| !group.InvitesEnabled
|
||||
|| (!skipInviteCheck && !group.InvitesEnabled)
|
||||
|| joinedGroups >= _maxJoinedGroupsByUser
|
||||
|| isBanned)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (oneTimeInvite != null)
|
||||
{
|
||||
@@ -375,9 +600,17 @@ public partial class MareHub
|
||||
|
||||
_logger.LogCallInfo(MareHubLogger.Args(aliasOrGid, "Success"));
|
||||
|
||||
await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(group.ToGroupData(), group.Owner.ToUserData(), group.GetGroupPermissions(), newPair.GetGroupPairPermissions(), newPair.GetGroupPairUserInfo())).ConfigureAwait(false);
|
||||
var owner = group.Owner ?? await DbContext.Users.AsNoTracking().SingleAsync(u => u.UID == group.OwnerUID).ConfigureAwait(false);
|
||||
|
||||
var self = DbContext.Users.Single(u => u.UID == UserUID);
|
||||
await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(group.ToGroupData(), owner.ToUserData(), group.GetGroupPermissions(), newPair.GetGroupPairPermissions(), newPair.GetGroupPairUserInfo())
|
||||
{
|
||||
IsTemporary = group.IsTemporary,
|
||||
ExpiresAt = group.ExpiresAt,
|
||||
AutoDetectVisible = group.AutoDetectVisible,
|
||||
PasswordTemporarilyDisabled = group.PasswordTemporarilyDisabled,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
var self = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
|
||||
|
||||
var groupPairs = await DbContext.GroupPairs.Include(p => p.GroupUser).Where(p => p.GroupGID == group.GID && p.GroupUserUID != UserUID).ToListAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -527,7 +760,13 @@ public partial class MareHub
|
||||
var groups = await DbContext.GroupPairs.Include(g => g.Group).Include(g => g.Group.Owner).Where(g => g.GroupUserUID == UserUID).AsNoTracking().ToListAsync().ConfigureAwait(false);
|
||||
|
||||
return groups.Select(g => new GroupFullInfoDto(g.Group.ToGroupData(), g.Group.Owner.ToUserData(),
|
||||
g.Group.GetGroupPermissions(), g.GetGroupPairPermissions(), g.GetGroupPairUserInfo())).ToList();
|
||||
g.Group.GetGroupPermissions(), g.GetGroupPairPermissions(), g.GetGroupPairUserInfo())
|
||||
{
|
||||
IsTemporary = g.Group.IsTemporary,
|
||||
ExpiresAt = g.Group.ExpiresAt,
|
||||
AutoDetectVisible = g.Group.AutoDetectVisible,
|
||||
PasswordTemporarilyDisabled = g.Group.PasswordTemporarilyDisabled,
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using MareSynchronos.API.Dto.McdfShare;
|
||||
using MareSynchronosServer.Utils;
|
||||
using MareSynchronosShared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MareSynchronosServer.Hubs;
|
||||
|
||||
public partial class MareHub
|
||||
{
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task<List<McdfShareEntryDto>> McdfShareGetOwn()
|
||||
{
|
||||
_logger.LogCallInfo();
|
||||
|
||||
var shares = await DbContext.McdfShares.AsNoTracking()
|
||||
.Include(s => s.Owner)
|
||||
.Include(s => s.AllowedIndividuals)
|
||||
.Include(s => s.AllowedSyncshells)
|
||||
.Where(s => s.OwnerUID == UserUID)
|
||||
.OrderByDescending(s => s.CreatedUtc)
|
||||
.ToListAsync().ConfigureAwait(false);
|
||||
|
||||
return shares.Select(s => MapShareEntryDto(s, true)).ToList();
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task<List<McdfShareEntryDto>> McdfShareGetShared()
|
||||
{
|
||||
_logger.LogCallInfo();
|
||||
|
||||
var userGroups = await DbContext.GroupPairs.AsNoTracking()
|
||||
.Where(p => p.GroupUserUID == UserUID)
|
||||
.Select(p => p.GroupGID.ToUpperInvariant())
|
||||
.ToListAsync().ConfigureAwait(false);
|
||||
|
||||
var shares = await DbContext.McdfShares.AsNoTracking()
|
||||
.Include(s => s.Owner)
|
||||
.Include(s => s.AllowedIndividuals)
|
||||
.Include(s => s.AllowedSyncshells)
|
||||
.Where(s => s.OwnerUID != UserUID)
|
||||
.OrderByDescending(s => s.CreatedUtc)
|
||||
.ToListAsync().ConfigureAwait(false);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var accessible = shares.Where(s => ShareAccessibleToUser(s, userGroups) && (!s.ExpiresAtUtc.HasValue || s.ExpiresAtUtc > now)).ToList();
|
||||
|
||||
return accessible.Select(s => MapShareEntryDto(s, false)).ToList();
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task<bool> McdfShareUpload(McdfShareUploadRequestDto dto)
|
||||
{
|
||||
_logger.LogCallInfo(MareHubLogger.Args(dto.ShareId));
|
||||
|
||||
var normalizedUsers = dto.AllowedIndividuals
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(NormalizeUid)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var normalizedGroups = dto.AllowedSyncshells
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(NormalizeGroup)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var share = await DbContext.McdfShares
|
||||
.Include(s => s.AllowedIndividuals)
|
||||
.Include(s => s.AllowedSyncshells)
|
||||
.SingleOrDefaultAsync(s => s.Id == dto.ShareId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (share != null && !string.Equals(share.OwnerUID, UserUID, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (share == null)
|
||||
{
|
||||
share = new McdfShare
|
||||
{
|
||||
Id = dto.ShareId,
|
||||
OwnerUID = UserUID,
|
||||
CreatedUtc = now,
|
||||
};
|
||||
DbContext.McdfShares.Add(share);
|
||||
}
|
||||
|
||||
share.Description = dto.Description ?? string.Empty;
|
||||
share.CipherData = dto.CipherData ?? Array.Empty<byte>();
|
||||
share.Nonce = dto.Nonce ?? Array.Empty<byte>();
|
||||
share.Salt = dto.Salt ?? Array.Empty<byte>();
|
||||
share.Tag = dto.Tag ?? Array.Empty<byte>();
|
||||
share.ExpiresAtUtc = dto.ExpiresAtUtc;
|
||||
share.UpdatedUtc = now;
|
||||
|
||||
share.AllowedIndividuals.Clear();
|
||||
foreach (var uid in normalizedUsers)
|
||||
{
|
||||
share.AllowedIndividuals.Add(new McdfShareAllowedUser
|
||||
{
|
||||
ShareId = share.Id,
|
||||
AllowedIndividualUid = uid,
|
||||
});
|
||||
}
|
||||
|
||||
share.AllowedSyncshells.Clear();
|
||||
foreach (var gid in normalizedGroups)
|
||||
{
|
||||
share.AllowedSyncshells.Add(new McdfShareAllowedGroup
|
||||
{
|
||||
ShareId = share.Id,
|
||||
AllowedGroupGid = gid,
|
||||
});
|
||||
}
|
||||
|
||||
await DbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task<McdfShareEntryDto?> McdfShareUpdate(McdfShareUpdateRequestDto dto)
|
||||
{
|
||||
_logger.LogCallInfo(MareHubLogger.Args(dto.ShareId));
|
||||
|
||||
var share = await DbContext.McdfShares
|
||||
.Include(s => s.AllowedIndividuals)
|
||||
.Include(s => s.AllowedSyncshells)
|
||||
.Include(s => s.Owner)
|
||||
.SingleOrDefaultAsync(s => s.Id == dto.ShareId && s.OwnerUID == UserUID)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (share == null) return null;
|
||||
|
||||
share.Description = dto.Description ?? string.Empty;
|
||||
share.ExpiresAtUtc = dto.ExpiresAtUtc;
|
||||
share.UpdatedUtc = DateTime.UtcNow;
|
||||
|
||||
var normalizedUsers = dto.AllowedIndividuals
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(NormalizeUid)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var normalizedGroups = dto.AllowedSyncshells
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(NormalizeGroup)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
share.AllowedIndividuals.Clear();
|
||||
foreach (var uid in normalizedUsers)
|
||||
{
|
||||
share.AllowedIndividuals.Add(new McdfShareAllowedUser
|
||||
{
|
||||
ShareId = share.Id,
|
||||
AllowedIndividualUid = uid,
|
||||
});
|
||||
}
|
||||
|
||||
share.AllowedSyncshells.Clear();
|
||||
foreach (var gid in normalizedGroups)
|
||||
{
|
||||
share.AllowedSyncshells.Add(new McdfShareAllowedGroup
|
||||
{
|
||||
ShareId = share.Id,
|
||||
AllowedGroupGid = gid,
|
||||
});
|
||||
}
|
||||
|
||||
await DbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
return MapShareEntryDto(share, true);
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task<bool> McdfShareDelete(Guid shareId)
|
||||
{
|
||||
_logger.LogCallInfo(MareHubLogger.Args(shareId));
|
||||
|
||||
var share = await DbContext.McdfShares.SingleOrDefaultAsync(s => s.Id == shareId && s.OwnerUID == UserUID).ConfigureAwait(false);
|
||||
if (share == null) return false;
|
||||
|
||||
DbContext.McdfShares.Remove(share);
|
||||
await DbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task<McdfSharePayloadDto?> McdfShareDownload(Guid shareId)
|
||||
{
|
||||
_logger.LogCallInfo(MareHubLogger.Args(shareId));
|
||||
|
||||
var share = await DbContext.McdfShares
|
||||
.Include(s => s.AllowedIndividuals)
|
||||
.Include(s => s.AllowedSyncshells)
|
||||
.SingleOrDefaultAsync(s => s.Id == shareId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (share == null) return null;
|
||||
|
||||
var userGroups = await DbContext.GroupPairs.AsNoTracking()
|
||||
.Where(p => p.GroupUserUID == UserUID)
|
||||
.Select(p => p.GroupGID.ToUpperInvariant())
|
||||
.ToListAsync().ConfigureAwait(false);
|
||||
|
||||
bool isOwner = string.Equals(share.OwnerUID, UserUID, StringComparison.Ordinal);
|
||||
if (!isOwner && (!ShareAccessibleToUser(share, userGroups) || (share.ExpiresAtUtc.HasValue && share.ExpiresAtUtc.Value <= DateTime.UtcNow)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
share.DownloadCount++;
|
||||
await DbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
|
||||
return new McdfSharePayloadDto
|
||||
{
|
||||
ShareId = share.Id,
|
||||
Description = share.Description,
|
||||
CipherData = share.CipherData,
|
||||
Nonce = share.Nonce,
|
||||
Salt = share.Salt,
|
||||
Tag = share.Tag,
|
||||
CreatedUtc = share.CreatedUtc,
|
||||
ExpiresAtUtc = share.ExpiresAtUtc,
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeUid(string candidate) => candidate.Trim().ToUpperInvariant();
|
||||
private static string NormalizeGroup(string candidate) => candidate.Trim().ToUpperInvariant();
|
||||
|
||||
private static McdfShareEntryDto MapShareEntryDto(McdfShare share, bool isOwner)
|
||||
{
|
||||
return new McdfShareEntryDto
|
||||
{
|
||||
Id = share.Id,
|
||||
Description = share.Description,
|
||||
CreatedUtc = share.CreatedUtc,
|
||||
UpdatedUtc = share.UpdatedUtc,
|
||||
ExpiresAtUtc = share.ExpiresAtUtc,
|
||||
DownloadCount = share.DownloadCount,
|
||||
IsOwner = isOwner,
|
||||
OwnerUid = share.OwnerUID,
|
||||
OwnerAlias = share.Owner?.Alias ?? string.Empty,
|
||||
AllowedIndividuals = share.AllowedIndividuals.Select(i => i.AllowedIndividualUid).OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
AllowedSyncshells = share.AllowedSyncshells.Select(g => g.AllowedGroupGid).OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
private bool ShareAccessibleToUser(McdfShare share, IReadOnlyCollection<string> userGroups)
|
||||
{
|
||||
if (string.Equals(share.OwnerUID, UserUID, StringComparison.Ordinal)) return true;
|
||||
|
||||
bool allowedByUser = share.AllowedIndividuals.Any(i => string.Equals(i.AllowedIndividualUid, UserUID, StringComparison.OrdinalIgnoreCase));
|
||||
if (allowedByUser) return true;
|
||||
|
||||
if (share.AllowedSyncshells.Count == 0) return false;
|
||||
var allowedGroups = share.AllowedSyncshells.Select(g => g.AllowedGroupGid).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return userGroups.Any(g => allowedGroups.Contains(g));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using MareSynchronos.API.Data.Enum;
|
||||
using MareSynchronos.API.Data.Extensions;
|
||||
using MareSynchronos.API.Dto.User;
|
||||
using MareSynchronosServer.Utils;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MareSynchronosServer.Hubs;
|
||||
|
||||
public partial class MareHub
|
||||
{
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task UserSetTypingState(bool isTyping)
|
||||
{
|
||||
_logger.LogCallInfo(MareHubLogger.Args(isTyping));
|
||||
|
||||
var pairedEntries = await GetAllPairedClientsWithPauseState().ConfigureAwait(false);
|
||||
|
||||
var sender = await DbContext.Users.AsNoTracking()
|
||||
.SingleAsync(u => u.UID == UserUID)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var typingDto = new TypingStateDto(sender.ToUserData(), isTyping, TypingScope.Proximity);
|
||||
|
||||
await Clients.Caller.Client_UserTypingState(typingDto).ConfigureAwait(false);
|
||||
|
||||
var recipients = pairedEntries
|
||||
.Where(p => !p.IsPaused)
|
||||
.Select(p => p.UID)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (recipients.Count == 0)
|
||||
return;
|
||||
|
||||
await Clients.Users(recipients).Client_UserTypingState(typingDto).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[Authorize(Policy = "Identified")]
|
||||
public async Task UserSetTypingState(bool isTyping, TypingScope scope)
|
||||
{
|
||||
_logger.LogCallInfo(MareHubLogger.Args(isTyping, scope));
|
||||
|
||||
var sender = await DbContext.Users.AsNoTracking()
|
||||
.SingleAsync(u => u.UID == UserUID)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var typingDto = new TypingStateDto(sender.ToUserData(), isTyping, scope);
|
||||
|
||||
await Clients.Caller.Client_UserTypingState(typingDto).ConfigureAwait(false);
|
||||
|
||||
if (scope == TypingScope.Party || scope == TypingScope.CrossParty)
|
||||
{
|
||||
_logger.LogCallInfo(MareHubLogger.Args("Typing scope is party-based; server-side party routing not yet implemented, not broadcasting to non-caller."));
|
||||
return;
|
||||
}
|
||||
|
||||
var pairedEntries = await GetAllPairedClientsWithPauseState().ConfigureAwait(false);
|
||||
var recipients = pairedEntries
|
||||
.Where(p => !p.IsPaused)
|
||||
.Select(p => p.UID)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (recipients.Count == 0) return;
|
||||
|
||||
await Clients.Users(recipients).Client_UserTypingState(typingDto).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ public partial class MareHub : Hub<IMareHub>, IMareHub
|
||||
dbUser.LastLoggedIn = DateTime.UtcNow;
|
||||
await DbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
|
||||
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "Welcome to Umbra! Current Online Users: " + _systemInfoService.SystemInfoDto.OnlineUsers).ConfigureAwait(false);
|
||||
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "Bienvenue sur UmbraSync ! Utilisateurs en ligne : " + _systemInfoService.SystemInfoDto.OnlineUsers).ConfigureAwait(false);
|
||||
|
||||
return new ConnectionDto(new UserData(dbUser.UID, string.IsNullOrWhiteSpace(dbUser.Alias) ? null : dbUser.Alias))
|
||||
{
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.19">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.7.1" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
|
||||
@@ -57,6 +57,7 @@ public class SystemInfoService : IHostedService, IDisposable
|
||||
SystemInfoDto = new SystemInfoDto()
|
||||
{
|
||||
OnlineUsers = onlineUsers,
|
||||
SupportsTypingState = true,
|
||||
};
|
||||
|
||||
if (_config.IsMain)
|
||||
|
||||
@@ -5,6 +5,7 @@ using MareSynchronosShared.Services;
|
||||
using MareSynchronosShared.Utils;
|
||||
using MareSynchronosShared.Utils.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Linq;
|
||||
|
||||
namespace MareSynchronosServer.Services;
|
||||
|
||||
@@ -46,20 +47,41 @@ public class UserCleanupService : IHostedService
|
||||
await PurgeUnusedAccounts(dbContext).ConfigureAwait(false);
|
||||
|
||||
await PurgeTempInvites(dbContext).ConfigureAwait(false);
|
||||
await PurgeExpiredTemporaryGroups(dbContext).ConfigureAwait(false);
|
||||
|
||||
dbContext.SaveChanges();
|
||||
}
|
||||
|
||||
var now = DateTime.Now;
|
||||
TimeOnly currentTime = new(now.Hour, now.Minute, now.Second);
|
||||
TimeOnly futureTime = new(now.Hour, now.Minute - now.Minute % 10, 0);
|
||||
var span = futureTime.AddMinutes(10) - currentTime;
|
||||
var span = TimeSpan.FromMinutes(1);
|
||||
var nextRun = DateTime.Now.Add(span);
|
||||
|
||||
_logger.LogInformation("User Cleanup Complete, next run at {date}", now.Add(span));
|
||||
_logger.LogInformation("User Cleanup Complete, next run at {date}", nextRun);
|
||||
await Task.Delay(span, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PurgeExpiredTemporaryGroups(MareDbContext dbContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var expiredGroups = await dbContext.Groups
|
||||
.Where(g => g.IsTemporary && g.ExpiresAt != null && g.ExpiresAt <= now)
|
||||
.ToListAsync()
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (expiredGroups.Count == 0) return;
|
||||
|
||||
_logger.LogInformation("Cleaning up {count} expired temporary syncshells", expiredGroups.Count);
|
||||
|
||||
dbContext.Groups.RemoveRange(expiredGroups);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error during temporary syncshell purge");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PurgeTempInvites(MareDbContext dbContext)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -13,6 +13,7 @@ using Prometheus;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using StackExchange.Redis;
|
||||
using StackExchange.Redis.Extensions.Core.Configuration;
|
||||
using System.Net;
|
||||
@@ -71,7 +72,7 @@ public class Startup
|
||||
a.FeatureProviders.Remove(a.FeatureProviders.OfType<ControllerFeatureProvider>().First());
|
||||
if (mareConfig.GetValue<Uri>(nameof(ServerConfiguration.MainServerAddress), defaultValue: null) == null)
|
||||
{
|
||||
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(MareServerConfigurationController), typeof(MareBaseConfigurationController), typeof(ClientMessageController), typeof(MainController)));
|
||||
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(MareServerConfigurationController), typeof(MareBaseConfigurationController), typeof(ClientMessageController), typeof(MainController), typeof(DiscoveryNotifyController)));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -198,6 +199,21 @@ public class Startup
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.GetValue<string>(nameof(MareConfigurationBase.Jwt)))),
|
||||
};
|
||||
|
||||
// Allow SignalR WebSocket connections to authenticate via access_token query on the hub path
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
var accessToken = context.Request.Query["access_token"].ToString();
|
||||
var path = context.HttpContext.Request.Path;
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments(IMareHub.Path))
|
||||
{
|
||||
context.Token = accessToken;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
services.AddAuthentication(o =>
|
||||
@@ -307,6 +323,12 @@ public class Startup
|
||||
|
||||
var config = app.ApplicationServices.GetRequiredService<IConfigurationService<MareConfigurationBase>>();
|
||||
|
||||
// Respect X-Forwarded-* headers from reverse proxies (required for correct scheme/host)
|
||||
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost | ForwardedHeaders.XForwardedFor
|
||||
});
|
||||
|
||||
app.UseIpRateLimiting();
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
@@ -51,6 +51,9 @@ public class MareDbContext : DbContext
|
||||
public DbSet<CharaDataOriginalFile> CharaDataOriginalFiles { get; set; }
|
||||
public DbSet<CharaDataPose> CharaDataPoses { get; set; }
|
||||
public DbSet<CharaDataAllowance> CharaDataAllowances { get; set; }
|
||||
public DbSet<McdfShare> McdfShares { get; set; }
|
||||
public DbSet<McdfShareAllowedUser> McdfShareAllowedUsers { get; set; }
|
||||
public DbSet<McdfShareAllowedGroup> McdfShareAllowedGroups { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder mb)
|
||||
{
|
||||
@@ -127,5 +130,28 @@ public class MareDbContext : DbContext
|
||||
mb.Entity<CharaDataAllowance>().HasIndex(c => c.ParentId);
|
||||
mb.Entity<CharaDataAllowance>().HasOne(u => u.AllowedGroup).WithMany().HasForeignKey(u => u.AllowedGroupGID).OnDelete(DeleteBehavior.Cascade);
|
||||
mb.Entity<CharaDataAllowance>().HasOne(u => u.AllowedUser).WithMany().HasForeignKey(u => u.AllowedUserUID).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
mb.Entity<McdfShare>().ToTable("mcdf_shares");
|
||||
mb.Entity<McdfShare>().HasIndex(s => s.OwnerUID);
|
||||
mb.Entity<McdfShare>().HasOne(s => s.Owner).WithMany().HasForeignKey(s => s.OwnerUID).OnDelete(DeleteBehavior.Cascade);
|
||||
mb.Entity<McdfShare>().Property(s => s.Description).HasColumnType("text");
|
||||
mb.Entity<McdfShare>().Property(s => s.CipherData).HasColumnType("bytea");
|
||||
mb.Entity<McdfShare>().Property(s => s.Nonce).HasColumnType("bytea");
|
||||
mb.Entity<McdfShare>().Property(s => s.Salt).HasColumnType("bytea");
|
||||
mb.Entity<McdfShare>().Property(s => s.Tag).HasColumnType("bytea");
|
||||
mb.Entity<McdfShare>().Property(s => s.CreatedUtc).HasColumnType("timestamp with time zone");
|
||||
mb.Entity<McdfShare>().Property(s => s.UpdatedUtc).HasColumnType("timestamp with time zone");
|
||||
mb.Entity<McdfShare>().Property(s => s.ExpiresAtUtc).HasColumnType("timestamp with time zone");
|
||||
mb.Entity<McdfShare>().Property(s => s.DownloadCount).HasColumnType("integer");
|
||||
mb.Entity<McdfShare>().HasMany(s => s.AllowedIndividuals).WithOne(a => a.Share).HasForeignKey(a => a.ShareId).OnDelete(DeleteBehavior.Cascade);
|
||||
mb.Entity<McdfShare>().HasMany(s => s.AllowedSyncshells).WithOne(a => a.Share).HasForeignKey(a => a.ShareId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
mb.Entity<McdfShareAllowedUser>().ToTable("mcdf_share_allowed_users");
|
||||
mb.Entity<McdfShareAllowedUser>().HasKey(u => new { u.ShareId, u.AllowedIndividualUid });
|
||||
mb.Entity<McdfShareAllowedUser>().HasIndex(u => u.AllowedIndividualUid);
|
||||
|
||||
mb.Entity<McdfShareAllowedGroup>().ToTable("mcdf_share_allowed_groups");
|
||||
mb.Entity<McdfShareAllowedGroup>().HasKey(g => new { g.ShareId, g.AllowedGroupGid });
|
||||
mb.Entity<McdfShareAllowedGroup>().HasIndex(g => g.AllowedGroupGid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MareSynchronosServer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class GroupTemporary : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "expires_at",
|
||||
table: "groups",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "is_temporary",
|
||||
table: "groups",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "expires_at",
|
||||
table: "groups");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "is_temporary",
|
||||
table: "groups");
|
||||
}
|
||||
}
|
||||
}
|
||||
1150
MareSynchronosServer/MareSynchronosShared/Migrations/20251102085631_SyncshellDiscovery.Designer.cs
generated
Normal file
1150
MareSynchronosServer/MareSynchronosShared/Migrations/20251102085631_SyncshellDiscovery.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MareSynchronosServer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SyncshellDiscovery : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "auto_detect_visible",
|
||||
table: "groups",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "password_temporarily_disabled",
|
||||
table: "groups",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "auto_detect_visible",
|
||||
table: "groups");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "password_temporarily_disabled",
|
||||
table: "groups");
|
||||
}
|
||||
}
|
||||
}
|
||||
1150
MareSynchronosServer/MareSynchronosShared/Migrations/20251102085639_McdfShare.Designer.cs
generated
Normal file
1150
MareSynchronosServer/MareSynchronosShared/Migrations/20251102085639_McdfShare.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MareSynchronosServer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class McdfShare : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "mcdf_shares",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
owner_uid = table.Column<string>(type: "character varying(10)", nullable: false),
|
||||
description = table.Column<string>(type: "text", nullable: false),
|
||||
cipher_data = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||
nonce = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||
salt = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||
tag = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||
created_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
updated_utc = table.Column<DateTime?>(type: "timestamp with time zone", nullable: true),
|
||||
expires_at_utc = table.Column<DateTime?>(type: "timestamp with time zone", nullable: true),
|
||||
download_count = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_mcdf_shares", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_mcdf_shares_users_owner_uid",
|
||||
column: x => x.owner_uid,
|
||||
principalTable: "users",
|
||||
principalColumn: "uid",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "mcdf_share_allowed_groups",
|
||||
columns: table => new
|
||||
{
|
||||
share_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
allowed_group_gid = table.Column<string>(type: "character varying(20)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_mcdf_share_allowed_groups", x => new { x.share_id, x.allowed_group_gid });
|
||||
table.ForeignKey(
|
||||
name: "fk_mcdf_share_allowed_groups_mcdf_shares_share_id",
|
||||
column: x => x.share_id,
|
||||
principalTable: "mcdf_shares",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "mcdf_share_allowed_users",
|
||||
columns: table => new
|
||||
{
|
||||
share_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
allowed_individual_uid = table.Column<string>(type: "character varying(10)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_mcdf_share_allowed_users", x => new { x.share_id, x.allowed_individual_uid });
|
||||
table.ForeignKey(
|
||||
name: "fk_mcdf_share_allowed_users_mcdf_shares_share_id",
|
||||
column: x => x.share_id,
|
||||
principalTable: "mcdf_shares",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_mcdf_share_allowed_groups_allowed_group_gid",
|
||||
table: "mcdf_share_allowed_groups",
|
||||
column: "allowed_group_gid");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_mcdf_share_allowed_users_allowed_individual_uid",
|
||||
table: "mcdf_share_allowed_users",
|
||||
column: "allowed_individual_uid");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_mcdf_shares_owner_uid",
|
||||
table: "mcdf_shares",
|
||||
column: "owner_uid");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "mcdf_share_allowed_groups");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "mcdf_share_allowed_users");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "mcdf_shares");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ namespace MareSynchronosServer.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.4")
|
||||
.HasAnnotation("ProductVersion", "8.0.19")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
@@ -438,6 +438,10 @@ namespace MareSynchronosServer.Migrations
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("alias");
|
||||
|
||||
b.Property<bool>("AutoDetectVisible")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("auto_detect_visible");
|
||||
|
||||
b.Property<bool>("DisableAnimations")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("disable_animations");
|
||||
@@ -450,6 +454,10 @@ namespace MareSynchronosServer.Migrations
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("disable_vfx");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<string>("HashedPassword")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("hashed_password");
|
||||
@@ -458,10 +466,18 @@ namespace MareSynchronosServer.Migrations
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("invites_enabled");
|
||||
|
||||
b.Property<bool>("IsTemporary")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_temporary");
|
||||
|
||||
b.Property<string>("OwnerUID")
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("owner_uid");
|
||||
|
||||
b.Property<bool>("PasswordTemporarilyDisabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("password_temporarily_disabled");
|
||||
|
||||
b.HasKey("GID")
|
||||
.HasName("pk_groups");
|
||||
|
||||
@@ -615,6 +631,103 @@ namespace MareSynchronosServer.Migrations
|
||||
b.ToTable("lodestone_auth", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MareSynchronosShared.Models.McdfShare", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<byte[]>("CipherData")
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("cipher_data");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_utc");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<int>("DownloadCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("download_count");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at_utc");
|
||||
|
||||
b.Property<byte[]>("Nonce")
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("nonce");
|
||||
|
||||
b.Property<string>("OwnerUID")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("owner_uid");
|
||||
|
||||
b.Property<byte[]>("Salt")
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("salt");
|
||||
|
||||
b.Property<byte[]>("Tag")
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("tag");
|
||||
|
||||
b.Property<DateTime?>("UpdatedUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_utc");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_mcdf_shares");
|
||||
|
||||
b.HasIndex("OwnerUID")
|
||||
.HasDatabaseName("ix_mcdf_shares_owner_uid");
|
||||
|
||||
b.ToTable("mcdf_shares", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MareSynchronosShared.Models.McdfShareAllowedGroup", b =>
|
||||
{
|
||||
b.Property<Guid>("ShareId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("share_id");
|
||||
|
||||
b.Property<string>("AllowedGroupGid")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("allowed_group_gid");
|
||||
|
||||
b.HasKey("ShareId", "AllowedGroupGid")
|
||||
.HasName("pk_mcdf_share_allowed_groups");
|
||||
|
||||
b.HasIndex("AllowedGroupGid")
|
||||
.HasDatabaseName("ix_mcdf_share_allowed_groups_allowed_group_gid");
|
||||
|
||||
b.ToTable("mcdf_share_allowed_groups", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MareSynchronosShared.Models.McdfShareAllowedUser", b =>
|
||||
{
|
||||
b.Property<Guid>("ShareId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("share_id");
|
||||
|
||||
b.Property<string>("AllowedIndividualUid")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("allowed_individual_uid");
|
||||
|
||||
b.HasKey("ShareId", "AllowedIndividualUid")
|
||||
.HasName("pk_mcdf_share_allowed_users");
|
||||
|
||||
b.HasIndex("AllowedIndividualUid")
|
||||
.HasDatabaseName("ix_mcdf_share_allowed_users_allowed_individual_uid");
|
||||
|
||||
b.ToTable("mcdf_share_allowed_users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MareSynchronosShared.Models.User", b =>
|
||||
{
|
||||
b.Property<string>("UID")
|
||||
@@ -945,6 +1058,41 @@ namespace MareSynchronosServer.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MareSynchronosShared.Models.McdfShare", b =>
|
||||
{
|
||||
b.HasOne("MareSynchronosShared.Models.User", "Owner")
|
||||
.WithMany()
|
||||
.HasForeignKey("OwnerUID")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.HasConstraintName("fk_mcdf_shares_users_owner_uid");
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MareSynchronosShared.Models.McdfShareAllowedGroup", b =>
|
||||
{
|
||||
b.HasOne("MareSynchronosShared.Models.McdfShare", "Share")
|
||||
.WithMany("AllowedSyncshells")
|
||||
.HasForeignKey("ShareId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_mcdf_share_allowed_groups_mcdf_shares_share_id");
|
||||
|
||||
b.Navigation("Share");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MareSynchronosShared.Models.McdfShareAllowedUser", b =>
|
||||
{
|
||||
b.HasOne("MareSynchronosShared.Models.McdfShare", "Share")
|
||||
.WithMany("AllowedIndividuals")
|
||||
.HasForeignKey("ShareId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_mcdf_share_allowed_users_mcdf_shares_share_id");
|
||||
|
||||
b.Navigation("Share");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MareSynchronosShared.Models.UserProfileData", b =>
|
||||
{
|
||||
b.HasOne("MareSynchronosShared.Models.User", "User")
|
||||
@@ -986,6 +1134,13 @@ namespace MareSynchronosServer.Migrations
|
||||
|
||||
b.Navigation("Poses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MareSynchronosShared.Models.McdfShare", b =>
|
||||
{
|
||||
b.Navigation("AllowedIndividuals");
|
||||
|
||||
b.Navigation("AllowedSyncshells");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MareSynchronosShared.Models;
|
||||
|
||||
@@ -16,4 +17,8 @@ public class Group
|
||||
public bool DisableSounds { get; set; }
|
||||
public bool DisableAnimations { get; set; }
|
||||
public bool DisableVFX { get; set; }
|
||||
public bool IsTemporary { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public bool AutoDetectVisible { get; set; }
|
||||
public bool PasswordTemporarilyDisabled { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MareSynchronosShared.Models;
|
||||
|
||||
public class McdfShare
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
[MaxLength(10)]
|
||||
public string OwnerUID { get; set; } = string.Empty;
|
||||
public User Owner { get; set; } = null!;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public byte[] CipherData { get; set; } = Array.Empty<byte>();
|
||||
public byte[] Nonce { get; set; } = Array.Empty<byte>();
|
||||
public byte[] Salt { get; set; } = Array.Empty<byte>();
|
||||
public byte[] Tag { get; set; } = Array.Empty<byte>();
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime? UpdatedUtc { get; set; }
|
||||
public DateTime? ExpiresAtUtc { get; set; }
|
||||
public int DownloadCount { get; set; }
|
||||
public ICollection<McdfShareAllowedUser> AllowedIndividuals { get; set; } = new HashSet<McdfShareAllowedUser>();
|
||||
public ICollection<McdfShareAllowedGroup> AllowedSyncshells { get; set; } = new HashSet<McdfShareAllowedGroup>();
|
||||
}
|
||||
|
||||
public class McdfShareAllowedUser
|
||||
{
|
||||
public Guid ShareId { get; set; }
|
||||
public McdfShare Share { get; set; } = null!;
|
||||
[MaxLength(10)]
|
||||
public string AllowedIndividualUid { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class McdfShareAllowedGroup
|
||||
{
|
||||
public Guid ShareId { get; set; }
|
||||
public McdfShare Share { get; set; } = null!;
|
||||
[MaxLength(20)]
|
||||
public string AllowedGroupGid { get; set; } = string.Empty;
|
||||
}
|
||||
Reference in New Issue
Block a user