Eerste commit
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
using System.Collections.Concurrent;
|
||||
using MijnBesluit.Application.Abstractions;
|
||||
|
||||
namespace MijnBesluit.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Waar een bijlage bij hoort. Hoogstens één van de drie is gevuld; alle drie leeg mag ook,
|
||||
/// dan staat het bestand op zichzelf - zoals een video-opname die pas bij het opslaan aan een
|
||||
/// bericht wordt gehangen.
|
||||
/// </summary>
|
||||
public record Bijlagedoel(long? KluisItemId = null, long? PersoonId = null, long? BerichtId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Houdt bestanden bij die in stukken binnenkomen of weggaan.
|
||||
///
|
||||
/// Waarom in stukken: één socketbericht is maximaal 16 MB, en de inhoud gaat als tekst
|
||||
/// (base64) mee, wat er nog een derde bij optelt. Een foto van de telefoon past daar vaak net
|
||||
/// in, een video nooit. Dus knippen we het op, met een volgnummer erbij zodat de ontvanger
|
||||
/// merkt als er een stuk mist.
|
||||
///
|
||||
/// Onvoltooide overdrachten worden na een half uur opgeruimd. Zonder dat zou een app die
|
||||
/// halverwege wegvalt geheugen laten staan tot de service herstart.
|
||||
/// </summary>
|
||||
public class Bestandsoverdracht
|
||||
{
|
||||
/// <summary>Hoeveel bytes er per stuk over de lijn gaan. Base64 maakt er ruim 680 kB van.</summary>
|
||||
public const int StukGrootte = 512 * 1024;
|
||||
|
||||
/// <summary>Zo lang blijft een halve overdracht bewaard voordat hij wordt weggegooid.</summary>
|
||||
public static readonly TimeSpan Verlooptijd = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>Zoveel overdrachten tegelijk per gebruiker; meer is geen gebruik maar misbruik.</summary>
|
||||
public const int MaxPerGebruiker = 3;
|
||||
|
||||
private readonly ConcurrentDictionary<string, Overdracht> _lopend = new();
|
||||
private readonly IClock _clock;
|
||||
|
||||
public Bestandsoverdracht(IClock clock) => _clock = clock;
|
||||
|
||||
private sealed class Overdracht
|
||||
{
|
||||
public required Bijlagedoel? Doel { get; init; }
|
||||
public required string Id { get; init; }
|
||||
public required long GebruikerId { get; init; }
|
||||
public required string Bestandsnaam { get; init; }
|
||||
public required string Soort { get; init; }
|
||||
public required long TotaalBytes { get; init; }
|
||||
public required MemoryStream Inhoud { get; init; }
|
||||
public int VolgendStuk { get; set; }
|
||||
public DateTime Begonnen { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Begint een nieuwe overdracht en geeft het kenmerk terug waarmee de stukken komen.</summary>
|
||||
public string Begin(long gebruikerId, string bestandsnaam, string soort, Bijlagedoel? doel, long totaalBytes)
|
||||
{
|
||||
RuimOp();
|
||||
|
||||
if (_lopend.Values.Count(o => o.GebruikerId == gebruikerId) >= MaxPerGebruiker)
|
||||
throw new InvalidOperationException("Er lopen al te veel uploads tegelijk. Maak er eerst een af.");
|
||||
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
_lopend[id] = new Overdracht
|
||||
{
|
||||
Id = id,
|
||||
GebruikerId = gebruikerId,
|
||||
Bestandsnaam = bestandsnaam,
|
||||
Soort = soort,
|
||||
Doel = doel,
|
||||
TotaalBytes = totaalBytes,
|
||||
Inhoud = new MemoryStream(capacity: (int)Math.Min(totaalBytes, int.MaxValue)),
|
||||
Begonnen = _clock.UtcNow
|
||||
};
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>Neemt één stuk aan. Het volgnummer moet kloppen, anders is er iets kwijtgeraakt.</summary>
|
||||
public void Stuk(string id, long gebruikerId, int volgnummer, byte[] inhoud)
|
||||
{
|
||||
var overdracht = Zoek(id, gebruikerId);
|
||||
|
||||
if (volgnummer != overdracht.VolgendStuk)
|
||||
throw new InvalidOperationException(
|
||||
$"Stuk {volgnummer} kwam binnen terwijl {overdracht.VolgendStuk} aan de beurt was. Begin opnieuw.");
|
||||
|
||||
if (overdracht.Inhoud.Length + inhoud.Length > overdracht.TotaalBytes)
|
||||
throw new InvalidOperationException("Er komt meer binnen dan aangekondigd.");
|
||||
|
||||
overdracht.Inhoud.Write(inhoud, 0, inhoud.Length);
|
||||
overdracht.VolgendStuk++;
|
||||
}
|
||||
|
||||
/// <summary>Sluit de overdracht af en geeft de volledige inhoud terug.</summary>
|
||||
public (string Bestandsnaam, string Soort, Bijlagedoel? Doel, byte[] Inhoud) Klaar(string id, long gebruikerId)
|
||||
{
|
||||
var overdracht = Zoek(id, gebruikerId);
|
||||
|
||||
if (overdracht.Inhoud.Length != overdracht.TotaalBytes)
|
||||
throw new InvalidOperationException(
|
||||
$"Er kwam {overdracht.Inhoud.Length} byte binnen van de aangekondigde {overdracht.TotaalBytes}.");
|
||||
|
||||
_lopend.TryRemove(id, out _);
|
||||
return (overdracht.Bestandsnaam, overdracht.Soort, overdracht.Doel, overdracht.Inhoud.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Breekt een overdracht af, bijvoorbeeld als de gebruiker annuleert.</summary>
|
||||
public void Afbreken(string id, long gebruikerId)
|
||||
{
|
||||
if (_lopend.TryGetValue(id, out var overdracht) && overdracht.GebruikerId == gebruikerId)
|
||||
{
|
||||
_lopend.TryRemove(id, out _);
|
||||
overdracht.Inhoud.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Aantal overdrachten dat nu loopt. Alleen om te kunnen controleren.</summary>
|
||||
public int Lopend => _lopend.Count;
|
||||
|
||||
private Overdracht Zoek(string id, long gebruikerId)
|
||||
{
|
||||
if (!_lopend.TryGetValue(id, out var overdracht))
|
||||
throw new InvalidOperationException("Deze upload is niet (meer) bekend. Begin opnieuw.");
|
||||
|
||||
// Van iemand anders? Dan bestaat hij voor jou niet.
|
||||
if (overdracht.GebruikerId != gebruikerId)
|
||||
throw new InvalidOperationException("Deze upload is niet (meer) bekend. Begin opnieuw.");
|
||||
|
||||
if (_clock.UtcNow - overdracht.Begonnen > Verlooptijd)
|
||||
{
|
||||
_lopend.TryRemove(id, out _);
|
||||
throw new InvalidOperationException("Deze upload heeft te lang stilgelegen. Begin opnieuw.");
|
||||
}
|
||||
|
||||
return overdracht;
|
||||
}
|
||||
|
||||
private void RuimOp()
|
||||
{
|
||||
var nu = _clock.UtcNow;
|
||||
foreach (var (id, overdracht) in _lopend)
|
||||
{
|
||||
if (nu - overdracht.Begonnen <= Verlooptijd) continue;
|
||||
if (_lopend.TryRemove(id, out var weg)) weg.Inhoud.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MijnBesluit.Application\MijnBesluit.Application.csproj" />
|
||||
<ProjectReference Include="..\MijnBesluit.Protocol\MijnBesluit.Protocol.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,225 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using MijnBesluit.Application.Abstractions;
|
||||
using MijnBesluit.Application.Security;
|
||||
|
||||
namespace MijnBesluit.Server;
|
||||
|
||||
/// <summary>Een ingelogde sessie van de app.</summary>
|
||||
public sealed class Session
|
||||
{
|
||||
public required string Token { get; init; }
|
||||
public required long UserId { get; init; }
|
||||
public required long PersonId { get; init; }
|
||||
public string? Device { get; init; }
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tot wanneer de database denkt dat deze sessie loopt. Zie <see cref="SessionStore"/>: de
|
||||
/// vervaltijd schuift bij elk verzoek op, maar wordt niet bij elk verzoek weggeschreven.
|
||||
/// </summary>
|
||||
internal DateTime OpgeslagenTot { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Wat er van een bewaarde sessie terugkomt. Het token staat er niet in - dat kent alleen het toestel.</summary>
|
||||
public sealed record Sessiegegevens(long UserId, long PersonId, string? Toestel,
|
||||
DateTime AangemaaktOp, DateTime VerlooptOp);
|
||||
|
||||
/// <summary>
|
||||
/// Waar sessies blijven staan als de service opnieuw start.
|
||||
///
|
||||
/// Deze laag krijgt nooit een token te zien, alleen de hash ervan: ze hoeft het token niet te
|
||||
/// kennen en hoort het dus ook niet te krijgen.
|
||||
/// </summary>
|
||||
public interface ISessiebewaring
|
||||
{
|
||||
Task BewaarAsync(byte[] hash, Sessiegegevens sessie, DateTime nu, CancellationToken ct = default);
|
||||
Task<Sessiegegevens?> ZoekAsync(byte[] hash, CancellationToken ct = default);
|
||||
Task SchuifOpAsync(byte[] hash, DateTime verlooptOp, DateTime nu, CancellationToken ct = default);
|
||||
Task VerwijderAsync(byte[] hash, CancellationToken ct = default);
|
||||
Task VerwijderAllesVanAsync(long userId, CancellationToken ct = default);
|
||||
Task<int> RuimVerlopenOpAsync(DateTime nu, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// De sessies van de SocketServer: in het geheugen om snel te zijn, en in de database om een
|
||||
/// herstart te overleven.
|
||||
///
|
||||
/// Waarom dat tweede erbij is gekomen: de app blijft aangemeld en gaat daarna open met een
|
||||
/// vingerafdruk of de code van de telefoon. Stonden de sessies alleen in het geheugen, dan was
|
||||
/// elke herstart van de service genoeg om iedereen weer een code per e-mail te laten opvragen -
|
||||
/// een uitrol, een Windows-update 's nachts, een reboot van de server. Het toestel wist dan nog
|
||||
/// wie je was, de server niet meer, en juist dat zou de vingerafdruk moeten voorkomen.
|
||||
///
|
||||
/// Het geheugen blijft ervoor zorgen dat een gewoon verzoek geen database aanraakt. De database
|
||||
/// wordt pas geraadpleegd bij een token dat we niet in het geheugen hebben - dus feitelijk bij
|
||||
/// het eerste verzoek van elk toestel na een herstart.
|
||||
///
|
||||
/// Wat er bewaard wordt is de SHA-256 van het token, nooit het token zelf. Wie de database in
|
||||
/// handen krijgt, krijgt dus geen werkende sessies mee.
|
||||
///
|
||||
/// Zonder bewaring werkt alles gewoon, alleen zijn de sessies dan weg na een herstart. Zo hoeven
|
||||
/// de zelftests geen database te hebben.
|
||||
/// </summary>
|
||||
public class SessionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, Session> _sessions = new();
|
||||
private readonly IClock _clock;
|
||||
private readonly TimeSpan _lifetime;
|
||||
private readonly ISessiebewaring? _bewaring;
|
||||
|
||||
/// <summary>
|
||||
/// Hoeveel de vervaltijd mag zijn opgeschoven voordat we dat ook echt wegschrijven.
|
||||
///
|
||||
/// Elk verzoek schuift de termijn op. Dat ook elke keer opslaan zou een schrijfactie per
|
||||
/// aanroep betekenen, voor iets waar bij een termijn van veertien dagen een uur speling
|
||||
/// niets uitmaakt. Het ergste geval is dat een sessie na een herstart een uur eerder
|
||||
/// verloopt dan hij had gemogen.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan SchuifopDrempel = TimeSpan.FromHours(1);
|
||||
|
||||
public SessionStore(IClock clock, TimeSpan? lifetime = null, ISessiebewaring? bewaring = null)
|
||||
{
|
||||
_clock = clock;
|
||||
_lifetime = lifetime ?? TimeSpan.FromDays(14);
|
||||
_bewaring = bewaring;
|
||||
}
|
||||
|
||||
/// <summary>De hash waaronder een token in de database staat.</summary>
|
||||
public static byte[] Hash(string token) => SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
|
||||
public async Task<Session> CreateAsync(long userId, long personId, string? device,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var now = _clock.UtcNow;
|
||||
var session = new Session
|
||||
{
|
||||
Token = TokenGenerator.Create(48),
|
||||
UserId = userId,
|
||||
PersonId = personId,
|
||||
Device = device,
|
||||
CreatedAt = now,
|
||||
ExpiresAt = now.Add(_lifetime),
|
||||
OpgeslagenTot = now.Add(_lifetime)
|
||||
};
|
||||
_sessions[session.Token] = session;
|
||||
|
||||
if (_bewaring is not null)
|
||||
{
|
||||
await _bewaring.BewaarAsync(Hash(session.Token),
|
||||
new Sessiegegevens(userId, personId, device, now, session.ExpiresAt), now, ct);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zoekt de sessie bij een token en schuift de vervaltijd op. Null als het token niet (meer)
|
||||
/// geldig is.
|
||||
///
|
||||
/// Kennen we het token niet uit het geheugen, dan kijken we in de database. Dat is het geval
|
||||
/// waar deze hele tabel voor bestaat: het eerste verzoek van een toestel nadat de service
|
||||
/// opnieuw is gestart.
|
||||
///
|
||||
/// Gaat de database niet, dan komt die fout hier naar buiten en wordt hij niet stilzwijgend
|
||||
/// een "je sessie is verlopen". Dat verschil is belangrijk: bij dat laatste gooit de app het
|
||||
/// token weg en moet iemand een code uit zijn mail halen terwijl er niets mis was met zijn
|
||||
/// sessie.
|
||||
/// </summary>
|
||||
public async Task<Session?> ValidateAsync(string? token, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return null;
|
||||
|
||||
var now = _clock.UtcNow;
|
||||
|
||||
if (!_sessions.TryGetValue(token, out var session))
|
||||
{
|
||||
session = await OphalenAsync(token, now, ct);
|
||||
if (session is null) return null;
|
||||
}
|
||||
|
||||
if (session.ExpiresAt <= now)
|
||||
{
|
||||
_sessions.TryRemove(token, out _);
|
||||
if (_bewaring is not null) await _bewaring.VerwijderAsync(Hash(token), ct);
|
||||
return null;
|
||||
}
|
||||
|
||||
session.ExpiresAt = now.Add(_lifetime);
|
||||
|
||||
// Niet bij elk verzoek naar de database; zie SchuifopDrempel.
|
||||
if (_bewaring is not null && session.ExpiresAt - session.OpgeslagenTot > SchuifopDrempel)
|
||||
{
|
||||
session.OpgeslagenTot = session.ExpiresAt;
|
||||
await _bewaring.SchuifOpAsync(Hash(token), session.ExpiresAt, now, ct);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>Haalt een sessie uit de database terug in het geheugen.</summary>
|
||||
private async Task<Session?> OphalenAsync(string token, DateTime now, CancellationToken ct)
|
||||
{
|
||||
if (_bewaring is null) return null;
|
||||
|
||||
var bewaard = await _bewaring.ZoekAsync(Hash(token), ct);
|
||||
if (bewaard is null) return null;
|
||||
|
||||
if (bewaard.VerlooptOp <= now)
|
||||
{
|
||||
await _bewaring.VerwijderAsync(Hash(token), ct);
|
||||
return null;
|
||||
}
|
||||
|
||||
var session = new Session
|
||||
{
|
||||
Token = token,
|
||||
UserId = bewaard.UserId,
|
||||
PersonId = bewaard.PersonId,
|
||||
Device = bewaard.Toestel,
|
||||
CreatedAt = bewaard.AangemaaktOp,
|
||||
ExpiresAt = bewaard.VerlooptOp,
|
||||
OpgeslagenTot = bewaard.VerlooptOp
|
||||
};
|
||||
|
||||
_sessions[token] = session;
|
||||
return session;
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(string? token, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return;
|
||||
|
||||
_sessions.TryRemove(token, out _);
|
||||
if (_bewaring is not null) await _bewaring.VerwijderAsync(Hash(token), ct);
|
||||
}
|
||||
|
||||
/// <summary>Beëindigt alle sessies van één gebruiker, bijvoorbeeld bij het opheffen van een account.</summary>
|
||||
public async Task RemoveAllForAsync(long userId, CancellationToken ct = default)
|
||||
{
|
||||
foreach (var kv in _sessions.Where(kv => kv.Value.UserId == userId))
|
||||
_sessions.TryRemove(kv.Key, out _);
|
||||
|
||||
if (_bewaring is not null) await _bewaring.VerwijderAllesVanAsync(userId, ct);
|
||||
}
|
||||
|
||||
/// <summary>Ruimt verlopen sessies op; draaien als achtergrondtaak.</summary>
|
||||
public async Task<int> SweepAsync(CancellationToken ct = default)
|
||||
{
|
||||
var now = _clock.UtcNow;
|
||||
var removed = 0;
|
||||
foreach (var kv in _sessions.Where(kv => kv.Value.ExpiresAt <= now))
|
||||
if (_sessions.TryRemove(kv.Key, out _)) removed++;
|
||||
|
||||
var uitDeDatabase = _bewaring is not null ? await _bewaring.RuimVerlopenOpAsync(now, ct) : 0;
|
||||
|
||||
// Niet optellen: dezelfde sessie staat meestal op allebei de plekken. Het getal is
|
||||
// alleen voor het logboek, en dan is het grootste van de twee het eerlijkste.
|
||||
return Math.Max(removed, uitDeDatabase);
|
||||
}
|
||||
|
||||
/// <summary>Hoeveel sessies er op dit moment in het geheugen staan. Alleen om te tonen.</summary>
|
||||
public int Count => _sessions.Count;
|
||||
}
|
||||
@@ -0,0 +1,925 @@
|
||||
using MijnBesluit.Application.Models;
|
||||
using MijnBesluit.Application.Services;
|
||||
using MijnBesluit.Domain.Enums;
|
||||
using MijnBesluit.Protocol;
|
||||
|
||||
namespace MijnBesluit.Server;
|
||||
|
||||
/// <summary>Alles wat één verzoek nodig heeft. De host levert dit per verzoek aan, met een verse databasecontext.</summary>
|
||||
public class RequestScope
|
||||
{
|
||||
public required AccountService Accounts { get; init; }
|
||||
public required FamilyService Families { get; init; }
|
||||
public required VaultService Vault { get; init; }
|
||||
public required MessageService Messages { get; init; }
|
||||
public required LegacyService Legacy { get; init; }
|
||||
public required OverzichtService Overzicht { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Voor het samenvoegen van dubbele profielen. Optioneel: zonder deze dienst werkt alles,
|
||||
/// alleen wordt een tweede uitnodiging dan geweigerd in plaats van samengevoegd.
|
||||
/// </summary>
|
||||
public SamenvoegService? Samenvoegen { get; init; }
|
||||
|
||||
/// <summary>Optioneel: zonder meldingen werkt de rest gewoon.</summary>
|
||||
public MeldingService? Meldingen { get; init; }
|
||||
|
||||
/// <summary>Optioneel: zonder deze dienst komt er simpelweg geen advertentie op het overzicht.</summary>
|
||||
public AdvertentieService? Adverteren { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Het opheffen van een account. Optioneel, zodat een server zonder deze dienst gewoon draait;
|
||||
/// de knop in de app krijgt dan een nette melding in plaats van een foutpagina.
|
||||
/// </summary>
|
||||
public AccountOpheffenService? Opheffen { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vertaalt een binnengekomen socketverzoek naar een aanroep van de Application-laag.
|
||||
/// Alle regels zitten in die laag, niet hier: de website en de app dwingen dus precies
|
||||
/// dezelfde dingen af, en er is maar één plek waar iets fout kan gaan.
|
||||
/// </summary>
|
||||
public class SocketDispatcher
|
||||
{
|
||||
private readonly SessionStore _sessions;
|
||||
private readonly Func<Func<RequestScope, Task<SocketResponse>>, Task<SocketResponse>> _withScope;
|
||||
private readonly Bestandsoverdracht _overdracht;
|
||||
private readonly Action<string, long>? _traagVerzoek;
|
||||
|
||||
/// <summary>Vanaf hoeveel milliseconden we een verzoek traag noemen en dat melden.</summary>
|
||||
public const int TraagVanafMs = 2000;
|
||||
|
||||
/// <param name="traagVerzoek">
|
||||
/// Wordt aangeroepen met de actie en de duur zodra een verzoek langer duurt dan
|
||||
/// <see cref="TraagVanafMs"/>. Hiermee komt in het logboek te staan wélke actie traag was;
|
||||
/// zonder dat staar je naar een gebruiker die wacht en weet je niet waar het zit.
|
||||
/// Optioneel, zodat de zelftest hem niet hoeft mee te geven.
|
||||
/// </param>
|
||||
public SocketDispatcher(SessionStore sessions,
|
||||
Func<Func<RequestScope, Task<SocketResponse>>, Task<SocketResponse>> withScope,
|
||||
Bestandsoverdracht? overdracht = null,
|
||||
Action<string, long>? traagVerzoek = null)
|
||||
{
|
||||
_sessions = sessions;
|
||||
_withScope = withScope;
|
||||
_overdracht = overdracht ?? new Bestandsoverdracht(new SysteemKlok());
|
||||
_traagVerzoek = traagVerzoek;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hoe een bijlage over de lijn gaat. Eén plek, zodat de lijst bij een kluisitem, bij een
|
||||
/// persoon en bij een bericht er precies hetzelfde uitziet en de app er één model voor heeft.
|
||||
/// </summary>
|
||||
private static object Bijlagen(IEnumerable<MijnBesluit.Domain.Entities.VaultFile> bestanden) =>
|
||||
bestanden.Select(f => new
|
||||
{
|
||||
f.Id, f.FileName, f.ContentType, f.SizeBytes, f.CreatedAt, f.IsProfilePhoto
|
||||
});
|
||||
|
||||
/// <summary>Voor als er geen klok wordt meegegeven; de overdracht heeft er wel een nodig.</summary>
|
||||
private sealed class SysteemKlok : MijnBesluit.Application.Abstractions.IClock
|
||||
{
|
||||
public DateTime UtcNow => DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>Acties die zonder geldige sessie mogen.</summary>
|
||||
private static readonly HashSet<string> Anonymous = new()
|
||||
{
|
||||
SocketActions.Ping, SocketActions.RequestLoginCode, SocketActions.SignIn,
|
||||
SocketActions.RequestRegisterCode, SocketActions.Register, SocketActions.AcceptInvitation
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Alle acties die deze versie van de server kent, rechtstreeks uit <see cref="SocketActions"/>.
|
||||
/// Zo kan er geen actie bijkomen die hier vergeten wordt.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> Bekend = typeof(SocketActions)
|
||||
.GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
|
||||
.Select(f => (string)f.GetRawConstantValue()!)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
public async Task<SocketResponse> HandleAsync(SocketRequest request, SocketConnection connection, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Action))
|
||||
return SocketResponse.Error(request.RequestId, "Geen actie opgegeven.");
|
||||
|
||||
// Eerst kijken of we de actie überhaupt kennen, en pas daarna naar de sessie. Andersom
|
||||
// krijgt een app die nieuwer is dan de server "log opnieuw in" te zien, en gaat iemand
|
||||
// uren zoeken in de verkeerde hoek. Nu staat er meteen dat de server de actie niet kent.
|
||||
if (!Bekend.Contains(request.Action))
|
||||
return SocketResponse.Error(request.RequestId, $"Onbekende actie '{request.Action}'.");
|
||||
|
||||
Session? session = null;
|
||||
if (!Anonymous.Contains(request.Action))
|
||||
{
|
||||
session = await _sessions.ValidateAsync(request.Token, ct);
|
||||
if (session is null)
|
||||
return SocketResponse.Error(request.RequestId, "Je sessie is verlopen. Log opnieuw in.",
|
||||
SocketFoutcodes.SessieVerlopen);
|
||||
connection.UserId = session.UserId;
|
||||
}
|
||||
|
||||
var klok = System.Diagnostics.Stopwatch.StartNew();
|
||||
var antwoord = await _withScope(scope => DispatchAsync(request, session, connection, scope, ct));
|
||||
klok.Stop();
|
||||
|
||||
if (klok.ElapsedMilliseconds > TraagVanafMs)
|
||||
_traagVerzoek?.Invoke(request.Action, klok.ElapsedMilliseconds);
|
||||
|
||||
return antwoord;
|
||||
}
|
||||
|
||||
private async Task<SocketResponse> DispatchAsync(SocketRequest request, Session? session,
|
||||
SocketConnection connection, RequestScope scope,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var id = request.RequestId;
|
||||
|
||||
switch (request.Action)
|
||||
{
|
||||
case SocketActions.Ping:
|
||||
return SocketResponse.Ok(id, new { tijd = DateTime.UtcNow, versleuteld = connection.IsEncrypted });
|
||||
|
||||
case SocketActions.RequestLoginCode:
|
||||
{
|
||||
var p = request.PayloadAs<EmailPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Vul je e-mailadres in.");
|
||||
|
||||
var r = await scope.Accounts.VraagInlogcodeAanAsync(p.Email, connection.RemoteAddress, ct);
|
||||
return r.Succeeded
|
||||
? SocketResponse.Ok(id, new { verstuurdNaar = r.Value!.AfgeschermdAdres, geldigTot = r.Value.GeldigTot })
|
||||
: SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.SignIn:
|
||||
{
|
||||
var p = request.PayloadAs<SignInPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Vul je e-mailadres en de code in.");
|
||||
|
||||
var result = await scope.Accounts.LogInMetCodeAsync(p.Email, p.Code, ct);
|
||||
if (!result.Succeeded) return SocketResponse.Error(id, result.Error!);
|
||||
|
||||
var s = await _sessions.CreateAsync(result.Value!.Id, result.Value.PersonId, request.Device, ct);
|
||||
connection.UserId = s.UserId;
|
||||
return SocketResponse.Ok(id, new { token = s.Token, verlooptOp = s.ExpiresAt, persoonId = s.PersonId });
|
||||
}
|
||||
|
||||
case SocketActions.SignOut:
|
||||
await _sessions.RemoveAsync(request.Token, ct);
|
||||
connection.UserId = null;
|
||||
return SocketResponse.Ok(id);
|
||||
|
||||
case SocketActions.RequestRegisterCode:
|
||||
{
|
||||
var p = request.PayloadAs<RegisterPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Niet alle gegevens zijn ingevuld.");
|
||||
|
||||
var r = await scope.Accounts.VraagRegistratiecodeAanAsync(p.Email, p.FirstName, p.LastName,
|
||||
connection.RemoteAddress, ct);
|
||||
return r.Succeeded
|
||||
? SocketResponse.Ok(id, new { verstuurdNaar = r.Value!.AfgeschermdAdres, geldigTot = r.Value.GeldigTot })
|
||||
: SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.Register:
|
||||
{
|
||||
var p = request.PayloadAs<SignInPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Vul je e-mailadres en de code in.");
|
||||
|
||||
var result = await scope.Accounts.RegistreerMetCodeAsync(p.Email, p.Code, ct);
|
||||
if (!result.Succeeded) return SocketResponse.Error(id, result.Error!);
|
||||
|
||||
var s = await _sessions.CreateAsync(result.Value!.Id, result.Value.PersonId, request.Device, ct);
|
||||
connection.UserId = s.UserId;
|
||||
return SocketResponse.Ok(id, new { token = s.Token, persoonId = s.PersonId });
|
||||
}
|
||||
|
||||
case SocketActions.AcceptInvitation:
|
||||
{
|
||||
var p = request.PayloadAs<AcceptInvitationPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen uitnodiging meegegeven.");
|
||||
|
||||
// Is er een geldige sessie meegestuurd, dan neemt iemand die hier al is een
|
||||
// tweede uitnodiging aan. Dan voegen we samen in plaats van een tweede account
|
||||
// te maken. Deze actie mag ook zonder sessie, dus dit mag niet hard mislukken.
|
||||
var ingelogd = (await _sessions.ValidateAsync(request.Token, ct))?.UserId;
|
||||
|
||||
var result = await scope.Families.AcceptInvitationAsync(
|
||||
p.Token, scope.Accounts, ct, ingelogd, scope.Samenvoegen);
|
||||
if (!result.Succeeded) return SocketResponse.Error(id, result.Error!);
|
||||
|
||||
var s = await _sessions.CreateAsync(result.Value!.Id, result.Value.PersonId, request.Device, ct);
|
||||
return SocketResponse.Ok(id, new { token = s.Token, persoonId = s.PersonId });
|
||||
}
|
||||
|
||||
case SocketActions.OwnProfile:
|
||||
{
|
||||
var r = await scope.Accounts.EigenProfielAsync(session!.UserId, ct);
|
||||
if (!r.Succeeded) return SocketResponse.Error(id, r.Error!);
|
||||
|
||||
// Je eigen kleine profielfoto gaat mee. De app toont hem op meer schermen dan
|
||||
// alleen je profiel - in de kluis staat hij als tekentje bij "Alleen voor
|
||||
// mijzelf" - en dit antwoord komt er bij het opstarten toch al. Een eigen ronde
|
||||
// langs de server voor een plaatje van veertig pixels zou meer kosten dan het
|
||||
// plaatje zelf.
|
||||
var fotos = await KleineFotosAsync(scope, new[] { r.Value!.PersoonId }, session.UserId, ct);
|
||||
fotos.TryGetValue(r.Value.PersoonId, out var klein);
|
||||
|
||||
return SocketResponse.Ok(id, new
|
||||
{
|
||||
r.Value.PersoonId,
|
||||
r.Value.Voornaam,
|
||||
r.Value.Achternaam,
|
||||
r.Value.Email,
|
||||
r.Value.Geboortedatum,
|
||||
r.Value.LidSinds,
|
||||
profielfoto = klein
|
||||
});
|
||||
}
|
||||
|
||||
case SocketActions.SaveOwnProfile:
|
||||
{
|
||||
var p = request.PayloadAs<OwnProfilePayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen gegevens meegegeven.");
|
||||
|
||||
var r = await scope.Accounts.WijzigEigenProfielAsync(
|
||||
session!.UserId, p.FirstName, p.LastName, p.BirthDate, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { gelukt = true })
|
||||
: SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
// ------------------------------------------------ aantekeningen over mij
|
||||
//
|
||||
// "Heeft iemand een profiel voor mij aangemaakt?" Het adres komt uit de sessie en
|
||||
// niet uit het verzoek: je vraagt dit alleen over jezelf, en dan hoort er ook geen
|
||||
// adres meegestuurd te worden dat iemand kan veranderen. Zie docs/Identiteit.md.
|
||||
case SocketActions.NotesAboutMe:
|
||||
{
|
||||
var lijst = await scope.Accounts.AantekeningenOverMijAsync(session!.UserId, ct);
|
||||
return SocketResponse.Ok(id, lijst.Select(a => new
|
||||
{
|
||||
persoonId = a.PersoonId, van = a.Van, relatie = a.Relatie, aangemaaktOp = a.AangemaaktOp
|
||||
}));
|
||||
}
|
||||
|
||||
case SocketActions.AcceptNote:
|
||||
{
|
||||
if (scope.Samenvoegen is null) return SocketResponse.Error(id, "Dit kan hier niet.");
|
||||
|
||||
var p = request.PayloadAs<PersonIdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen profiel meegegeven.");
|
||||
|
||||
var r = await scope.Accounts.NeemAantekeningOverAsync(p.PersonId, session!.UserId,
|
||||
scope.Samenvoegen, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.DeclineNote:
|
||||
{
|
||||
var p = request.PayloadAs<PersonIdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen profiel meegegeven.");
|
||||
|
||||
var r = await scope.Accounts.WijsAantekeningAfAsync(p.PersonId, session!.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.DeleteAccountSummary:
|
||||
{
|
||||
if (scope.Opheffen is null) return SocketResponse.Error(id, "Dit kan hier niet.");
|
||||
|
||||
var r = await scope.Opheffen.TelOpAsync(session!.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, r.Value) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.RequestDeleteAccount:
|
||||
{
|
||||
// Het adres komt uit de sessie en niet uit het verzoek: je kunt alleen je eigen
|
||||
// account opheffen, en dan hoort er ook geen adres meegestuurd te worden dat
|
||||
// iemand kan veranderen.
|
||||
var ik = await scope.Accounts.EigenProfielAsync(session!.UserId, ct);
|
||||
if (!ik.Succeeded) return SocketResponse.Error(id, ik.Error!);
|
||||
|
||||
var r = await scope.Accounts.VraagOpheffencodeAanAsync(ik.Value!.Email, null, ct);
|
||||
return r.Succeeded
|
||||
? SocketResponse.Ok(id, new { verstuurdNaar = r.Value!.AfgeschermdAdres, geldigTot = r.Value.GeldigTot })
|
||||
: SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.ConfirmDeleteAccount:
|
||||
{
|
||||
if (scope.Opheffen is null) return SocketResponse.Error(id, "Dit kan hier niet.");
|
||||
|
||||
var p = request.PayloadAs<CodePayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen code meegegeven.");
|
||||
|
||||
var ik = await scope.Accounts.EigenProfielAsync(session!.UserId, ct);
|
||||
if (!ik.Succeeded) return SocketResponse.Error(id, ik.Error!);
|
||||
|
||||
var controle = await scope.Accounts.ControleerOpheffencodeAsync(ik.Value!.Email, p.Code, ct);
|
||||
if (!controle.Succeeded) return SocketResponse.Error(id, controle.Error!);
|
||||
if (controle.Value!.Id != session.UserId)
|
||||
return SocketResponse.Error(id, "Deze code hoort niet bij dit account.");
|
||||
|
||||
var weg = await scope.Opheffen.VerwijderAsync(session.UserId, ct);
|
||||
if (!weg.Succeeded) return SocketResponse.Error(id, weg.Error!);
|
||||
|
||||
// Alle sessies van dit account eraf - ook die op andere toestellen. Er is niets
|
||||
// meer om mee te werken, en een openstaande sessie op een account dat niet meer
|
||||
// bestaat geeft bij elk volgend verzoek een fout waar niemand iets van begrijpt.
|
||||
await _sessions.RemoveAllForAsync(session.UserId, ct);
|
||||
|
||||
return SocketResponse.Ok(id, new { gelukt = true });
|
||||
}
|
||||
|
||||
case SocketActions.RequestEmailChange:
|
||||
{
|
||||
var p = request.PayloadAs<EmailPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen e-mailadres meegegeven.");
|
||||
|
||||
var r = await scope.Accounts.VraagAdreswijzigingAanAsync(session!.UserId, p.Email, null, ct);
|
||||
return r.Succeeded
|
||||
? SocketResponse.Ok(id, new { verstuurdNaar = r.Value!.AfgeschermdAdres, geldigTot = r.Value.GeldigTot })
|
||||
: SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.ConfirmEmailChange:
|
||||
{
|
||||
var p = request.PayloadAs<SignInPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen code meegegeven.");
|
||||
|
||||
var r = await scope.Accounts.BevestigAdreswijzigingAsync(session!.UserId, p.Email, p.Code, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { gelukt = true })
|
||||
: SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.Overview:
|
||||
{
|
||||
var r = await scope.Overzicht.VoorAsync(session!.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, r.Value) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.SetLookahead:
|
||||
{
|
||||
var p = request.PayloadAs<LookaheadPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen aantal dagen meegegeven.");
|
||||
var r = await scope.Overzicht.VooruitblikAsync(session!.UserId, p.Days, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { dagen = p.Days })
|
||||
: SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.ListFamilies:
|
||||
{
|
||||
// De app weet bij het opstarten nog niet welke familie van wie is; hiermee
|
||||
// haalt hij dat op, inclusief de families waar hij alleen lid van is.
|
||||
var eigen = await scope.Families.ListOwnedAsync(session!.PersonId, ct);
|
||||
var lid = await scope.Families.ListMemberOfAsync(session.PersonId, ct);
|
||||
|
||||
return SocketResponse.Ok(id, eigen
|
||||
.Select(f => new { f.Id, f.Name, eigenaar = true })
|
||||
.Concat(lid.Where(f => eigen.All(e => e.Id != f.Id))
|
||||
.Select(f => new { f.Id, f.Name, eigenaar = false })));
|
||||
}
|
||||
|
||||
case SocketActions.ListMembers:
|
||||
{
|
||||
var p = request.PayloadAs<FamilyIdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen familie opgegeven.");
|
||||
var leden = await scope.Families.ListMembersAsync(p.FamilyId, session!.UserId, ct);
|
||||
|
||||
// De profielfoto's gaan als kleine versie mee in hetzelfde antwoord; zie
|
||||
// KleineFotosAsync. Het nummer gaat ook mee, voor als de app de grote wil.
|
||||
var bestanden = (await scope.Vault.ProfielfotosAsync(leden.Select(l => l.PersonId),
|
||||
session.UserId, ct))
|
||||
.ToDictionary(f => f.PersoonId);
|
||||
|
||||
return SocketResponse.Ok(id, leden.Select(l =>
|
||||
{
|
||||
bestanden.TryGetValue(l.PersonId, out var foto);
|
||||
return new
|
||||
{
|
||||
l.PersonId, l.DisplayName, l.FirstName, l.LastName, l.Email,
|
||||
l.Relation, l.RelationLabel, l.State, l.CanEdit, l.BirthDate, l.DateOfDeath,
|
||||
l.CreatedAt, l.InvitedAt, l.InvitationExpiresAt, l.InvitationResends, l.ClaimedAt,
|
||||
l.IsBeheerder, l.MomentenKlaar,
|
||||
profielfotoId = foto?.BestandId,
|
||||
profielfoto = foto?.Klein is { Length: > 0 } klein ? Convert.ToBase64String(klein) : null
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
case SocketActions.AddMember:
|
||||
{
|
||||
var p = request.PayloadAs<AddMemberPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Niet alle gegevens zijn ingevuld.");
|
||||
var r = await scope.Families.AddMemberAsync(p.FamilyId, session!.UserId, p.FirstName, p.LastName,
|
||||
p.Email, p.Relation, p.RelationLabel, p.BirthDate, ct);
|
||||
return r.Succeeded
|
||||
? SocketResponse.Ok(id, new { persoonId = r.Value!.Id, naam = r.Value.DisplayName })
|
||||
: SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.UpdatePerson:
|
||||
{
|
||||
var p = request.PayloadAs<UpdateMemberPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Niet alle gegevens zijn ingevuld.");
|
||||
var r = await scope.Families.WijzigLidAsync(p.FamilyId, p.PersonId, session!.UserId,
|
||||
p.FirstName, p.LastName, p.Email,
|
||||
p.Relation, p.RelationLabel, p.BirthDate, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { gewijzigd = true })
|
||||
: SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.RemoveMember:
|
||||
{
|
||||
var p = request.PayloadAs<InvitePayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven.");
|
||||
var r = await scope.Families.VerwijderLidAsync(p.FamilyId, p.PersonId, session!.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { verwijderd = true }) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.Invite:
|
||||
{
|
||||
var p = request.PayloadAs<InvitePayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven.");
|
||||
var r = await scope.Families.InviteAsync(p.FamilyId, p.PersonId, session!.UserId, ct);
|
||||
// Het token gaat bewust alleen naar de e-mail, niet terug naar de app.
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { verstuurd = true }) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.VaultList:
|
||||
{
|
||||
var p = request.PayloadAs<VaultListPayload>();
|
||||
var items = await scope.Vault.ListAsync(session!.PersonId, p?.Category, ct);
|
||||
return SocketResponse.Ok(id, items.Select(i => new
|
||||
{
|
||||
i.Id, i.Title, i.Category, i.ReleasePolicy, i.ReleaseAt, i.UpdatedAt, i.SharedManagerPersonId
|
||||
}));
|
||||
}
|
||||
|
||||
case SocketActions.VaultOpen:
|
||||
{
|
||||
var p = request.PayloadAs<IdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen item opgegeven.");
|
||||
var r = await scope.Vault.OpenAsync(p.Id, session!.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, r.Value) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.VaultSave:
|
||||
{
|
||||
var p = request.PayloadAs<VaultSavePayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen gegevens meegegeven.");
|
||||
var r = await scope.Vault.CreateAsync(session!.UserId, p.Category, p.Title,
|
||||
p.Payload ?? new VaultPayload(),
|
||||
p.ReleasePolicy, p.ReleaseAt, p.SharedManagerPersonId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { itemId = r.Value!.Id }) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.VaultUpdate:
|
||||
{
|
||||
var p = request.PayloadAs<VaultUpdatePayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen gegevens meegegeven.");
|
||||
var r = await scope.Vault.UpdateAsync(p.Id, session!.UserId, p.Category, p.Title,
|
||||
p.Payload ?? new VaultPayload(),
|
||||
p.ReleasePolicy, p.ReleaseAt, p.SharedManagerPersonId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { gewijzigd = true }) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.VaultDelete:
|
||||
{
|
||||
var p = request.PayloadAs<IdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen item opgegeven.");
|
||||
var r = await scope.Vault.DeleteAsync(p.Id, session!.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { verwijderd = true }) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- bijlagen
|
||||
case SocketActions.FileBegin:
|
||||
{
|
||||
var p = request.PayloadAs<FileBeginPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen bestandsgegevens meegegeven.");
|
||||
|
||||
if (p.TotalBytes <= 0) return SocketResponse.Error(id, "Het bestand is leeg.");
|
||||
if (p.TotalBytes > VaultService.MaxBestandsgrootte)
|
||||
return SocketResponse.Error(id,
|
||||
$"Dit bestand is te groot. Maximaal {VaultService.MaxBestandsgrootte / 1024 / 1024} MB.");
|
||||
|
||||
try
|
||||
{
|
||||
var doel = new Bijlagedoel(p.VaultItemId, p.SubjectPersonId, p.MessageId);
|
||||
var uploadId = _overdracht.Begin(session!.UserId, p.FileName, p.ContentType ?? "application/octet-stream",
|
||||
doel, p.TotalBytes);
|
||||
return SocketResponse.Ok(id, new { uploadId, stukGrootte = Bestandsoverdracht.StukGrootte });
|
||||
}
|
||||
catch (InvalidOperationException ex) { return SocketResponse.Error(id, ex.Message); }
|
||||
}
|
||||
|
||||
case SocketActions.FileChunk:
|
||||
{
|
||||
var p = request.PayloadAs<FileChunkPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen gegevens meegegeven.");
|
||||
|
||||
try
|
||||
{
|
||||
_overdracht.Stuk(p.UploadId, session!.UserId, p.Index, Convert.FromBase64String(p.Data ?? ""));
|
||||
return SocketResponse.Ok(id, new { volgende = p.Index + 1 });
|
||||
}
|
||||
catch (FormatException) { return SocketResponse.Error(id, "Dit stuk is onleesbaar aangekomen."); }
|
||||
catch (InvalidOperationException ex) { return SocketResponse.Error(id, ex.Message); }
|
||||
}
|
||||
|
||||
case SocketActions.FileEnd:
|
||||
{
|
||||
var p = request.PayloadAs<UploadIdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen upload opgegeven.");
|
||||
|
||||
byte[] inhoud;
|
||||
string naam, soort;
|
||||
Bijlagedoel? doel;
|
||||
try
|
||||
{
|
||||
(naam, soort, doel, inhoud) = _overdracht.Klaar(p.UploadId, session!.UserId);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return SocketResponse.Error(id, ex.Message); }
|
||||
|
||||
var r = await scope.Vault.StoreFileAsync(session.UserId, naam, soort, inhoud,
|
||||
doel?.KluisItemId, doel?.PersoonId, doel?.BerichtId, ct);
|
||||
return r.Succeeded
|
||||
? SocketResponse.Ok(id, new { bestandId = r.Value!.Id, naam = r.Value.FileName, grootte = r.Value.SizeBytes })
|
||||
: SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.FileCancel:
|
||||
{
|
||||
var p = request.PayloadAs<UploadIdPayload>();
|
||||
if (p is not null) _overdracht.Afbreken(p.UploadId, session!.UserId);
|
||||
return SocketResponse.Ok(id);
|
||||
}
|
||||
|
||||
case SocketActions.FileList:
|
||||
{
|
||||
var p = request.PayloadAs<IdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen item opgegeven.");
|
||||
|
||||
var r = await scope.Vault.ListFilesAsync(p.Id, session!.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, Bijlagen(r.Value!)) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.FileRead:
|
||||
{
|
||||
var p = request.PayloadAs<FileReadPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen bestand opgegeven.");
|
||||
|
||||
var r = await scope.Vault.OpenFileAsync(p.Id, session!.UserId, ct);
|
||||
if (!r.Succeeded) return SocketResponse.Error(id, r.Error!);
|
||||
|
||||
var (bestand, inhoud) = r.Value;
|
||||
var stukken = (int)Math.Ceiling(inhoud.LongLength / (double)Bestandsoverdracht.StukGrootte);
|
||||
var index = Math.Max(0, p.Index);
|
||||
|
||||
if (index >= stukken)
|
||||
return SocketResponse.Error(id, "Dat stuk bestaat niet.");
|
||||
|
||||
var begin = (long)index * Bestandsoverdracht.StukGrootte;
|
||||
var lengte = (int)Math.Min(Bestandsoverdracht.StukGrootte, inhoud.LongLength - begin);
|
||||
|
||||
return SocketResponse.Ok(id, new
|
||||
{
|
||||
naam = bestand.FileName,
|
||||
soort = bestand.ContentType,
|
||||
grootte = bestand.SizeBytes,
|
||||
stukken,
|
||||
index,
|
||||
data = Convert.ToBase64String(inhoud, (int)begin, lengte)
|
||||
});
|
||||
}
|
||||
|
||||
case SocketActions.FileDelete:
|
||||
{
|
||||
var p = request.PayloadAs<IdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen bestand opgegeven.");
|
||||
var r = await scope.Vault.DeleteFileAsync(p.Id, session!.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { verwijderd = true }) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.Inbox:
|
||||
{
|
||||
var berichten = await scope.Messages.InboxAsync(session!.PersonId, ct);
|
||||
return SocketResponse.Ok(id, berichten.Select(m => new
|
||||
{
|
||||
m.Id, m.Subject, m.Kind, m.FromPersonId, m.DeliveredAt, m.ReadAt, m.Status, m.VaultFileId
|
||||
}));
|
||||
}
|
||||
|
||||
case SocketActions.Outbox:
|
||||
{
|
||||
var berichten = await scope.Messages.OutboxAsync(session!.PersonId, ct);
|
||||
return SocketResponse.Ok(id, berichten.Select(m => new
|
||||
{
|
||||
m.Id, m.Subject, m.Kind, m.ToPersonId, m.DeliverAt, m.DeliverAfterDeath, m.Status, m.VaultFileId
|
||||
}));
|
||||
}
|
||||
|
||||
case SocketActions.MessageRead:
|
||||
{
|
||||
var p = request.PayloadAs<IdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven.");
|
||||
var r = await scope.Messages.ReadAsync(p.Id, session!.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { tekst = r.Value }) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.MessageReadSent:
|
||||
{
|
||||
var p = request.PayloadAs<IdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven.");
|
||||
var r = await scope.Messages.ReadSentAsync(p.Id, session!.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { tekst = r.Value }) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.MessageCompose:
|
||||
{
|
||||
var p = request.PayloadAs<ComposePayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Niet alle gegevens zijn ingevuld.");
|
||||
var r = await scope.Messages.ComposeAsync(session!.UserId, p.ToPersonId, p.Subject, p.Text,
|
||||
p.DeliverAt, p.AfterDeath, p.Kind, p.VaultFileId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { berichtId = r.Value!.Id }) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.MessageDraft:
|
||||
{
|
||||
var p = request.PayloadAs<IdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven.");
|
||||
var r = await scope.Messages.ConceptAsync(p.Id, session!.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { tekst = r.Value }) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.MessageUpdate:
|
||||
{
|
||||
var p = request.PayloadAs<MessageUpdatePayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Niet alle gegevens zijn ingevuld.");
|
||||
var r = await scope.Messages.UpdateAsync(p.Id, session!.UserId, p.Subject, p.Text,
|
||||
p.DeliverAt, p.AfterDeath, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { gewijzigd = true }) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.MessageWithdraw:
|
||||
{
|
||||
var p = request.PayloadAs<IdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven.");
|
||||
var r = await scope.Messages.WithdrawAsync(p.Id, session!.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { ingetrokken = true }) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.MessageDelete:
|
||||
{
|
||||
var p = request.PayloadAs<IdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven.");
|
||||
var r = await scope.Messages.DeleteForMeAsync(p.Id, session!.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { verwijderd = true }) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.DeclareDeath:
|
||||
{
|
||||
var p = request.PayloadAs<DeclareDeathPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen profiel opgegeven.");
|
||||
var r = await scope.Legacy.DeclareDeathAsync(p.PersonId, session!.UserId, p.DateOfDeath, p.Note, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, r.Value) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.ObjectDeath:
|
||||
{
|
||||
var r = await scope.Legacy.ObjectAsync(session!.PersonId, session.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.Advertentie:
|
||||
{
|
||||
// De keuze en het tellen horen bij elkaar: wat hier wordt uitgekozen gaat ook
|
||||
// echt mee terug, dus dat is het moment om het mee te tellen.
|
||||
if (scope.Adverteren is null) return SocketResponse.Ok(id, null);
|
||||
|
||||
var campagne = await scope.Adverteren.KiesAsync(ct);
|
||||
if (campagne is null) return SocketResponse.Ok(id, null);
|
||||
|
||||
await scope.Adverteren.TelVertoningAsync(campagne.Id, ct);
|
||||
|
||||
// Volledige adressen, want de app weet niet waar de site staat en hoort dat ook
|
||||
// niet zelf in elkaar te zetten. De afbeelding en de doorverwijzing zijn openbaar;
|
||||
// zie Advertenties.cs in het webproject.
|
||||
var basis = scope.Adverteren.SiteUrl.TrimEnd('/');
|
||||
|
||||
return SocketResponse.Ok(id, new
|
||||
{
|
||||
campagne.Id,
|
||||
campagne.Tekst,
|
||||
Afbeelding = campagne.HeeftAfbeelding ? $"{basis}/advertentie/{campagne.Id}/afbeelding" : null,
|
||||
Link = $"{basis}/advertentie/{campagne.Id}/uit"
|
||||
});
|
||||
}
|
||||
|
||||
case SocketActions.LegacyStatus:
|
||||
{
|
||||
var p = request.PayloadAs<PersonIdPayload>();
|
||||
var status = await scope.Legacy.StatusAsync(p?.PersonId ?? session!.PersonId, ct);
|
||||
return SocketResponse.Ok(id, status);
|
||||
}
|
||||
|
||||
case SocketActions.RegisterDevice:
|
||||
{
|
||||
var p = request.PayloadAs<DevicePayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen kenmerk meegegeven.");
|
||||
if (scope.Meldingen is null) return SocketResponse.Ok(id, new { meldingen = false });
|
||||
|
||||
var r = await scope.Meldingen.AanmeldenAsync(session!.UserId, p.Token,
|
||||
p.Platform ?? "android", p.Name, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, new { meldingen = true })
|
||||
: SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.TestDevice:
|
||||
{
|
||||
// Alleen naar je eigen toestellen; er gaat geen persoonsnummer in de vraag mee.
|
||||
if (scope.Meldingen is null)
|
||||
return SocketResponse.Ok(id, new { gelukt = false, uitleg = "Meldingen zijn hier niet ingericht." });
|
||||
|
||||
var proef = await scope.Meldingen.ProefAsync(session!.UserId, ct);
|
||||
return SocketResponse.Ok(id, new { gelukt = proef.Gelukt, uitleg = proef.Uitleg });
|
||||
}
|
||||
|
||||
case SocketActions.UnregisterDevice:
|
||||
{
|
||||
var p = request.PayloadAs<DevicePayload>();
|
||||
if (p is null || scope.Meldingen is null) return SocketResponse.Ok(id);
|
||||
await scope.Meldingen.AfmeldenAsync(session!.UserId, p.Token, ct);
|
||||
return SocketResponse.Ok(id);
|
||||
}
|
||||
|
||||
case SocketActions.AppointManager:
|
||||
{
|
||||
var p = request.PayloadAs<AppointManagerPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven.");
|
||||
var r = await scope.Legacy.AppointManagerAsync(session!.UserId, p.ManagerPersonId, p.Priority, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.FileListPerson:
|
||||
{
|
||||
var p = request.PayloadAs<PersonIdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven.");
|
||||
var r = await scope.Vault.ListPersonFilesAsync(p.PersonId, session!.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, Bijlagen(r.Value!)) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.FileListMessage:
|
||||
{
|
||||
var p = request.PayloadAs<IdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven.");
|
||||
var r = await scope.Vault.ListMessageFilesAsync(p.Id, session!.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id, Bijlagen(r.Value!)) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.FileProfilePhoto:
|
||||
{
|
||||
var p = request.PayloadAs<IdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen foto opgegeven.");
|
||||
var r = await scope.Vault.SetProfilePhotoAsync(p.Id, session!.UserId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.LegacyEntrusted:
|
||||
{
|
||||
var r = await scope.Legacy.ToevertrouwdAsync(session!.UserId, ct);
|
||||
var fotos = await KleineFotosAsync(scope, r.Select(t => t.PersonId), session.UserId, ct);
|
||||
|
||||
return SocketResponse.Ok(id, r.Select(t => new
|
||||
{
|
||||
t.PersonId, t.DisplayName, t.DateOfDeath, t.AccessGrantedAt, t.AantalItems,
|
||||
t.Afgerond, t.Volgorde, t.RequiredDeclarations, t.ObjectionDays,
|
||||
profielfoto = fotos.GetValueOrDefault(t.PersonId)
|
||||
}));
|
||||
}
|
||||
|
||||
case SocketActions.LegacyEntrustedItems:
|
||||
{
|
||||
var p = request.PayloadAs<PersonIdPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven.");
|
||||
|
||||
var r = await scope.Legacy.NalatenschapItemsAsync(p.PersonId, session!.UserId, ct);
|
||||
if (!r.Succeeded) return SocketResponse.Error(id, r.Error!);
|
||||
|
||||
return SocketResponse.Ok(id, r.Value!.Select(i => new
|
||||
{
|
||||
i.Id, i.Title, Category = (int)i.Category, ReleasePolicy = (int)i.ReleasePolicy,
|
||||
i.ReleaseAt, i.CreatedAt
|
||||
}));
|
||||
}
|
||||
|
||||
case SocketActions.RemoveManager:
|
||||
{
|
||||
var p = request.PayloadAs<ManagerPayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven.");
|
||||
|
||||
var r = await scope.Legacy.RemoveManagerAsync(session!.UserId, p.ManagerPersonId, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
case SocketActions.VaultSharedWithMe:
|
||||
{
|
||||
var r = await scope.Vault.ListSharedWithMeAsync(session!.UserId, ct);
|
||||
if (!r.Succeeded) return SocketResponse.Error(id, r.Error!);
|
||||
|
||||
return SocketResponse.Ok(id, r.Value!.Select(i => new
|
||||
{
|
||||
i.Id, i.Title, Category = (int)i.Category, ReleasePolicy = (int)i.ReleasePolicy,
|
||||
i.ReleaseAt, i.UpdatedAt, i.SharedManagerPersonId
|
||||
}));
|
||||
}
|
||||
|
||||
case SocketActions.LegacyManagers:
|
||||
{
|
||||
var lijst = await scope.Legacy.ListManagersAsync(session!.PersonId, ct);
|
||||
var fotos = await KleineFotosAsync(scope, lijst.Select(b => b.PersonId), session.UserId, ct);
|
||||
|
||||
return SocketResponse.Ok(id, lijst.Select(b => new
|
||||
{
|
||||
b.PersonId, b.DisplayName, b.Priority, b.AccessGrantedAt,
|
||||
profielfoto = fotos.GetValueOrDefault(b.PersonId)
|
||||
}));
|
||||
}
|
||||
|
||||
case SocketActions.LegacySettings:
|
||||
{
|
||||
var p = request.PayloadAs<ProcedurePayload>();
|
||||
if (p is null) return SocketResponse.Error(id, "Geen instellingen meegegeven.");
|
||||
var r = await scope.Legacy.WijzigProcedureAsync(session!.UserId, p.Declarations, p.ObjectionDays, ct);
|
||||
return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!);
|
||||
}
|
||||
|
||||
default:
|
||||
return SocketResponse.Error(id, $"Onbekende actie '{request.Action}'.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// De kleine profielfoto's van een stel mensen, als base64 en per persoon.
|
||||
///
|
||||
/// De app krijgt ze mee in het antwoord waar de mensen zelf in staan. Dat scheelt een
|
||||
/// tweede ronde per persoon, en het zijn een paar kilobyte per stuk: de foto zelf ophalen
|
||||
/// voor twintig mensen zou megabytes kosten voor plaatjes van veertig pixels.
|
||||
///
|
||||
/// Wie je mag zien bepaalt de kluis zelf; hier staat geen tweede oordeel.
|
||||
/// </summary>
|
||||
private static async Task<Dictionary<long, string?>> KleineFotosAsync(
|
||||
RequestScope scope, IEnumerable<long> persoonIds, long gebruikerId, CancellationToken ct)
|
||||
{
|
||||
var uitkomst = new Dictionary<long, string?>();
|
||||
|
||||
foreach (var foto in await scope.Vault.ProfielfotosAsync(persoonIds, gebruikerId, ct))
|
||||
{
|
||||
if (foto.Klein is { Length: > 0 } klein)
|
||||
uitkomst[foto.PersoonId] = Convert.ToBase64String(klein);
|
||||
}
|
||||
|
||||
return uitkomst;
|
||||
}
|
||||
}
|
||||
|
||||
// De vorm van wat de app meestuurt. Bewust kleine, expliciete typen: zo is in één oogopslag
|
||||
// duidelijk welke gegevens een actie nodig heeft.
|
||||
public record EmailPayload(string Email);
|
||||
public record SignInPayload(string Email, string Code);
|
||||
|
||||
/// <summary>Alleen een code. Bij het opheffen van een account komt het adres uit de sessie.</summary>
|
||||
public record CodePayload(string Code);
|
||||
public record RegisterPayload(string Email, string FirstName, string LastName);
|
||||
public record AcceptInvitationPayload(string Token);
|
||||
public record OwnProfilePayload(string FirstName, string LastName, DateOnly? BirthDate);
|
||||
public record FamilyIdPayload(long FamilyId);
|
||||
public record IdPayload(long Id);
|
||||
public record PersonIdPayload(long PersonId);
|
||||
public record AddMemberPayload(long FamilyId, string FirstName, string LastName, string? Email,
|
||||
RelationType Relation, string? RelationLabel, DateOnly? BirthDate);
|
||||
public record UpdateMemberPayload(long FamilyId, long PersonId, string FirstName, string LastName,
|
||||
string? Email, RelationType Relation, string? RelationLabel,
|
||||
DateOnly? BirthDate);
|
||||
public record InvitePayload(long FamilyId, long PersonId);
|
||||
public record ProcedurePayload(int Declarations, int ObjectionDays);
|
||||
public record VaultListPayload(VaultCategory? Category);
|
||||
public record VaultUpdatePayload(long Id, VaultCategory Category, string Title, VaultPayload? Payload,
|
||||
ReleasePolicy ReleasePolicy, DateTime? ReleaseAt, long? SharedManagerPersonId = null);
|
||||
public record MessageUpdatePayload(long Id, string Subject, string Text, DateTime? DeliverAt, bool AfterDeath);
|
||||
public record FileBeginPayload(string FileName, string? ContentType, long? VaultItemId, long TotalBytes,
|
||||
long? SubjectPersonId = null, long? MessageId = null);
|
||||
public record FileIdPayload(long Id);
|
||||
public record FileChunkPayload(string UploadId, int Index, string? Data);
|
||||
public record UploadIdPayload(string UploadId);
|
||||
public record FileReadPayload(long Id, int Index);
|
||||
public record DevicePayload(string Token, string? Platform, string? Name);
|
||||
public record LookaheadPayload(int Days);
|
||||
public record VaultSavePayload(VaultCategory Category, string Title, VaultPayload? Payload,
|
||||
ReleasePolicy ReleasePolicy, DateTime? ReleaseAt, long? SharedManagerPersonId = null);
|
||||
public record ComposePayload(long ToPersonId, string Subject, string Text, DateTime? DeliverAt,
|
||||
bool AfterDeath, MessageKind Kind, long? VaultFileId);
|
||||
public record DeclareDeathPayload(long PersonId, DateOnly? DateOfDeath, string? Note);
|
||||
public record AppointManagerPayload(long ManagerPersonId, int Priority);
|
||||
public record ManagerPayload(long ManagerPersonId);
|
||||
Reference in New Issue
Block a user