@meith/accounts 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +165 -0
- package/package.json +25 -0
- package/src/ban-filter.ts +127 -0
- package/src/ban-service.ts +71 -0
- package/src/case-fold.ts +3 -0
- package/src/credential-proof.ts +17 -0
- package/src/crypto/base64url.ts +53 -0
- package/src/crypto/legacy.ts +127 -0
- package/src/crypto/password.ts +92 -0
- package/src/crypto/tokens.ts +28 -0
- package/src/federation/catalog.ts +115 -0
- package/src/federation/github.ts +117 -0
- package/src/federation/http.ts +48 -0
- package/src/federation/jwt.ts +198 -0
- package/src/federation/oidc.ts +175 -0
- package/src/federation/pkce.ts +20 -0
- package/src/federation/service.ts +198 -0
- package/src/federation/types.ts +50 -0
- package/src/index.ts +223 -0
- package/src/member-settings.ts +390 -0
- package/src/memory-bans.ts +88 -0
- package/src/memory-repos.ts +665 -0
- package/src/policy.ts +104 -0
- package/src/ports.ts +415 -0
- package/src/register-fields.ts +17 -0
- package/src/service.ts +684 -0
- package/src/session-service.ts +134 -0
- package/src/test-support.fixture.ts +8 -0
- package/src/totp/base32.ts +52 -0
- package/src/totp/secret-box.ts +83 -0
- package/src/totp/service.ts +252 -0
- package/src/totp/totp.ts +120 -0
- package/src/webauthn/authenticator.fixture.ts +268 -0
- package/src/webauthn/cbor.ts +110 -0
- package/src/webauthn/cose.ts +130 -0
- package/src/webauthn/service.ts +266 -0
- package/src/webauthn/verify.ts +231 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { ValidationError } from '@meith/core'
|
|
2
|
+
import { msg, normaliseLocale } from '@meith/i18n'
|
|
3
|
+
|
|
4
|
+
import { foldIdentifier } from './case-fold'
|
|
5
|
+
import { hashPassword, needsRehash, verifyPassword } from './crypto/password'
|
|
6
|
+
import { generateToken, hashToken } from './crypto/tokens'
|
|
7
|
+
import type {
|
|
8
|
+
AccountRepository,
|
|
9
|
+
CredentialTokenRepository,
|
|
10
|
+
RememberTokenRepository,
|
|
11
|
+
SessionRepository,
|
|
12
|
+
} from './ports'
|
|
13
|
+
|
|
14
|
+
export const LOCATION_MAX = 100
|
|
15
|
+
export const WEBSITE_MAX = 200
|
|
16
|
+
export const BIO_MAX = 1000
|
|
17
|
+
|
|
18
|
+
export const PAGE_SIZE_MIN = 5
|
|
19
|
+
export const PAGE_SIZE_MAX = 100
|
|
20
|
+
|
|
21
|
+
export const EMAIL_CHANGE_TTL_MINUTES = 60
|
|
22
|
+
|
|
23
|
+
export interface MemberSettings {
|
|
24
|
+
readonly userId: number
|
|
25
|
+
readonly email: string
|
|
26
|
+
readonly timezone: string
|
|
27
|
+
readonly locale: string
|
|
28
|
+
readonly postsPerPage: number | null
|
|
29
|
+
readonly threadsPerPage: number | null
|
|
30
|
+
readonly invisible: boolean
|
|
31
|
+
readonly location: string | null
|
|
32
|
+
readonly website: string | null
|
|
33
|
+
readonly bio: string | null
|
|
34
|
+
readonly displayGroupId: number | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MemberGroupChoice {
|
|
38
|
+
readonly groupId: number
|
|
39
|
+
readonly title: string
|
|
40
|
+
readonly isPrimary: boolean
|
|
41
|
+
readonly isStaff: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MemberSettingsRepository {
|
|
45
|
+
read(userId: number): Promise<MemberSettings | null>
|
|
46
|
+
|
|
47
|
+
groupsHeldBy(userId: number): Promise<readonly MemberGroupChoice[]>
|
|
48
|
+
|
|
49
|
+
saveDisplayGroup(input: {
|
|
50
|
+
readonly userId: number
|
|
51
|
+
readonly displayGroupId: number | null
|
|
52
|
+
}): Promise<void>
|
|
53
|
+
|
|
54
|
+
saveProfile(input: {
|
|
55
|
+
readonly userId: number
|
|
56
|
+
readonly location: string | null
|
|
57
|
+
readonly website: string | null
|
|
58
|
+
readonly bio: string | null
|
|
59
|
+
}): Promise<void>
|
|
60
|
+
|
|
61
|
+
saveOptions(input: {
|
|
62
|
+
readonly userId: number
|
|
63
|
+
readonly timezone: string
|
|
64
|
+
readonly locale: string
|
|
65
|
+
readonly postsPerPage: number | null
|
|
66
|
+
readonly threadsPerPage: number | null
|
|
67
|
+
readonly invisible: boolean
|
|
68
|
+
}): Promise<void>
|
|
69
|
+
|
|
70
|
+
adoptEmail(input: {
|
|
71
|
+
readonly userId: number
|
|
72
|
+
readonly email: string
|
|
73
|
+
readonly emailLower: string
|
|
74
|
+
}): Promise<boolean>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const AUTOMATIC_TIMEZONE = 'auto'
|
|
78
|
+
|
|
79
|
+
export function isKnownTimezone(value: string): boolean {
|
|
80
|
+
if (value === 'UTC') return true
|
|
81
|
+
|
|
82
|
+
if (!/^[A-Za-z][A-Za-z0-9_+-]*\/[A-Za-z0-9_+/-]+$/.test(value)) return false
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
new Intl.DateTimeFormat('en-GB', { timeZone: value })
|
|
86
|
+
return true
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isTimezonePreference(value: string): boolean {
|
|
93
|
+
return value === AUTOMATIC_TIMEZONE || isKnownTimezone(value)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const AUTOMATIC_LOCALE = 'auto'
|
|
97
|
+
|
|
98
|
+
export function isLocalePreference(value: string): boolean {
|
|
99
|
+
return value === AUTOMATIC_LOCALE || normaliseLocale(value) !== null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class MemberSettingsService {
|
|
103
|
+
private readonly settings: MemberSettingsRepository
|
|
104
|
+
private readonly accounts: AccountRepository
|
|
105
|
+
private readonly sessions: SessionRepository
|
|
106
|
+
private readonly remember: RememberTokenRepository
|
|
107
|
+
private readonly tokens: CredentialTokenRepository
|
|
108
|
+
private readonly now: () => Date
|
|
109
|
+
|
|
110
|
+
constructor(deps: {
|
|
111
|
+
settings: MemberSettingsRepository
|
|
112
|
+
accounts: AccountRepository
|
|
113
|
+
sessions: SessionRepository
|
|
114
|
+
remember: RememberTokenRepository
|
|
115
|
+
tokens: CredentialTokenRepository
|
|
116
|
+
now?: () => Date
|
|
117
|
+
}) {
|
|
118
|
+
this.settings = deps.settings
|
|
119
|
+
this.accounts = deps.accounts
|
|
120
|
+
this.sessions = deps.sessions
|
|
121
|
+
this.remember = deps.remember
|
|
122
|
+
this.tokens = deps.tokens
|
|
123
|
+
this.now = deps.now ?? (() => new Date())
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async read(userId: number): Promise<MemberSettings | null> {
|
|
127
|
+
return this.settings.read(userId)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async groupsHeldBy(userId: number): Promise<readonly MemberGroupChoice[]> {
|
|
131
|
+
return this.settings.groupsHeldBy(userId)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async saveDisplayGroup(input: {
|
|
135
|
+
readonly userId: number
|
|
136
|
+
readonly displayGroupId: string
|
|
137
|
+
}): Promise<void> {
|
|
138
|
+
const held = await this.settings.groupsHeldBy(input.userId)
|
|
139
|
+
const primary = held.find((choice) => choice.isPrimary)
|
|
140
|
+
|
|
141
|
+
if (primary?.isStaff === true) {
|
|
142
|
+
throw new ValidationError(msg('error.accounts.group-one-were-appointed-shown'))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const raw = input.displayGroupId.trim()
|
|
146
|
+
if (raw === '' || (primary !== undefined && raw === String(primary.groupId))) {
|
|
147
|
+
await this.settings.saveDisplayGroup({ userId: input.userId, displayGroupId: null })
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!/^[1-9]\d*$/.test(raw)) {
|
|
152
|
+
throw new ValidationError(msg('error.accounts.group-board'))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const chosen = Number(raw)
|
|
156
|
+
if (!held.some((choice) => choice.groupId === chosen)) {
|
|
157
|
+
throw new ValidationError(msg('error.accounts.only-shown-as-group-if'))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await this.settings.saveDisplayGroup({
|
|
161
|
+
userId: input.userId,
|
|
162
|
+
displayGroupId: chosen,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async saveProfile(input: {
|
|
167
|
+
readonly userId: number
|
|
168
|
+
readonly location: string
|
|
169
|
+
readonly website: string
|
|
170
|
+
readonly bio: string
|
|
171
|
+
}): Promise<void> {
|
|
172
|
+
const location = input.location.trim()
|
|
173
|
+
const bio = input.bio.trim()
|
|
174
|
+
const website = input.website.trim()
|
|
175
|
+
|
|
176
|
+
if (location.length > LOCATION_MAX) {
|
|
177
|
+
throw new ValidationError(msg('error.accounts.location-length', { max: LOCATION_MAX }))
|
|
178
|
+
}
|
|
179
|
+
if (bio.length > BIO_MAX) {
|
|
180
|
+
throw new ValidationError(msg('error.accounts.bio-length', { max: BIO_MAX }))
|
|
181
|
+
}
|
|
182
|
+
if (website.length > WEBSITE_MAX) {
|
|
183
|
+
throw new ValidationError(msg('error.accounts.website-length', { max: WEBSITE_MAX }))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
await this.settings.saveProfile({
|
|
187
|
+
userId: input.userId,
|
|
188
|
+
location: location === '' ? null : location,
|
|
189
|
+
website: website === '' ? null : normaliseWebsite(website),
|
|
190
|
+
bio: bio === '' ? null : bio,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async saveOptions(input: {
|
|
195
|
+
readonly userId: number
|
|
196
|
+
readonly timezone: string
|
|
197
|
+
readonly locale: string
|
|
198
|
+
readonly postsPerPage: string
|
|
199
|
+
readonly threadsPerPage: string
|
|
200
|
+
readonly invisible: boolean
|
|
201
|
+
}): Promise<void> {
|
|
202
|
+
const timezone = input.timezone.trim()
|
|
203
|
+
if (!isTimezonePreference(timezone)) {
|
|
204
|
+
throw new ValidationError(msg('error.accounts.timezone-board-recognises'))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const locale = input.locale.trim()
|
|
208
|
+
if (!isLocalePreference(locale)) {
|
|
209
|
+
throw new ValidationError(msg('error.accounts.language-board-recognises'))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await this.settings.saveOptions({
|
|
213
|
+
userId: input.userId,
|
|
214
|
+
timezone,
|
|
215
|
+
locale,
|
|
216
|
+
postsPerPage: parsePageSize(input.postsPerPage, 'Posts per page'),
|
|
217
|
+
threadsPerPage: parsePageSize(input.threadsPerPage, 'Threads per page'),
|
|
218
|
+
invisible: input.invisible,
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async changePassword(input: {
|
|
223
|
+
readonly userId: number
|
|
224
|
+
readonly currentPassword: string
|
|
225
|
+
readonly newPassword: string
|
|
226
|
+
readonly minLength: number
|
|
227
|
+
}): Promise<void> {
|
|
228
|
+
const account = await this.requireVerified(input.userId, input.currentPassword)
|
|
229
|
+
|
|
230
|
+
const next = input.newPassword
|
|
231
|
+
if (next.length < input.minLength) {
|
|
232
|
+
throw new ValidationError(msg('error.accounts.password-min', { min: input.minLength }))
|
|
233
|
+
}
|
|
234
|
+
if (next === input.currentPassword) {
|
|
235
|
+
throw new ValidationError(msg('error.accounts.password-already-using'))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const hash = await hashPassword(next)
|
|
239
|
+
const at = this.now()
|
|
240
|
+
await this.accounts.updatePassword(account.id, hash, 'argon2id')
|
|
241
|
+
await this.sessions.revokeAllForUser(account.id)
|
|
242
|
+
await this.remember.revokeAllForUser(account.id, 'password_change', at)
|
|
243
|
+
await this.tokens.revokeAllForUser(account.id, 'email_change')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async requestEmailChange(input: {
|
|
247
|
+
readonly userId: number
|
|
248
|
+
readonly currentPassword: string
|
|
249
|
+
readonly newEmail: string
|
|
250
|
+
}): Promise<{ token: string; email: string; previousEmail: string; expiresAt: Date }> {
|
|
251
|
+
const account = await this.requireVerified(input.userId, input.currentPassword)
|
|
252
|
+
|
|
253
|
+
const email = input.newEmail.trim()
|
|
254
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
255
|
+
throw new ValidationError(msg('error.accounts.look-like-e-mail-address'))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const emailLower = foldIdentifier(email)
|
|
259
|
+
if (emailLower === account.emailLower) {
|
|
260
|
+
throw new ValidationError(msg('error.accounts.address-already-using'))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const taken = await this.accounts.findByEmailLower(emailLower)
|
|
264
|
+
if (taken !== null) {
|
|
265
|
+
throw new ValidationError(msg('error.accounts.address-already-use-board'))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const token = generateToken()
|
|
269
|
+
const at = this.now()
|
|
270
|
+
const expiresAt = new Date(at.getTime() + EMAIL_CHANGE_TTL_MINUTES * 60_000)
|
|
271
|
+
|
|
272
|
+
await this.tokens.revokeAllForUser(account.id, 'email_change')
|
|
273
|
+
await this.tokens.issue({
|
|
274
|
+
tokenHash: await hashToken(token),
|
|
275
|
+
userId: account.id,
|
|
276
|
+
purpose: 'email_change',
|
|
277
|
+
payload: JSON.stringify({ version: 1, email, previousEmailLower: account.emailLower }),
|
|
278
|
+
expiresAt,
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
return { token, email, previousEmail: account.email, expiresAt }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async confirmEmailChange(
|
|
285
|
+
token: string,
|
|
286
|
+
actorUserId: number,
|
|
287
|
+
): Promise<{ userId: number; email: string; previousEmail: string } | null> {
|
|
288
|
+
const tokenHash = await hashToken(token)
|
|
289
|
+
const pending = await this.tokens.peek(tokenHash, 'email_change', this.now())
|
|
290
|
+
if (pending === null || pending.userId !== actorUserId) return null
|
|
291
|
+
|
|
292
|
+
const payload = parseEmailChangePayload(pending.payload)
|
|
293
|
+
if (payload === null) return null
|
|
294
|
+
|
|
295
|
+
const account = await this.accounts.findById(pending.userId)
|
|
296
|
+
if (account === null || account.emailLower !== payload.previousEmailLower) return null
|
|
297
|
+
|
|
298
|
+
const consumed = await this.tokens.consume(tokenHash, 'email_change', this.now())
|
|
299
|
+
if (
|
|
300
|
+
consumed === null ||
|
|
301
|
+
consumed.userId !== pending.userId ||
|
|
302
|
+
consumed.payload !== pending.payload
|
|
303
|
+
) {
|
|
304
|
+
return null
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const adopted = await this.settings.adoptEmail({
|
|
308
|
+
userId: pending.userId,
|
|
309
|
+
email: payload.email,
|
|
310
|
+
emailLower: foldIdentifier(payload.email),
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
return adopted
|
|
314
|
+
? { userId: pending.userId, email: payload.email, previousEmail: account.email }
|
|
315
|
+
: null
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private async requireVerified(userId: number, currentPassword: string) {
|
|
319
|
+
const account = await this.accounts.findById(userId)
|
|
320
|
+
if (account === null) throw new ValidationError(msg('error.accounts.account-exist'))
|
|
321
|
+
|
|
322
|
+
if (account.passwordHash === null) {
|
|
323
|
+
throw new ValidationError(msg('error.accounts.account-password-set-so-changed'))
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const ok = await verifyPassword(currentPassword, account.passwordHash)
|
|
327
|
+
if (!ok) throw new ValidationError(msg('error.accounts.current-password'))
|
|
328
|
+
|
|
329
|
+
void needsRehash
|
|
330
|
+
|
|
331
|
+
return account
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function parseEmailChangePayload(
|
|
336
|
+
payload: string | null,
|
|
337
|
+
): { email: string; previousEmailLower: string } | null {
|
|
338
|
+
if (payload === null) return null
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
const parsed: unknown = JSON.parse(payload)
|
|
342
|
+
if (
|
|
343
|
+
typeof parsed !== 'object' ||
|
|
344
|
+
parsed === null ||
|
|
345
|
+
!('version' in parsed) ||
|
|
346
|
+
parsed.version !== 1 ||
|
|
347
|
+
!('email' in parsed) ||
|
|
348
|
+
typeof parsed.email !== 'string' ||
|
|
349
|
+
!('previousEmailLower' in parsed) ||
|
|
350
|
+
typeof parsed.previousEmailLower !== 'string'
|
|
351
|
+
) {
|
|
352
|
+
return null
|
|
353
|
+
}
|
|
354
|
+
return { email: parsed.email, previousEmailLower: parsed.previousEmailLower }
|
|
355
|
+
} catch {
|
|
356
|
+
return null
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function parsePageSize(raw: string, label: string): number | null {
|
|
361
|
+
const value = raw.trim()
|
|
362
|
+
if (value === '') return null
|
|
363
|
+
|
|
364
|
+
if (!/^[1-9]\d*$/.test(value)) {
|
|
365
|
+
throw new ValidationError(msg('error.accounts.page-size-number', { label }))
|
|
366
|
+
}
|
|
367
|
+
const size = Number(value)
|
|
368
|
+
if (!Number.isSafeInteger(size) || size < PAGE_SIZE_MIN || size > PAGE_SIZE_MAX) {
|
|
369
|
+
throw new ValidationError(
|
|
370
|
+
msg('error.accounts.page-size-range', { label, min: PAGE_SIZE_MIN, max: PAGE_SIZE_MAX }),
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
return size
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function normaliseWebsite(value: string): string {
|
|
377
|
+
const candidate = /^[a-z][a-z0-9+.-]*:/i.test(value) ? value : `https://${value}`
|
|
378
|
+
|
|
379
|
+
let url: URL
|
|
380
|
+
try {
|
|
381
|
+
url = new URL(candidate)
|
|
382
|
+
} catch {
|
|
383
|
+
throw new ValidationError(msg('error.accounts.look-like-web-address'))
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
387
|
+
throw new ValidationError(msg('error.accounts.website-address-must-start-with'))
|
|
388
|
+
}
|
|
389
|
+
return url.toString()
|
|
390
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { BanFilter } from './ban-filter'
|
|
2
|
+
import type {
|
|
3
|
+
AccountRepository,
|
|
4
|
+
BanFilterRepository,
|
|
5
|
+
BanRecord,
|
|
6
|
+
BanRepository,
|
|
7
|
+
CreateBanInput,
|
|
8
|
+
} from './ports'
|
|
9
|
+
|
|
10
|
+
export interface MemoryBanDeps {
|
|
11
|
+
readonly accounts: AccountRepository & {
|
|
12
|
+
getPrimaryGroupId?(userId: number): Promise<number | null>
|
|
13
|
+
setPrimaryGroupId?(userId: number, groupId: number | null): Promise<void>
|
|
14
|
+
revokeAllSessions?(userId: number): Promise<void>
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class MemoryBans implements BanRepository {
|
|
19
|
+
private readonly rows: (BanRecord & { bannedByUserId: number | null })[] = []
|
|
20
|
+
private nextId = 1
|
|
21
|
+
|
|
22
|
+
private readonly primaryGroup = new Map<number, number | null>()
|
|
23
|
+
readonly revoked: number[] = []
|
|
24
|
+
|
|
25
|
+
setPrimaryGroup(userId: number, groupId: number | null): void {
|
|
26
|
+
this.primaryGroup.set(userId, groupId)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
primaryGroupOf(userId: number): number | null {
|
|
30
|
+
return this.primaryGroup.get(userId) ?? null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async findActive(userId: number): Promise<BanRecord | null> {
|
|
34
|
+
return this.rows.find((r) => r.userId === userId && r.liftedAt === null) ?? null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async create(input: CreateBanInput): Promise<BanRecord> {
|
|
38
|
+
const previousPrimaryGroupId = this.primaryGroup.get(input.userId) ?? null
|
|
39
|
+
|
|
40
|
+
const row = {
|
|
41
|
+
id: this.nextId++,
|
|
42
|
+
userId: input.userId,
|
|
43
|
+
bannedByUserId: input.bannedByUserId,
|
|
44
|
+
reason: input.reason,
|
|
45
|
+
publicReason: input.publicReason,
|
|
46
|
+
previousPrimaryGroupId,
|
|
47
|
+
expiresAt: input.expiresAt,
|
|
48
|
+
liftedAt: null,
|
|
49
|
+
}
|
|
50
|
+
this.rows.push(row)
|
|
51
|
+
|
|
52
|
+
this.primaryGroup.set(input.userId, input.bannedGroupId)
|
|
53
|
+
this.revoked.push(input.userId)
|
|
54
|
+
return row
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async lift(banId: number, now: Date): Promise<void> {
|
|
58
|
+
const row = this.rows.find((r) => r.id === banId && r.liftedAt === null)
|
|
59
|
+
if (!row) return
|
|
60
|
+
|
|
61
|
+
;(row as { liftedAt: Date | null }).liftedAt = now
|
|
62
|
+
this.primaryGroup.set(row.userId, row.previousPrimaryGroupId)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async expireDue(now: Date, limit: number): Promise<number> {
|
|
66
|
+
const due = this.rows
|
|
67
|
+
.filter((r) => r.liftedAt === null && r.expiresAt !== null && r.expiresAt <= now)
|
|
68
|
+
.slice(0, limit)
|
|
69
|
+
|
|
70
|
+
for (const row of due) await this.lift(row.id, now)
|
|
71
|
+
return due.length
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class MemoryBanFilters implements BanFilterRepository {
|
|
76
|
+
private readonly rows: BanFilter[] = []
|
|
77
|
+
private nextId = 1
|
|
78
|
+
|
|
79
|
+
add(type: BanFilter['type'], pattern: string): BanFilter {
|
|
80
|
+
const row = { id: this.nextId++, type, pattern }
|
|
81
|
+
this.rows.push(row)
|
|
82
|
+
return row
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async listAll(): Promise<readonly BanFilter[]> {
|
|
86
|
+
return [...this.rows]
|
|
87
|
+
}
|
|
88
|
+
}
|