Compare commits

..

19 Commits

Author SHA1 Message Date
b231d80101 "Ajout de la gestion des états de frappe avec TypingScope et d'une nouvelle méthode UserSetTypingState." 2025-11-02 17:35:20 +01:00
f5d2a95b97 MareAPI link 2025-11-02 12:31:47 +01:00
97c009aef6 "Add MessagePack annotations to MCDF Share DTOs for serialization support" 2025-11-02 12:25:12 +01:00
8527e67af1 Hotfix Migration failed 2025-11-02 10:50:15 +01:00
073464e6c8 Update MareAPI 2025-11-02 00:26:52 +01:00
b2b9ecad7b Préparation feature 2.0 2025-11-01 22:46:55 +01:00
e484616ecd Fix TypingDetection 2025-10-04 09:59:30 +02:00
331cb612ad Préparation serveur pour TypingState 2025-09-28 22:32:05 +02:00
f696c1d400 Ajout healthCheck pour le serveur Auth 2025-09-28 10:57:45 +02:00
cd5248e4e6 Fix delet syncshell 2025-09-20 12:13:45 +02:00
79ff8fad7a Mise à jour deploy perma/temp syncshell 2025-09-20 09:51:39 +02:00
f9df60dc88 Ajout des syncshell perma & temporaire 2025-09-20 09:02:53 +02:00
74dac9f506 Enforcing Unique groupe aliases 2025-09-19 23:25:20 +02:00
54e66b2387 Reverse statut & Fix Server Salt Time & Clean Code & Update API 2025-09-19 23:14:58 +02:00
6852869313 Fix TTL 2025-09-13 20:08:52 +02:00
693d3c6af7 Deploy AutoDetect 2025-09-13 13:40:32 +02:00
8d0c3aafb3 Fix AutoDetect 2025-09-11 22:07:46 +02:00
5366cfbc60 Support AutoDetect 2025-09-11 15:43:11 +02:00
c23a43c84c Update MareAPI submodule to origin/main 2025-09-05 12:10:09 +02:00
33 changed files with 4210 additions and 58 deletions

2
.gitmodules vendored
View File

@@ -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

Submodule MareAPI updated: fb71135026...d105d20507

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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; }
}
}

View File

@@ -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)
{

View File

@@ -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
}
}

View File

@@ -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();
}
}

View File

@@ -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");

View File

@@ -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
{

View File

@@ -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")]

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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))
{

View File

@@ -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" />

View File

@@ -57,6 +57,7 @@ public class SystemInfoService : IHostedService, IDisposable
SystemInfoDto = new SystemInfoDto()
{
OnlineUsers = onlineUsers,
SupportsTypingState = true,
};
if (_config.IsMain)

View File

@@ -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

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -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
}
}

View File

@@ -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; }
}

View File

@@ -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;
}