1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
|
// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
namespace Progmasoft.Progmaweb.Api.Accounts;
/// <summary>An <see cref="IAccountStore"/> that keeps everything in the memory of the process.</summary>
/// <remarks>
/// It keeps the uniqueness and session rules of <see cref="PostgresAccountStore"/> under one lock, for development
/// and for tests that need no database. Nothing survives a restart of the service: every account and every session
/// is lost when the process stops.
/// </remarks>
internal sealed class InMemoryAccountStore : IAccountStore
{
/// <summary>Guards every dictionary below; each operation holds it for its whole duration.</summary>
private readonly Lock gate = new();
/// <summary>Every account, by identifier.</summary>
private readonly Dictionary<Guid, AccountRecord> accountsById = [];
/// <summary>Account identifiers by normalized email address; the uniqueness index of addresses.</summary>
private readonly Dictionary<string, Guid> accountIdsByEmail = new(StringComparer.Ordinal);
/// <summary>Account identifiers by Google subject; the uniqueness index of linked identities.</summary>
private readonly Dictionary<string, Guid> accountIdsByGoogleSubject = new(StringComparer.Ordinal);
/// <summary>Account identifiers by normalized account name; the uniqueness index of names.</summary>
private readonly Dictionary<string, Guid> accountIdsByName = new(StringComparer.Ordinal);
/// <summary>Sessions by the hexadecimal form of their token digest.</summary>
private readonly Dictionary<string, SessionRecord> sessionsByDigest = new(StringComparer.Ordinal);
/// <inheritdoc/>
public ValueTask<CreateAccountResult> CreateAsync(AccountRecord account, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (gate)
{
if (account.GoogleSubject is not null && accountIdsByGoogleSubject.ContainsKey(account.GoogleSubject))
{
return ValueTask.FromResult(CreateAccountResult.GoogleSubjectUnavailable());
}
if (accountIdsByName.ContainsKey(account.NormalizedAccountName))
{
return ValueTask.FromResult(CreateAccountResult.AccountNameUnavailable());
}
if (accountIdsByEmail.ContainsKey(account.NormalizedEmail))
{
return ValueTask.FromResult(CreateAccountResult.EmailUnavailable());
}
accountsById.Add(account.Id, account);
accountIdsByName.Add(account.NormalizedAccountName, account.Id);
accountIdsByEmail.Add(account.NormalizedEmail, account.Id);
if (account.GoogleSubject is not null)
{
accountIdsByGoogleSubject.Add(account.GoogleSubject, account.Id);
}
return ValueTask.FromResult(CreateAccountResult.Created(account));
}
}
/// <inheritdoc/>
public ValueTask<AccountRecord?> FindByGoogleSubjectAsync(
string googleSubject,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (gate)
{
return ValueTask.FromResult(
accountIdsByGoogleSubject.TryGetValue(googleSubject, out Guid id) ? accountsById[id] : null);
}
}
/// <inheritdoc/>
public ValueTask<AccountRecord?> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (gate)
{
return ValueTask.FromResult(
accountIdsByEmail.TryGetValue(normalizedEmail, out Guid id) ? accountsById[id] : null);
}
}
/// <inheritdoc/>
public ValueTask<AccountRecord?> FindByAccountNameAsync(
string normalizedAccountName,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (gate)
{
return ValueTask.FromResult(
accountIdsByName.TryGetValue(normalizedAccountName, out Guid id) ? accountsById[id] : null);
}
}
/// <inheritdoc/>
public ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (gate)
{
accountsById.TryGetValue(id, out AccountRecord? account);
return ValueTask.FromResult(account);
}
}
/// <inheritdoc/>
public ValueTask StoreSessionAsync(SessionRecord session, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (gate)
{
// The durable store refuses a session of an account that does not exist; so does this one.
if (!accountsById.ContainsKey(session.AccountId))
{
throw new InvalidOperationException("A session refers to an account that is not stored.");
}
sessionsByDigest[Convert.ToHexString(session.TokenDigest)] = session;
return ValueTask.CompletedTask;
}
}
/// <inheritdoc/>
public ValueTask<SessionRecord?> FindSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (gate)
{
sessionsByDigest.TryGetValue(Convert.ToHexString(tokenDigest), out SessionRecord? session);
return ValueTask.FromResult(session);
}
}
/// <inheritdoc/>
public ValueTask RevokeSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (gate)
{
sessionsByDigest.Remove(Convert.ToHexString(tokenDigest));
return ValueTask.CompletedTask;
}
}
/// <inheritdoc/>
public ValueTask<int> RemoveExpiredSessionsAsync(DateTimeOffset now, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (gate)
{
string[] expired = [.. sessionsByDigest.Where(entry => entry.Value.ExpiresAt <= now).Select(entry => entry.Key)];
foreach (string digest in expired)
{
sessionsByDigest.Remove(digest);
}
return ValueTask.FromResult(expired.Length);
}
}
}
|