@meith/db 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/migrations/0000_initial_schema.sql +538 -0
- package/migrations/0001_seed_usergroups.sql +111 -0
- package/migrations/0002_group_promotions.sql +25 -0
- package/migrations/0003_counter_maintenance.sql +34 -0
- package/migrations/0004_post_render_cache.sql +7 -0
- package/migrations/0005_reports.sql +51 -0
- package/migrations/0006_warnings.sql +91 -0
- package/migrations/0007_notifications.sql +44 -0
- package/migrations/0008_subscriptions.sql +48 -0
- package/migrations/0009_member_preferences.sql +13 -0
- package/migrations/0010_profile_fields.sql +53 -0
- package/migrations/0011_private_messages.sql +63 -0
- package/migrations/0012_user_relations.sql +18 -0
- package/migrations/0013_reputation.sql +45 -0
- package/migrations/0014_signatures.sql +16 -0
- package/migrations/0015_admin_sessions.sql +24 -0
- package/migrations/0016_attachments.sql +51 -0
- package/migrations/0017_avatars.sql +33 -0
- package/migrations/0018_mass_mail.sql +15 -0
- package/migrations/0019_word_filters.sql +8 -0
- package/migrations/0020_searches.sql +15 -0
- package/migrations/0021_presence_stats.sql +18 -0
- package/migrations/0022_api_tokens_webhooks.sql +58 -0
- package/migrations/0023_install_state.sql +6 -0
- package/migrations/0024_component_versions.sql +13 -0
- package/migrations/0025_import.sql +25 -0
- package/migrations/0026_polls.sql +40 -0
- package/migrations/0027_drafts.sql +13 -0
- package/migrations/0028_board_vocabulary.sql +32 -0
- package/migrations/0029_announcements.sql +16 -0
- package/migrations/0030_antispam.sql +18 -0
- package/migrations/0031_markdown.sql +24 -0
- package/migrations/0032_theme_enablement.sql +1 -0
- package/migrations/0033_group_identity.sql +5 -0
- package/migrations/0034_search_document_version.sql +4 -0
- package/migrations/0035_categories_hold_no_threads.sql +1 -0
- package/migrations/0036_expiring_group_grants.sql +6 -0
- package/migrations/0037_automatic_timezone.sql +3 -0
- package/migrations/0038_primary_group_grants.sql +3 -0
- package/migrations/0039_moderator_log_forum_scope.sql +1 -0
- package/migrations/0040_moderator_restore_grants.sql +1 -0
- package/migrations/0041_drop_placebo_moderator_rights.sql +3 -0
- package/migrations/0042_drop_placebo_delete_others.sql +2 -0
- package/migrations/0043_federated_identity.sql +28 -0
- package/migrations/0044_second_factor.sql +34 -0
- package/migrations/0045_drop_dead_task_due_index.sql +1 -0
- package/migrations/0046_member_locale.sql +1 -0
- package/migrations/0047_navigation_items.sql +34 -0
- package/migrations/0048_web_push.sql +16 -0
- package/migrations/0049_plugin_health.sql +7 -0
- package/migrations/0050_drop_forum_password_grants.sql +1 -0
- package/migrations/0051_glossy_barracuda.sql +1 -0
- package/migrations/0052_many_professor_monster.sql +2 -0
- package/migrations/0053_strange_maximus.sql +14 -0
- package/migrations/0054_legacy_import_sources.sql +25 -0
- package/migrations/0055_poll_parity.sql +14 -0
- package/migrations/0056_marketplace_catalog.sql +11 -0
- package/migrations/meta/0000_snapshot.json +4412 -0
- package/migrations/meta/0006_snapshot.json +5411 -0
- package/migrations/meta/0007_snapshot.json +5634 -0
- package/migrations/meta/0008_snapshot.json +5779 -0
- package/migrations/meta/0009_snapshot.json +5816 -0
- package/migrations/meta/0010_snapshot.json +6117 -0
- package/migrations/meta/0011_snapshot.json +6406 -0
- package/migrations/meta/0012_snapshot.json +6517 -0
- package/migrations/meta/0013_snapshot.json +6722 -0
- package/migrations/meta/0014_snapshot.json +6777 -0
- package/migrations/meta/0015_snapshot.json +6914 -0
- package/migrations/meta/0016_snapshot.json +7192 -0
- package/migrations/meta/0017_snapshot.json +7248 -0
- package/migrations/meta/0018_snapshot.json +7355 -0
- package/migrations/meta/0019_snapshot.json +7419 -0
- package/migrations/meta/0020_snapshot.json +7562 -0
- package/migrations/meta/0021_snapshot.json +7653 -0
- package/migrations/meta/0031_snapshot.json +8162 -0
- package/migrations/meta/0032_snapshot.json +8169 -0
- package/migrations/meta/0033_snapshot.json +8193 -0
- package/migrations/meta/0034_snapshot.json +8221 -0
- package/migrations/meta/0036_snapshot.json +8262 -0
- package/migrations/meta/0037_snapshot.json +8262 -0
- package/migrations/meta/0038_snapshot.json +8293 -0
- package/migrations/meta/0039_snapshot.json +8308 -0
- package/migrations/meta/0041_snapshot.json +8287 -0
- package/migrations/meta/0042_snapshot.json +8274 -0
- package/migrations/meta/0043_snapshot.json +8521 -0
- package/migrations/meta/0044_snapshot.json +8814 -0
- package/migrations/meta/0045_snapshot.json +8797 -0
- package/migrations/meta/0046_snapshot.json +8804 -0
- package/migrations/meta/0047_snapshot.json +9018 -0
- package/migrations/meta/0048_snapshot.json +9145 -0
- package/migrations/meta/0049_snapshot.json +9190 -0
- package/migrations/meta/0050_snapshot.json +9126 -0
- package/migrations/meta/0051_snapshot.json +9132 -0
- package/migrations/meta/0052_snapshot.json +9148 -0
- package/migrations/meta/0053_snapshot.json +9268 -0
- package/migrations/meta/0054_snapshot.json +9378 -0
- package/migrations/meta/0056_snapshot.json +9447 -0
- package/migrations/meta/_journal.json +405 -0
- package/package.json +64 -0
- package/src/account-repos.ts +580 -0
- package/src/actor-builder.ts +130 -0
- package/src/admin-repo.ts +85 -0
- package/src/admin-session-repo.ts +205 -0
- package/src/admin-undo-repo.ts +128 -0
- package/src/announcement-repo.ts +176 -0
- package/src/antispam-repo.ts +145 -0
- package/src/api-repo.ts +276 -0
- package/src/attachment-admin-repo.ts +161 -0
- package/src/attachment-repo.ts +240 -0
- package/src/authorization-source.ts +138 -0
- package/src/avatar-repo.ts +185 -0
- package/src/ban-repos.ts +146 -0
- package/src/client.ts +85 -0
- package/src/content-admin-repo.ts +298 -0
- package/src/content-counters.ts +152 -0
- package/src/counter-recount.ts +263 -0
- package/src/cursor.ts +16 -0
- package/src/denormalised-username.ts +34 -0
- package/src/discovery-repo.ts +140 -0
- package/src/draft-repo.ts +59 -0
- package/src/feed-repo.ts +219 -0
- package/src/forum-admin-repo.ts +310 -0
- package/src/forum-repo.ts +206 -0
- package/src/group-admin-repo.ts +256 -0
- package/src/group-identity-repo.ts +84 -0
- package/src/identity-link-repo.ts +145 -0
- package/src/import-repo.ts +123 -0
- package/src/import-sink.ts +1377 -0
- package/src/index.ts +316 -0
- package/src/inline-moderation.ts +344 -0
- package/src/install-repo.ts +67 -0
- package/src/latest-repo.ts +115 -0
- package/src/maintenance-repo.ts +54 -0
- package/src/marketplace-repo.ts +111 -0
- package/src/member-profile-repo.ts +82 -0
- package/src/member-settings-repo.ts +160 -0
- package/src/message-repo.ts +384 -0
- package/src/migrate-run.ts +18 -0
- package/src/migrate.ts +129 -0
- package/src/modcp-repo.ts +253 -0
- package/src/moderation-queue.ts +279 -0
- package/src/navigation-repo.ts +288 -0
- package/src/notification-repo.ts +402 -0
- package/src/orphan-keys.ts +19 -0
- package/src/outbox-repo.ts +78 -0
- package/src/permission-version.ts +24 -0
- package/src/permissions-map.ts +52 -0
- package/src/pglite.fixture.ts +87 -0
- package/src/plugin-data.ts +88 -0
- package/src/plugin-grants.ts +369 -0
- package/src/plugin-health-repo.ts +80 -0
- package/src/plugin-purge-repo.ts +91 -0
- package/src/plugin-users.ts +39 -0
- package/src/poll-repo.ts +334 -0
- package/src/post-repo.ts +261 -0
- package/src/post-writes.ts +227 -0
- package/src/presence-repo.ts +220 -0
- package/src/profile-field-repo.ts +182 -0
- package/src/promotion-repo.ts +152 -0
- package/src/read-state-repo.ts +81 -0
- package/src/relation-repo.ts +113 -0
- package/src/render-backfill.ts +110 -0
- package/src/render-signature.ts +33 -0
- package/src/report-repo.ts +349 -0
- package/src/reputation-repo.ts +295 -0
- package/src/result-rows.ts +13 -0
- package/src/row-values.ts +7 -0
- package/src/schema/content.ts +451 -0
- package/src/schema/identity.ts +699 -0
- package/src/schema/index.ts +10 -0
- package/src/schema/messages.ts +76 -0
- package/src/schema/permission-columns.ts +34 -0
- package/src/schema/platform.ts +376 -0
- package/src/schema/structure.ts +164 -0
- package/src/search-repo.ts +359 -0
- package/src/search-store.ts +113 -0
- package/src/seed-groups.ts +11 -0
- package/src/settings-repo.ts +32 -0
- package/src/signature-repo.ts +102 -0
- package/src/sql-lists.ts +17 -0
- package/src/staff-groups.ts +50 -0
- package/src/stats-repo.ts +151 -0
- package/src/subscription-repo.ts +350 -0
- package/src/system-health-repo.ts +130 -0
- package/src/task-repo.ts +104 -0
- package/src/theme-admin-repo.ts +172 -0
- package/src/theme-repo.ts +43 -0
- package/src/thread-audience.ts +45 -0
- package/src/thread-counters.ts +107 -0
- package/src/thread-repo.ts +255 -0
- package/src/thread-surgery.ts +282 -0
- package/src/thread-tools.ts +311 -0
- package/src/thread-views.ts +41 -0
- package/src/thread-writes.ts +356 -0
- package/src/two-factor-repo.ts +235 -0
- package/src/upgrade-repo.ts +66 -0
- package/src/user-admin-repo.ts +367 -0
- package/src/user-bulk-repo.ts +397 -0
- package/src/user-merge-map.ts +111 -0
- package/src/user-merge-repo.ts +304 -0
- package/src/visibility-counters.ts +190 -0
- package/src/visibility.ts +17 -0
- package/src/vocabulary-repo.ts +54 -0
- package/src/warning-repo.ts +328 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { and, count, desc, eq, isNull, lt, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AuthEventKind,
|
|
5
|
+
AuthEventRecord,
|
|
6
|
+
AuthEventRepository,
|
|
7
|
+
NewAuthEvent,
|
|
8
|
+
RecoveryCodeRepository,
|
|
9
|
+
TwoFactorRecord,
|
|
10
|
+
TwoFactorRepository,
|
|
11
|
+
} from '@meith/accounts'
|
|
12
|
+
|
|
13
|
+
import type { Database } from './client'
|
|
14
|
+
import { resultRows } from './result-rows'
|
|
15
|
+
import { authEvents, recoveryCodes, userTwoFactor } from './schema'
|
|
16
|
+
|
|
17
|
+
const TWO_FACTOR_COLUMNS = {
|
|
18
|
+
userId: userTwoFactor.userId,
|
|
19
|
+
sealedSecret: userTwoFactor.sealedSecret,
|
|
20
|
+
confirmedAt: userTwoFactor.confirmedAt,
|
|
21
|
+
lastStep: userTwoFactor.lastStep,
|
|
22
|
+
createdAt: userTwoFactor.createdAt,
|
|
23
|
+
} as const
|
|
24
|
+
|
|
25
|
+
export class PostgresTwoFactorRepository implements TwoFactorRepository {
|
|
26
|
+
constructor(private readonly db: Database) {}
|
|
27
|
+
|
|
28
|
+
async find(userId: number): Promise<TwoFactorRecord | null> {
|
|
29
|
+
const rows = await this.db
|
|
30
|
+
.select(TWO_FACTOR_COLUMNS)
|
|
31
|
+
.from(userTwoFactor)
|
|
32
|
+
.where(eq(userTwoFactor.userId, userId))
|
|
33
|
+
.limit(1)
|
|
34
|
+
return rows[0] ?? null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async startEnrolment(input: {
|
|
38
|
+
userId: number
|
|
39
|
+
sealedSecret: string
|
|
40
|
+
now: Date
|
|
41
|
+
}): Promise<TwoFactorRecord> {
|
|
42
|
+
const rows = await this.db
|
|
43
|
+
.insert(userTwoFactor)
|
|
44
|
+
.values({
|
|
45
|
+
userId: input.userId,
|
|
46
|
+
sealedSecret: input.sealedSecret,
|
|
47
|
+
confirmedAt: null,
|
|
48
|
+
lastStep: null,
|
|
49
|
+
createdAt: input.now,
|
|
50
|
+
})
|
|
51
|
+
.onConflictDoUpdate({
|
|
52
|
+
target: userTwoFactor.userId,
|
|
53
|
+
set: {
|
|
54
|
+
sealedSecret: input.sealedSecret,
|
|
55
|
+
confirmedAt: null,
|
|
56
|
+
lastStep: null,
|
|
57
|
+
createdAt: input.now,
|
|
58
|
+
},
|
|
59
|
+
setWhere: isNull(userTwoFactor.confirmedAt),
|
|
60
|
+
})
|
|
61
|
+
.returning(TWO_FACTOR_COLUMNS)
|
|
62
|
+
|
|
63
|
+
const record = rows[0] ?? (await this.find(input.userId))
|
|
64
|
+
if (record === null || record === undefined) {
|
|
65
|
+
throw new Error('the two-factor enrolment vanished between insert and read')
|
|
66
|
+
}
|
|
67
|
+
return record
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async confirm(userId: number, step: number, now: Date): Promise<boolean> {
|
|
71
|
+
const rows = await this.db
|
|
72
|
+
.update(userTwoFactor)
|
|
73
|
+
.set({ confirmedAt: now, lastStep: step })
|
|
74
|
+
.where(and(eq(userTwoFactor.userId, userId), isNull(userTwoFactor.confirmedAt)))
|
|
75
|
+
.returning({ userId: userTwoFactor.userId })
|
|
76
|
+
return rows.length > 0
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async spendStep(userId: number, step: number): Promise<boolean> {
|
|
80
|
+
const rows = await this.db
|
|
81
|
+
.update(userTwoFactor)
|
|
82
|
+
.set({ lastStep: step })
|
|
83
|
+
.where(
|
|
84
|
+
and(
|
|
85
|
+
eq(userTwoFactor.userId, userId),
|
|
86
|
+
sql`(${userTwoFactor.lastStep} is null or ${userTwoFactor.lastStep} < ${step})`,
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
.returning({ userId: userTwoFactor.userId })
|
|
90
|
+
return rows.length > 0
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async remove(userId: number): Promise<boolean> {
|
|
94
|
+
const rows = await this.db
|
|
95
|
+
.delete(userTwoFactor)
|
|
96
|
+
.where(eq(userTwoFactor.userId, userId))
|
|
97
|
+
.returning({ userId: userTwoFactor.userId })
|
|
98
|
+
return rows.length > 0
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class PostgresRecoveryCodeRepository implements RecoveryCodeRepository {
|
|
103
|
+
constructor(private readonly db: Database) {}
|
|
104
|
+
|
|
105
|
+
async replaceAll(userId: number, hashes: readonly string[], now: Date): Promise<void> {
|
|
106
|
+
await this.db.transaction(async (tx) => {
|
|
107
|
+
await tx.delete(recoveryCodes).where(eq(recoveryCodes.userId, userId))
|
|
108
|
+
|
|
109
|
+
if (hashes.length === 0) return
|
|
110
|
+
|
|
111
|
+
await tx
|
|
112
|
+
.insert(recoveryCodes)
|
|
113
|
+
.values(hashes.map((codeHash) => ({ userId, codeHash, createdAt: now })))
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async spend(userId: number, hash: string, now: Date): Promise<boolean> {
|
|
118
|
+
const rows = await this.db
|
|
119
|
+
.update(recoveryCodes)
|
|
120
|
+
.set({ usedAt: now })
|
|
121
|
+
.where(
|
|
122
|
+
and(
|
|
123
|
+
eq(recoveryCodes.userId, userId),
|
|
124
|
+
eq(recoveryCodes.codeHash, hash),
|
|
125
|
+
isNull(recoveryCodes.usedAt),
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
.returning({ id: recoveryCodes.id })
|
|
129
|
+
return rows.length > 0
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async countUnused(userId: number): Promise<number> {
|
|
133
|
+
const rows = await this.db
|
|
134
|
+
.select({ total: count() })
|
|
135
|
+
.from(recoveryCodes)
|
|
136
|
+
.where(and(eq(recoveryCodes.userId, userId), isNull(recoveryCodes.usedAt)))
|
|
137
|
+
return Number(rows[0]?.total ?? 0)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async removeAll(userId: number): Promise<void> {
|
|
141
|
+
await this.db.delete(recoveryCodes).where(eq(recoveryCodes.userId, userId))
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const EVENT_COLUMNS = {
|
|
146
|
+
id: authEvents.id,
|
|
147
|
+
userId: authEvents.userId,
|
|
148
|
+
kind: authEvents.kind,
|
|
149
|
+
ipPrefix: authEvents.ipPrefix,
|
|
150
|
+
userAgent: authEvents.userAgent,
|
|
151
|
+
detail: authEvents.detail,
|
|
152
|
+
at: authEvents.at,
|
|
153
|
+
} as const
|
|
154
|
+
|
|
155
|
+
function toEvent(row: {
|
|
156
|
+
id: number
|
|
157
|
+
userId: number | null
|
|
158
|
+
kind: string
|
|
159
|
+
ipPrefix: string | null
|
|
160
|
+
userAgent: string | null
|
|
161
|
+
detail: unknown
|
|
162
|
+
at: Date
|
|
163
|
+
}): AuthEventRecord {
|
|
164
|
+
return {
|
|
165
|
+
id: row.id,
|
|
166
|
+
userId: row.userId,
|
|
167
|
+
kind: row.kind as AuthEventKind,
|
|
168
|
+
ipPrefix: row.ipPrefix,
|
|
169
|
+
userAgent: row.userAgent,
|
|
170
|
+
detail:
|
|
171
|
+
typeof row.detail === 'object' && row.detail !== null
|
|
172
|
+
? (row.detail as Record<string, unknown>)
|
|
173
|
+
: {},
|
|
174
|
+
at: row.at,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export class PostgresAuthEventRepository implements AuthEventRepository {
|
|
179
|
+
constructor(private readonly db: Database) {}
|
|
180
|
+
|
|
181
|
+
async record(event: NewAuthEvent): Promise<void> {
|
|
182
|
+
await this.db.insert(authEvents).values({
|
|
183
|
+
userId: event.userId,
|
|
184
|
+
kind: event.kind,
|
|
185
|
+
ipPrefix: event.ipPrefix ?? null,
|
|
186
|
+
userAgent: event.userAgent ?? null,
|
|
187
|
+
detail: event.detail ?? {},
|
|
188
|
+
at: event.at,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async listForUser(userId: number, limit: number): Promise<readonly AuthEventRecord[]> {
|
|
193
|
+
const rows = await this.db
|
|
194
|
+
.select(EVENT_COLUMNS)
|
|
195
|
+
.from(authEvents)
|
|
196
|
+
.where(eq(authEvents.userId, userId))
|
|
197
|
+
.orderBy(desc(authEvents.id))
|
|
198
|
+
.limit(limit)
|
|
199
|
+
return rows.map(toEvent)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async listRecent(input: {
|
|
203
|
+
limit: number
|
|
204
|
+
before?: number | undefined
|
|
205
|
+
kind?: AuthEventKind | undefined
|
|
206
|
+
}): Promise<readonly AuthEventRecord[]> {
|
|
207
|
+
const filters = [
|
|
208
|
+
input.before === undefined ? undefined : lt(authEvents.id, input.before),
|
|
209
|
+
input.kind === undefined ? undefined : eq(authEvents.kind, input.kind),
|
|
210
|
+
].filter((filter) => filter !== undefined)
|
|
211
|
+
|
|
212
|
+
const rows = await this.db
|
|
213
|
+
.select(EVENT_COLUMNS)
|
|
214
|
+
.from(authEvents)
|
|
215
|
+
.where(filters.length === 0 ? undefined : and(...filters))
|
|
216
|
+
.orderBy(desc(authEvents.id))
|
|
217
|
+
.limit(input.limit)
|
|
218
|
+
return rows.map(toEvent)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async pruneBefore(cutoff: Date, limit = 5000): Promise<number> {
|
|
222
|
+
const result = await this.db.execute(sql`
|
|
223
|
+
delete from ${authEvents}
|
|
224
|
+
where id in (
|
|
225
|
+
select id from ${authEvents}
|
|
226
|
+
where at < ${cutoff}
|
|
227
|
+
order by id
|
|
228
|
+
limit ${limit}
|
|
229
|
+
)
|
|
230
|
+
returning id
|
|
231
|
+
`)
|
|
232
|
+
|
|
233
|
+
return resultRows(result).length
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { Database } from './client'
|
|
4
|
+
|
|
5
|
+
export async function readVersion(db: Database, component: string): Promise<string | null> {
|
|
6
|
+
const rows = (await db.execute(sql`
|
|
7
|
+
select version from component_versions where component = ${component}
|
|
8
|
+
`)) as unknown as { version: string }[]
|
|
9
|
+
return rows[0]?.version ?? null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function readVersions(db: Database): Promise<Readonly<Record<string, string>>> {
|
|
13
|
+
const rows = (await db.execute(sql`
|
|
14
|
+
select component, version from component_versions order by component
|
|
15
|
+
`)) as unknown as { component: string; version: string }[]
|
|
16
|
+
|
|
17
|
+
return Object.fromEntries(rows.map((row) => [row.component, row.version]))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function recordVersion(
|
|
21
|
+
db: Database,
|
|
22
|
+
component: string,
|
|
23
|
+
version: string,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
await db.execute(sql`
|
|
26
|
+
insert into component_versions (component, version, updated_at)
|
|
27
|
+
values (${component}, ${version}, now())
|
|
28
|
+
on conflict (component) do update set version = excluded.version, updated_at = now()
|
|
29
|
+
`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function appliedPluginMigrations(
|
|
33
|
+
db: Database,
|
|
34
|
+
pluginKey: string,
|
|
35
|
+
): Promise<readonly string[]> {
|
|
36
|
+
const rows = (await db.execute(sql`
|
|
37
|
+
select migration_id from plugin_migrations
|
|
38
|
+
where plugin_key = ${pluginKey}
|
|
39
|
+
order by migration_id
|
|
40
|
+
`)) as unknown as { migration_id: string }[]
|
|
41
|
+
|
|
42
|
+
return rows.map((row) => row.migration_id)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function applyPluginMigration(
|
|
46
|
+
db: Database,
|
|
47
|
+
pluginKey: string,
|
|
48
|
+
migrationId: string,
|
|
49
|
+
statements: readonly string[],
|
|
50
|
+
): Promise<boolean> {
|
|
51
|
+
return db.transaction(async (tx) => {
|
|
52
|
+
const claimed = (await tx.execute(sql`
|
|
53
|
+
insert into plugin_migrations (plugin_key, migration_id)
|
|
54
|
+
values (${pluginKey}, ${migrationId})
|
|
55
|
+
on conflict (plugin_key, migration_id) do nothing
|
|
56
|
+
returning migration_id
|
|
57
|
+
`)) as unknown as unknown[]
|
|
58
|
+
|
|
59
|
+
if (claimed.length === 0) return false
|
|
60
|
+
|
|
61
|
+
for (const statement of statements) {
|
|
62
|
+
await tx.execute(sql.raw(statement))
|
|
63
|
+
}
|
|
64
|
+
return true
|
|
65
|
+
})
|
|
66
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { type SQL, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from '@meith/core'
|
|
4
|
+
import { msg } from '@meith/i18n'
|
|
5
|
+
|
|
6
|
+
import type { Database } from './client'
|
|
7
|
+
import { rewriteDenormalisedUsernames } from './denormalised-username'
|
|
8
|
+
import { withPermissionVersionBump } from './permission-version'
|
|
9
|
+
import { resultRows } from './result-rows'
|
|
10
|
+
import { toDate, toNullableDate } from './row-values'
|
|
11
|
+
|
|
12
|
+
export type AccountState = 'active' | 'awaiting_activation' | 'banned'
|
|
13
|
+
|
|
14
|
+
const STATES: readonly AccountState[] = ['active', 'awaiting_activation', 'banned']
|
|
15
|
+
|
|
16
|
+
export const BANNED_PREDICATE: SQL = sql`(u.state = 'banned' or exists (
|
|
17
|
+
select 1 from bans b where b.user_id = u.id and b.lifted_at is null
|
|
18
|
+
))`
|
|
19
|
+
|
|
20
|
+
export interface UserSearchFilter {
|
|
21
|
+
readonly username?: string | undefined
|
|
22
|
+
readonly email?: string | undefined
|
|
23
|
+
readonly ipPrefix?: string | undefined
|
|
24
|
+
readonly primaryGroupId?: number | undefined
|
|
25
|
+
readonly state?: AccountState | undefined
|
|
26
|
+
readonly registeredBefore?: Date | undefined
|
|
27
|
+
readonly registeredAfter?: Date | undefined
|
|
28
|
+
readonly minPostCount?: number | undefined
|
|
29
|
+
readonly maxPostCount?: number | undefined
|
|
30
|
+
readonly includeDeleted?: boolean | undefined
|
|
31
|
+
readonly afterUserId?: number | undefined
|
|
32
|
+
readonly offset?: number | undefined
|
|
33
|
+
readonly limit: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface UserSearchRow {
|
|
37
|
+
readonly id: number
|
|
38
|
+
readonly username: string
|
|
39
|
+
readonly email: string
|
|
40
|
+
readonly state: AccountState
|
|
41
|
+
readonly isBanned: boolean
|
|
42
|
+
readonly primaryGroupId: number
|
|
43
|
+
readonly primaryGroupTitle: string
|
|
44
|
+
readonly postCount: number
|
|
45
|
+
readonly reputation: number
|
|
46
|
+
readonly warningPoints: number
|
|
47
|
+
readonly registrationIpPrefix: string | null
|
|
48
|
+
readonly lastIpPrefix: string | null
|
|
49
|
+
readonly lastActiveAt: Date | null
|
|
50
|
+
readonly createdAt: Date
|
|
51
|
+
readonly deletedAt: Date | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface UserSearchPage {
|
|
55
|
+
readonly rows: readonly UserSearchRow[]
|
|
56
|
+
readonly nextCursor: number | null
|
|
57
|
+
/** How many accounts the filter matches, whichever page was asked for. */
|
|
58
|
+
readonly total: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface UserDetail extends UserSearchRow {
|
|
62
|
+
readonly displayGroupId: number | null
|
|
63
|
+
readonly emailVerifiedAt: Date | null
|
|
64
|
+
readonly passwordChangedAt: Date | null
|
|
65
|
+
readonly threadCount: number
|
|
66
|
+
readonly legacyId: number | null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface UserAccountInput {
|
|
70
|
+
readonly username: string
|
|
71
|
+
readonly email: string
|
|
72
|
+
readonly primaryGroupId: number
|
|
73
|
+
readonly displayGroupId: number | null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function likeFragment(value: string): string {
|
|
77
|
+
return value.replace(/[\\%_]/g, (character) => `\\${character}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fold(value: string): string {
|
|
81
|
+
return value.trim().toLowerCase()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function assertState(state: string): AccountState {
|
|
85
|
+
if (!STATES.includes(state as AccountState)) {
|
|
86
|
+
throw new ValidationError(msg('error.db.such-account-state'))
|
|
87
|
+
}
|
|
88
|
+
return state as AccountState
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function toSearchRow(row: Record<string, unknown>): UserSearchRow {
|
|
92
|
+
return {
|
|
93
|
+
id: Number(row.id),
|
|
94
|
+
username: String(row.username),
|
|
95
|
+
email: String(row.email),
|
|
96
|
+
state: assertState(String(row.state)),
|
|
97
|
+
isBanned: row.is_banned === true || row.is_banned === 't',
|
|
98
|
+
primaryGroupId: Number(row.primary_group_id),
|
|
99
|
+
primaryGroupTitle: String(row.primary_group_title ?? ''),
|
|
100
|
+
postCount: Number(row.post_count),
|
|
101
|
+
reputation: Number(row.reputation),
|
|
102
|
+
warningPoints: Number(row.warning_points),
|
|
103
|
+
registrationIpPrefix:
|
|
104
|
+
row.registration_ip_prefix === null ? null : String(row.registration_ip_prefix),
|
|
105
|
+
lastIpPrefix: row.last_ip_prefix === null ? null : String(row.last_ip_prefix),
|
|
106
|
+
lastActiveAt: toNullableDate(row.last_active_at),
|
|
107
|
+
createdAt: toDate(row.created_at),
|
|
108
|
+
deletedAt: toNullableDate(row.deleted_at),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class PostgresUserAdminRepository {
|
|
113
|
+
constructor(private readonly db: Database) {}
|
|
114
|
+
|
|
115
|
+
async search(filter: UserSearchFilter): Promise<UserSearchPage> {
|
|
116
|
+
const conditions: SQL[] = []
|
|
117
|
+
|
|
118
|
+
if (filter.afterUserId !== undefined && filter.afterUserId > 0) {
|
|
119
|
+
conditions.push(sql`u.id > ${filter.afterUserId}`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (filter.username !== undefined && filter.username.trim() !== '') {
|
|
123
|
+
conditions.push(
|
|
124
|
+
sql`u.username_lower like ${`%${likeFragment(fold(filter.username))}%`} escape '\\'`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
if (filter.email !== undefined && filter.email.trim() !== '') {
|
|
128
|
+
conditions.push(
|
|
129
|
+
sql`u.email_lower like ${`%${likeFragment(fold(filter.email))}%`} escape '\\'`,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
if (filter.ipPrefix !== undefined && filter.ipPrefix.trim() !== '') {
|
|
133
|
+
const pattern = `${likeFragment(filter.ipPrefix.trim())}%`
|
|
134
|
+
conditions.push(
|
|
135
|
+
sql`(u.registration_ip_prefix like ${pattern} escape '\\'
|
|
136
|
+
or u.last_ip_prefix like ${pattern} escape '\\')`,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
if (filter.primaryGroupId !== undefined) {
|
|
140
|
+
conditions.push(sql`u.primary_group_id = ${filter.primaryGroupId}`)
|
|
141
|
+
}
|
|
142
|
+
if (filter.state !== undefined) {
|
|
143
|
+
const state = assertState(filter.state)
|
|
144
|
+
conditions.push(
|
|
145
|
+
state === 'banned' ? BANNED_PREDICATE : sql`u.state = ${state} and not ${BANNED_PREDICATE}`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
if (filter.registeredAfter !== undefined) {
|
|
149
|
+
conditions.push(sql`u.created_at >= ${filter.registeredAfter}`)
|
|
150
|
+
}
|
|
151
|
+
if (filter.registeredBefore !== undefined) {
|
|
152
|
+
conditions.push(sql`u.created_at < ${filter.registeredBefore}`)
|
|
153
|
+
}
|
|
154
|
+
if (filter.minPostCount !== undefined) {
|
|
155
|
+
conditions.push(sql`u.post_count >= ${filter.minPostCount}`)
|
|
156
|
+
}
|
|
157
|
+
if (filter.maxPostCount !== undefined) {
|
|
158
|
+
conditions.push(sql`u.post_count <= ${filter.maxPostCount}`)
|
|
159
|
+
}
|
|
160
|
+
if (filter.includeDeleted !== true) {
|
|
161
|
+
conditions.push(sql`u.deleted_at is null`)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const where = conditions.length === 0 ? sql`true` : sql.join(conditions, sql` and `)
|
|
165
|
+
const offset = filter.offset ?? 0
|
|
166
|
+
|
|
167
|
+
const [listed, counted] = await Promise.all([
|
|
168
|
+
this.db.execute(sql`
|
|
169
|
+
select u.id, u.username, u.email, u.state, u.primary_group_id,
|
|
170
|
+
g.title as primary_group_title,
|
|
171
|
+
u.post_count, u.reputation, u.warning_points,
|
|
172
|
+
u.registration_ip_prefix, u.last_ip_prefix,
|
|
173
|
+
u.last_active_at, u.created_at, u.deleted_at,
|
|
174
|
+
${BANNED_PREDICATE} as is_banned
|
|
175
|
+
from users u
|
|
176
|
+
join usergroups g on g.id = u.primary_group_id
|
|
177
|
+
where ${where}
|
|
178
|
+
order by u.id
|
|
179
|
+
limit ${filter.limit} offset ${offset}
|
|
180
|
+
`),
|
|
181
|
+
this.db.execute(sql`
|
|
182
|
+
select count(*)::int as total
|
|
183
|
+
from users u
|
|
184
|
+
where ${where}
|
|
185
|
+
`),
|
|
186
|
+
])
|
|
187
|
+
|
|
188
|
+
const mapped = (resultRows(listed) as Array<Record<string, unknown>>).map(toSearchRow)
|
|
189
|
+
const total = Number((resultRows(counted) as Array<Record<string, unknown>>)[0]?.total ?? 0)
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
rows: mapped,
|
|
193
|
+
nextCursor: mapped.length < filter.limit ? null : (mapped[mapped.length - 1]?.id ?? null),
|
|
194
|
+
total,
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async readDetail(userId: number): Promise<UserDetail | null> {
|
|
199
|
+
const rows = resultRows(
|
|
200
|
+
await this.db.execute(sql`
|
|
201
|
+
select u.*, g.title as primary_group_title,
|
|
202
|
+
${BANNED_PREDICATE} as is_banned
|
|
203
|
+
from users u
|
|
204
|
+
join usergroups g on g.id = u.primary_group_id
|
|
205
|
+
where u.id = ${userId}
|
|
206
|
+
`),
|
|
207
|
+
) as Array<Record<string, unknown>>
|
|
208
|
+
|
|
209
|
+
const row = rows[0]
|
|
210
|
+
if (row === undefined) return null
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
...toSearchRow(row),
|
|
214
|
+
displayGroupId: row.display_group_id === null ? null : Number(row.display_group_id),
|
|
215
|
+
emailVerifiedAt: toNullableDate(row.email_verified_at),
|
|
216
|
+
passwordChangedAt: toNullableDate(row.password_changed_at),
|
|
217
|
+
threadCount: Number(row.thread_count),
|
|
218
|
+
legacyId: row.legacy_id === null ? null : Number(row.legacy_id),
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async updateAccount(userId: number, input: UserAccountInput): Promise<void> {
|
|
223
|
+
const username = input.username.trim()
|
|
224
|
+
const email = input.email.trim()
|
|
225
|
+
if (username === '') throw new ValidationError(msg('error.db.member-needs-username'))
|
|
226
|
+
if (email === '') throw new ValidationError(msg('error.db.member-needs-email-address'))
|
|
227
|
+
|
|
228
|
+
const primaryGroupId = input.primaryGroupId
|
|
229
|
+
|
|
230
|
+
await withPermissionVersionBump(this.db, async (tx) => {
|
|
231
|
+
const named = resultRows(
|
|
232
|
+
await tx.execute(sql`select username from users where id = ${userId}`),
|
|
233
|
+
) as Array<{ username: string }>
|
|
234
|
+
|
|
235
|
+
const previous = named[0]?.username
|
|
236
|
+
|
|
237
|
+
const rows = resultRows(
|
|
238
|
+
await tx.execute(sql`
|
|
239
|
+
update users
|
|
240
|
+
set username = ${username},
|
|
241
|
+
username_lower = ${fold(username)},
|
|
242
|
+
email = ${email},
|
|
243
|
+
email_lower = ${fold(email)},
|
|
244
|
+
primary_group_id = ${primaryGroupId},
|
|
245
|
+
display_group_id = ${input.displayGroupId},
|
|
246
|
+
updated_at = now()
|
|
247
|
+
where id = ${userId}
|
|
248
|
+
returning id
|
|
249
|
+
`),
|
|
250
|
+
) as Array<{ id: number }>
|
|
251
|
+
|
|
252
|
+
if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-member'))
|
|
253
|
+
|
|
254
|
+
if (previous !== username) {
|
|
255
|
+
await rewriteDenormalisedUsernames(tx, userId, username)
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async setState(userId: number, state: AccountState): Promise<void> {
|
|
261
|
+
if (state === 'banned') {
|
|
262
|
+
throw new ValidationError(msg('error.db.bans-issued-through-ban-screen'))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
await withPermissionVersionBump(this.db, async (tx) => {
|
|
266
|
+
const rows = resultRows(
|
|
267
|
+
await tx.execute(sql`
|
|
268
|
+
update users u set state = ${assertState(state)}, updated_at = now()
|
|
269
|
+
where u.id = ${userId} and not ${BANNED_PREDICATE}
|
|
270
|
+
returning u.id
|
|
271
|
+
`),
|
|
272
|
+
) as Array<{ id: number }>
|
|
273
|
+
|
|
274
|
+
if (rows[0] === undefined) {
|
|
275
|
+
throw new ValidationError(msg('error.db.such-member-member-under-ban'))
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async sharingIpPrefix(
|
|
281
|
+
prefix: string,
|
|
282
|
+
excludeUserId: number,
|
|
283
|
+
limit = 50,
|
|
284
|
+
): Promise<readonly UserSearchRow[]> {
|
|
285
|
+
if (prefix.trim() === '') return []
|
|
286
|
+
|
|
287
|
+
const pattern = `${likeFragment(prefix.trim())}%`
|
|
288
|
+
const rows = resultRows(
|
|
289
|
+
await this.db.execute(sql`
|
|
290
|
+
select u.id, u.username, u.email, u.state, u.primary_group_id,
|
|
291
|
+
g.title as primary_group_title,
|
|
292
|
+
u.post_count, u.reputation, u.warning_points,
|
|
293
|
+
u.registration_ip_prefix, u.last_ip_prefix,
|
|
294
|
+
u.last_active_at, u.created_at, u.deleted_at
|
|
295
|
+
from users u
|
|
296
|
+
join usergroups g on g.id = u.primary_group_id
|
|
297
|
+
where u.id <> ${excludeUserId}
|
|
298
|
+
and (u.registration_ip_prefix like ${pattern} escape '\\'
|
|
299
|
+
or u.last_ip_prefix like ${pattern} escape '\\')
|
|
300
|
+
order by u.id
|
|
301
|
+
limit ${limit}
|
|
302
|
+
`),
|
|
303
|
+
) as Array<Record<string, unknown>>
|
|
304
|
+
|
|
305
|
+
return rows.map(toSearchRow)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async readSecondaryGroups(userId: number): Promise<readonly number[]> {
|
|
309
|
+
const rows = resultRows(
|
|
310
|
+
await this.db.execute(sql`
|
|
311
|
+
select group_id from user_group_memberships
|
|
312
|
+
where user_id = ${userId}
|
|
313
|
+
order by group_id
|
|
314
|
+
`),
|
|
315
|
+
) as Array<{ group_id: number }>
|
|
316
|
+
|
|
317
|
+
return rows.map((row) => Number(row.group_id))
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async setSecondaryGroups(
|
|
321
|
+
userId: number,
|
|
322
|
+
groupIds: readonly number[],
|
|
323
|
+
grantedByUserId: number | null,
|
|
324
|
+
): Promise<void> {
|
|
325
|
+
await withPermissionVersionBump(this.db, async (tx) => {
|
|
326
|
+
const rows = resultRows(
|
|
327
|
+
await tx.execute(sql`select primary_group_id from users where id = ${userId}`),
|
|
328
|
+
) as Array<Record<string, unknown>>
|
|
329
|
+
|
|
330
|
+
if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-member'))
|
|
331
|
+
const primary = Number(rows[0].primary_group_id)
|
|
332
|
+
|
|
333
|
+
const wanted = new Set([...groupIds].filter((id) => id !== primary))
|
|
334
|
+
|
|
335
|
+
const held = resultRows(
|
|
336
|
+
await tx.execute(sql`
|
|
337
|
+
select group_id from user_group_memberships where user_id = ${userId}
|
|
338
|
+
`),
|
|
339
|
+
) as Array<{ group_id: number }>
|
|
340
|
+
const existing = new Set(held.map((row) => Number(row.group_id)))
|
|
341
|
+
|
|
342
|
+
for (const groupId of existing) {
|
|
343
|
+
if (wanted.has(groupId) || groupId === primary) continue
|
|
344
|
+
await tx.execute(sql`
|
|
345
|
+
delete from user_group_memberships
|
|
346
|
+
where user_id = ${userId} and group_id = ${groupId}
|
|
347
|
+
`)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
for (const groupId of wanted) {
|
|
351
|
+
if (existing.has(groupId)) continue
|
|
352
|
+
await tx.execute(sql`
|
|
353
|
+
insert into user_group_memberships (user_id, group_id, granted_by_user_id)
|
|
354
|
+
values (${userId}, ${groupId}, ${grantedByUserId})
|
|
355
|
+
`)
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async listGroups(): Promise<readonly { readonly id: number; readonly title: string }[]> {
|
|
361
|
+
const rows = resultRows(
|
|
362
|
+
await this.db.execute(sql`select id, title from usergroups order by display_order, id`),
|
|
363
|
+
) as Array<Record<string, unknown>>
|
|
364
|
+
|
|
365
|
+
return rows.map((row) => ({ id: Number(row.id), title: String(row.title) }))
|
|
366
|
+
}
|
|
367
|
+
}
|