@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,240 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AttachmentForDownload,
|
|
5
|
+
AttachmentRecord,
|
|
6
|
+
AttachmentRepository,
|
|
7
|
+
AttachmentStatus,
|
|
8
|
+
CreateAttachmentInput,
|
|
9
|
+
ReadyInput,
|
|
10
|
+
} from '@meith/attachments'
|
|
11
|
+
|
|
12
|
+
import type { Database } from './client'
|
|
13
|
+
import { forgetOrphanKeys, rememberOrphanKey } from './orphan-keys'
|
|
14
|
+
import { resultRows } from './result-rows'
|
|
15
|
+
import { toDate } from './row-values'
|
|
16
|
+
|
|
17
|
+
interface RawAttachment {
|
|
18
|
+
id: number
|
|
19
|
+
post_id: number | null
|
|
20
|
+
forum_id: number
|
|
21
|
+
uploader_user_id: number | null
|
|
22
|
+
filename: string
|
|
23
|
+
content_type: string
|
|
24
|
+
size_bytes: number
|
|
25
|
+
storage_key: string | null
|
|
26
|
+
source_key: string | null
|
|
27
|
+
thumbnail_key: string | null
|
|
28
|
+
width: number | null
|
|
29
|
+
height: number | null
|
|
30
|
+
status: string
|
|
31
|
+
failure_reason: string | null
|
|
32
|
+
download_count: number
|
|
33
|
+
created_at: string | Date
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const COLUMNS = sql`
|
|
37
|
+
id, post_id, forum_id, uploader_user_id, filename, content_type, size_bytes,
|
|
38
|
+
storage_key, source_key, thumbnail_key, width, height, status,
|
|
39
|
+
failure_reason, download_count, created_at
|
|
40
|
+
`
|
|
41
|
+
|
|
42
|
+
function toRecord(row: RawAttachment): AttachmentRecord {
|
|
43
|
+
return {
|
|
44
|
+
id: Number(row.id),
|
|
45
|
+
postId: row.post_id === null ? null : Number(row.post_id),
|
|
46
|
+
forumId: Number(row.forum_id),
|
|
47
|
+
uploaderUserId: row.uploader_user_id === null ? null : Number(row.uploader_user_id),
|
|
48
|
+
filename: row.filename,
|
|
49
|
+
contentType: row.content_type,
|
|
50
|
+
sizeBytes: Number(row.size_bytes),
|
|
51
|
+
storageKey: row.storage_key,
|
|
52
|
+
sourceKey: row.source_key,
|
|
53
|
+
thumbnailKey: row.thumbnail_key,
|
|
54
|
+
width: row.width === null ? null : Number(row.width),
|
|
55
|
+
height: row.height === null ? null : Number(row.height),
|
|
56
|
+
status: (['pending', 'ready', 'failed'] as const).includes(row.status as AttachmentStatus)
|
|
57
|
+
? (row.status as AttachmentStatus)
|
|
58
|
+
: 'failed',
|
|
59
|
+
failureReason: row.failure_reason,
|
|
60
|
+
downloadCount: Number(row.download_count),
|
|
61
|
+
createdAt: toDate(row.created_at),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class PostgresAttachmentRepository implements AttachmentRepository {
|
|
66
|
+
constructor(private readonly db: Database) {}
|
|
67
|
+
|
|
68
|
+
async create(input: CreateAttachmentInput): Promise<AttachmentRecord> {
|
|
69
|
+
const rows = resultRows(
|
|
70
|
+
await this.db.execute(sql`
|
|
71
|
+
insert into attachments
|
|
72
|
+
(post_id, forum_id, uploader_user_id, filename, content_type,
|
|
73
|
+
size_bytes, storage_key, source_key, status, ready_at)
|
|
74
|
+
values (${input.postId}, ${input.forumId}, ${input.uploaderUserId},
|
|
75
|
+
${input.filename}, ${input.contentType}, ${input.sizeBytes},
|
|
76
|
+
${input.storageKey}, ${input.sourceKey}, ${input.status},
|
|
77
|
+
${input.status === 'ready' ? sql`now()` : sql`null`})
|
|
78
|
+
returning ${COLUMNS}
|
|
79
|
+
`),
|
|
80
|
+
) as RawAttachment[]
|
|
81
|
+
|
|
82
|
+
if (rows[0] === undefined) throw new Error('Attachment insert returned no row')
|
|
83
|
+
return toRecord(rows[0])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async findById(id: number): Promise<AttachmentRecord | null> {
|
|
87
|
+
const rows = resultRows(
|
|
88
|
+
await this.db.execute(sql`select ${COLUMNS} from attachments where id = ${id}`),
|
|
89
|
+
) as RawAttachment[]
|
|
90
|
+
return rows[0] === undefined ? null : toRecord(rows[0])
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async findForDownload(id: number): Promise<AttachmentForDownload | null> {
|
|
94
|
+
const rows = resultRows(
|
|
95
|
+
await this.db.execute(sql`
|
|
96
|
+
select a.id, a.post_id, a.forum_id, a.uploader_user_id, a.filename,
|
|
97
|
+
a.content_type, a.size_bytes, a.storage_key, a.source_key,
|
|
98
|
+
a.thumbnail_key, a.width, a.height, a.status, a.failure_reason,
|
|
99
|
+
a.download_count, a.created_at,
|
|
100
|
+
p.visibility as post_visibility, t.visibility as thread_visibility,
|
|
101
|
+
t.author_user_id as thread_author_user_id
|
|
102
|
+
from attachments a
|
|
103
|
+
join posts p on p.id = a.post_id
|
|
104
|
+
join threads t on t.id = p.thread_id
|
|
105
|
+
where a.id = ${id}
|
|
106
|
+
`),
|
|
107
|
+
) as Array<
|
|
108
|
+
RawAttachment & {
|
|
109
|
+
post_visibility: string
|
|
110
|
+
thread_visibility: string
|
|
111
|
+
thread_author_user_id: number | null
|
|
112
|
+
}
|
|
113
|
+
>
|
|
114
|
+
|
|
115
|
+
const row = rows[0]
|
|
116
|
+
if (row === undefined) return null
|
|
117
|
+
return {
|
|
118
|
+
record: toRecord(row),
|
|
119
|
+
postVisibility: row.post_visibility,
|
|
120
|
+
threadVisibility: row.thread_visibility,
|
|
121
|
+
threadAuthorUserId:
|
|
122
|
+
row.thread_author_user_id === null ? null : Number(row.thread_author_user_id),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async listForPosts(postIds: readonly number[]): Promise<readonly AttachmentRecord[]> {
|
|
127
|
+
if (postIds.length === 0) return []
|
|
128
|
+
|
|
129
|
+
const rows = resultRows(
|
|
130
|
+
await this.db.execute(sql`
|
|
131
|
+
select ${COLUMNS} from attachments
|
|
132
|
+
where post_id in ${sql`(${sql.join(
|
|
133
|
+
postIds.map((id) => sql`${id}`),
|
|
134
|
+
sql`, `,
|
|
135
|
+
)})`}
|
|
136
|
+
order by post_id, id
|
|
137
|
+
`),
|
|
138
|
+
) as RawAttachment[]
|
|
139
|
+
|
|
140
|
+
return rows.map(toRecord)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async countForPost(postId: number): Promise<number> {
|
|
144
|
+
const rows = resultRows(
|
|
145
|
+
await this.db.execute(
|
|
146
|
+
sql`select count(*)::int as n from attachments where post_id = ${postId}`,
|
|
147
|
+
),
|
|
148
|
+
) as Array<{ n: number }>
|
|
149
|
+
return Number(rows[0]?.n ?? 0)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async markReady(id: number, input: ReadyInput): Promise<void> {
|
|
153
|
+
await this.db.execute(sql`
|
|
154
|
+
update attachments
|
|
155
|
+
set storage_key = ${input.storageKey},
|
|
156
|
+
thumbnail_key = ${input.thumbnailKey},
|
|
157
|
+
width = ${input.width},
|
|
158
|
+
height = ${input.height},
|
|
159
|
+
size_bytes = ${input.sizeBytes},
|
|
160
|
+
source_key = null,
|
|
161
|
+
status = 'ready',
|
|
162
|
+
ready_at = now()
|
|
163
|
+
where id = ${id} and status = 'pending'
|
|
164
|
+
`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async markFailed(id: number, reason: string): Promise<void> {
|
|
168
|
+
await this.db.execute(sql`
|
|
169
|
+
update attachments
|
|
170
|
+
set status = 'failed', failure_reason = ${reason}, source_key = null
|
|
171
|
+
where id = ${id} and status = 'pending'
|
|
172
|
+
`)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async recordDownload(id: number): Promise<void> {
|
|
176
|
+
await this.db.execute(sql`
|
|
177
|
+
update attachments set download_count = download_count + 1 where id = ${id}
|
|
178
|
+
`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async attachTo(id: number, postId: number): Promise<AttachmentRecord | null> {
|
|
182
|
+
const rows = resultRows(
|
|
183
|
+
await this.db.execute(sql`
|
|
184
|
+
update attachments
|
|
185
|
+
set post_id = ${postId}
|
|
186
|
+
where id = ${id} and post_id is null
|
|
187
|
+
returning ${COLUMNS}
|
|
188
|
+
`),
|
|
189
|
+
) as RawAttachment[]
|
|
190
|
+
return rows[0] === undefined ? null : toRecord(rows[0])
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async deleteOrphans(before: Date, limit: number): Promise<readonly AttachmentRecord[]> {
|
|
194
|
+
const rows = resultRows(
|
|
195
|
+
await this.db.execute(sql`
|
|
196
|
+
delete from attachments
|
|
197
|
+
where id in (
|
|
198
|
+
select id from attachments
|
|
199
|
+
where post_id is null and created_at < ${before}
|
|
200
|
+
order by created_at
|
|
201
|
+
limit ${limit}
|
|
202
|
+
)
|
|
203
|
+
returning ${COLUMNS}
|
|
204
|
+
`),
|
|
205
|
+
) as RawAttachment[]
|
|
206
|
+
return rows.map(toRecord)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async stalled(before: Date, limit: number): Promise<readonly AttachmentRecord[]> {
|
|
210
|
+
const rows = resultRows(
|
|
211
|
+
await this.db.execute(sql`
|
|
212
|
+
select ${COLUMNS} from attachments
|
|
213
|
+
where status = 'pending' and created_at < ${before}
|
|
214
|
+
order by created_at
|
|
215
|
+
limit ${limit}
|
|
216
|
+
`),
|
|
217
|
+
) as RawAttachment[]
|
|
218
|
+
return rows.map(toRecord)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async rememberKey(key: string): Promise<void> {
|
|
222
|
+
await rememberOrphanKey(this.db, key)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async forgetKeys(keys: readonly string[]): Promise<void> {
|
|
226
|
+
await forgetOrphanKeys(this.db, keys)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async staleKeys(before: Date, limit: number): Promise<readonly string[]> {
|
|
230
|
+
const rows = resultRows(
|
|
231
|
+
await this.db.execute(sql`
|
|
232
|
+
select storage_key from attachment_orphans
|
|
233
|
+
where created_at < ${before}
|
|
234
|
+
order by created_at
|
|
235
|
+
limit ${limit}
|
|
236
|
+
`),
|
|
237
|
+
) as Array<{ storage_key: string }>
|
|
238
|
+
return rows.map((row) => row.storage_key)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { and, inArray, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AuthorizationSource,
|
|
5
|
+
ForumOverride,
|
|
6
|
+
GroupDefaults,
|
|
7
|
+
ModeratorAppointment,
|
|
8
|
+
} from '@meith/authorization'
|
|
9
|
+
|
|
10
|
+
import type { Database } from './client'
|
|
11
|
+
import { forumRowToOverride, groupRowToPermissionSet } from './permissions-map'
|
|
12
|
+
import { resultRows } from './result-rows'
|
|
13
|
+
import { forumPermissions, forums, usergroups } from './schema'
|
|
14
|
+
|
|
15
|
+
export function parseAncestorPath(path: string): number[] {
|
|
16
|
+
const ids = path
|
|
17
|
+
.split('.')
|
|
18
|
+
.map((segment) => Number(segment))
|
|
19
|
+
.filter((n) => Number.isInteger(n) && n > 0)
|
|
20
|
+
ids.reverse()
|
|
21
|
+
return ids
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class PostgresAuthorizationSource implements AuthorizationSource {
|
|
25
|
+
constructor(private readonly db: Database) {}
|
|
26
|
+
|
|
27
|
+
async groupDefaults(groupIds: readonly number[]): Promise<readonly GroupDefaults[]> {
|
|
28
|
+
if (groupIds.length === 0) return []
|
|
29
|
+
|
|
30
|
+
const rows = await this.db
|
|
31
|
+
.select()
|
|
32
|
+
.from(usergroups)
|
|
33
|
+
.where(inArray(usergroups.id, [...groupIds]))
|
|
34
|
+
|
|
35
|
+
return rows.map((row) => ({
|
|
36
|
+
groupId: row.id,
|
|
37
|
+
permissions: groupRowToPermissionSet(row as Record<string, unknown>),
|
|
38
|
+
}))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async ancestorChain(forumId: number): Promise<readonly number[]> {
|
|
42
|
+
const rows = await this.db
|
|
43
|
+
.select({ path: forums.path })
|
|
44
|
+
.from(forums)
|
|
45
|
+
.where(inArray(forums.id, [forumId]))
|
|
46
|
+
.limit(1)
|
|
47
|
+
|
|
48
|
+
const row = rows[0]
|
|
49
|
+
if (!row) return []
|
|
50
|
+
return parseAncestorPath(row.path)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async forumOverrides(
|
|
54
|
+
forumIds: readonly number[],
|
|
55
|
+
groupIds: readonly number[],
|
|
56
|
+
): Promise<readonly ForumOverride[]> {
|
|
57
|
+
if (forumIds.length === 0 || groupIds.length === 0) return []
|
|
58
|
+
|
|
59
|
+
const rows = await this.db
|
|
60
|
+
.select()
|
|
61
|
+
.from(forumPermissions)
|
|
62
|
+
.where(
|
|
63
|
+
and(
|
|
64
|
+
inArray(forumPermissions.forumId, [...forumIds]),
|
|
65
|
+
inArray(forumPermissions.groupId, [...groupIds]),
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return rows.map((row) => ({
|
|
70
|
+
forumId: row.forumId,
|
|
71
|
+
groupId: row.groupId,
|
|
72
|
+
overrides: forumRowToOverride(row as Record<string, unknown>),
|
|
73
|
+
}))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async allForumIds(): Promise<readonly number[]> {
|
|
77
|
+
const rows = await this.db.select({ id: forums.id }).from(forums)
|
|
78
|
+
return rows.map((r) => r.id)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async allAncestorChains(): Promise<ReadonlyMap<number, readonly number[]>> {
|
|
82
|
+
const rows = await this.db.select({ id: forums.id, path: forums.path }).from(forums)
|
|
83
|
+
|
|
84
|
+
return new Map(rows.map((r) => [r.id, parseAncestorPath(r.path)]))
|
|
85
|
+
}
|
|
86
|
+
async moderatorAppointments(
|
|
87
|
+
userId: number | null,
|
|
88
|
+
groupIds: readonly number[],
|
|
89
|
+
): Promise<readonly ModeratorAppointment[]> {
|
|
90
|
+
if (userId === null && groupIds.length === 0) return []
|
|
91
|
+
|
|
92
|
+
const groupList =
|
|
93
|
+
groupIds.length === 0
|
|
94
|
+
? sql`(null)`
|
|
95
|
+
: sql`(${sql.join(
|
|
96
|
+
groupIds.map((id) => sql`${id}`),
|
|
97
|
+
sql`, `,
|
|
98
|
+
)})`
|
|
99
|
+
|
|
100
|
+
const rows = resultRows(
|
|
101
|
+
await this.db.execute(sql`
|
|
102
|
+
select forum_id, cascade_to_subforums, can_approve_content,
|
|
103
|
+
can_edit_posts, can_soft_delete_posts, can_restore_posts,
|
|
104
|
+
can_open_close_threads, can_stick_threads, can_move_threads,
|
|
105
|
+
can_merge_threads, can_split_threads
|
|
106
|
+
from forum_moderators
|
|
107
|
+
where (user_id is not null and user_id = ${userId})
|
|
108
|
+
or (group_id is not null and group_id in ${groupList})
|
|
109
|
+
`),
|
|
110
|
+
) as Array<{
|
|
111
|
+
forum_id: number
|
|
112
|
+
cascade_to_subforums: boolean
|
|
113
|
+
can_approve_content: boolean
|
|
114
|
+
can_edit_posts: boolean
|
|
115
|
+
can_soft_delete_posts: boolean
|
|
116
|
+
can_restore_posts: boolean
|
|
117
|
+
can_open_close_threads: boolean
|
|
118
|
+
can_stick_threads: boolean
|
|
119
|
+
can_move_threads: boolean
|
|
120
|
+
can_merge_threads: boolean
|
|
121
|
+
can_split_threads: boolean
|
|
122
|
+
}>
|
|
123
|
+
|
|
124
|
+
return rows.map((row) => ({
|
|
125
|
+
forumId: Number(row.forum_id),
|
|
126
|
+
cascadeToSubforums: row.cascade_to_subforums,
|
|
127
|
+
canApproveContent: row.can_approve_content,
|
|
128
|
+
canEditPosts: row.can_edit_posts,
|
|
129
|
+
canSoftDeletePosts: row.can_soft_delete_posts,
|
|
130
|
+
canRestorePosts: row.can_restore_posts,
|
|
131
|
+
canOpenCloseThreads: row.can_open_close_threads,
|
|
132
|
+
canStickThreads: row.can_stick_threads,
|
|
133
|
+
canMoveThreads: row.can_move_threads,
|
|
134
|
+
canMergeThreads: row.can_merge_threads,
|
|
135
|
+
canSplitThreads: row.can_split_threads,
|
|
136
|
+
}))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { AvatarRepository, AvatarStatus, StoredAvatar } from '@meith/avatars'
|
|
4
|
+
|
|
5
|
+
import type { Database } from './client'
|
|
6
|
+
import { forgetOrphanKeys, rememberOrphanKey } from './orphan-keys'
|
|
7
|
+
import { resultRows } from './result-rows'
|
|
8
|
+
import { toNullableDate } from './row-values'
|
|
9
|
+
|
|
10
|
+
interface RawAvatar {
|
|
11
|
+
id?: number
|
|
12
|
+
avatar_status: string
|
|
13
|
+
avatar_key: string | null
|
|
14
|
+
avatar_source_key: string | null
|
|
15
|
+
avatar_width: number | null
|
|
16
|
+
avatar_height: number | null
|
|
17
|
+
avatar_failure_reason: string | null
|
|
18
|
+
avatar_updated_at: string | Date | null
|
|
19
|
+
avatar_locked: boolean
|
|
20
|
+
avatar_locked_reason: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const STATUSES = ['none', 'pending', 'ready', 'failed'] as const
|
|
24
|
+
|
|
25
|
+
function toAvatar(row: RawAvatar): StoredAvatar {
|
|
26
|
+
return {
|
|
27
|
+
status: STATUSES.includes(row.avatar_status as AvatarStatus)
|
|
28
|
+
? (row.avatar_status as AvatarStatus)
|
|
29
|
+
: 'failed',
|
|
30
|
+
key: row.avatar_key,
|
|
31
|
+
sourceKey: row.avatar_source_key,
|
|
32
|
+
width: row.avatar_width === null ? null : Number(row.avatar_width),
|
|
33
|
+
height: row.avatar_height === null ? null : Number(row.avatar_height),
|
|
34
|
+
failureReason: row.avatar_failure_reason,
|
|
35
|
+
updatedAt: toNullableDate(row.avatar_updated_at),
|
|
36
|
+
locked: row.avatar_locked === true,
|
|
37
|
+
lockedReason: row.avatar_locked_reason,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const COLUMNS = sql`
|
|
42
|
+
avatar_status, avatar_key, avatar_source_key, avatar_width, avatar_height,
|
|
43
|
+
avatar_failure_reason, avatar_updated_at, avatar_locked, avatar_locked_reason
|
|
44
|
+
`
|
|
45
|
+
|
|
46
|
+
function droppedKeys(row: { avatar_key: string | null; avatar_source_key: string | null }) {
|
|
47
|
+
return [row.avatar_key, row.avatar_source_key].filter((key): key is string => key !== null)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class PostgresAvatarRepository implements AvatarRepository {
|
|
51
|
+
constructor(private readonly db: Database) {}
|
|
52
|
+
|
|
53
|
+
async find(userId: number): Promise<StoredAvatar | null> {
|
|
54
|
+
const rows = resultRows(
|
|
55
|
+
await this.db.execute(sql`select ${COLUMNS} from users where id = ${userId}`),
|
|
56
|
+
) as RawAvatar[]
|
|
57
|
+
return rows[0] === undefined ? null : toAvatar(rows[0])
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async readMany(userIds: readonly number[]): Promise<ReadonlyMap<number, StoredAvatar>> {
|
|
61
|
+
if (userIds.length === 0) return new Map()
|
|
62
|
+
|
|
63
|
+
const rows = resultRows(
|
|
64
|
+
await this.db.execute(sql`
|
|
65
|
+
select id, ${COLUMNS} from users
|
|
66
|
+
where id in ${sql`(${sql.join(
|
|
67
|
+
userIds.map((id) => sql`${id}`),
|
|
68
|
+
sql`, `,
|
|
69
|
+
)})`}
|
|
70
|
+
`),
|
|
71
|
+
) as RawAvatar[]
|
|
72
|
+
|
|
73
|
+
return new Map(rows.map((row) => [Number(row.id), toAvatar(row)]))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async beginUpload(input: {
|
|
77
|
+
readonly userId: number
|
|
78
|
+
readonly sourceKey: string
|
|
79
|
+
readonly at: Date
|
|
80
|
+
}): Promise<{ replaced: readonly string[] }> {
|
|
81
|
+
const rows = resultRows(
|
|
82
|
+
await this.db.execute(sql`
|
|
83
|
+
update users u
|
|
84
|
+
set avatar_source_key = ${input.sourceKey},
|
|
85
|
+
avatar_key = null,
|
|
86
|
+
avatar_status = 'pending',
|
|
87
|
+
avatar_failure_reason = null,
|
|
88
|
+
avatar_width = null,
|
|
89
|
+
avatar_height = null,
|
|
90
|
+
avatar_updated_at = ${input.at}
|
|
91
|
+
from users old
|
|
92
|
+
where u.id = ${input.userId}
|
|
93
|
+
and old.id = u.id
|
|
94
|
+
and u.avatar_locked = false
|
|
95
|
+
returning old.avatar_key, old.avatar_source_key
|
|
96
|
+
`),
|
|
97
|
+
) as Array<{ avatar_key: string | null; avatar_source_key: string | null }>
|
|
98
|
+
|
|
99
|
+
const row = rows[0]
|
|
100
|
+
if (row === undefined) return { replaced: [input.sourceKey] }
|
|
101
|
+
return { replaced: droppedKeys(row) }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async markReady(input: {
|
|
105
|
+
readonly userId: number
|
|
106
|
+
readonly key: string
|
|
107
|
+
readonly width: number
|
|
108
|
+
readonly height: number
|
|
109
|
+
}): Promise<void> {
|
|
110
|
+
await this.db.execute(sql`
|
|
111
|
+
update users
|
|
112
|
+
set avatar_key = ${input.key},
|
|
113
|
+
avatar_source_key = null,
|
|
114
|
+
avatar_width = ${input.width},
|
|
115
|
+
avatar_height = ${input.height},
|
|
116
|
+
avatar_status = 'ready'
|
|
117
|
+
where id = ${input.userId} and avatar_status = 'pending'
|
|
118
|
+
`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async markFailed(userId: number, reason: string): Promise<void> {
|
|
122
|
+
await this.db.execute(sql`
|
|
123
|
+
update users
|
|
124
|
+
set avatar_status = 'failed',
|
|
125
|
+
avatar_failure_reason = ${reason},
|
|
126
|
+
avatar_source_key = null
|
|
127
|
+
where id = ${userId} and avatar_status = 'pending'
|
|
128
|
+
`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async clear(userId: number): Promise<{ replaced: readonly string[] }> {
|
|
132
|
+
const rows = resultRows(
|
|
133
|
+
await this.db.execute(sql`
|
|
134
|
+
update users u
|
|
135
|
+
set avatar_key = null,
|
|
136
|
+
avatar_source_key = null,
|
|
137
|
+
avatar_status = 'none',
|
|
138
|
+
avatar_failure_reason = null,
|
|
139
|
+
avatar_width = null,
|
|
140
|
+
avatar_height = null,
|
|
141
|
+
avatar_updated_at = null
|
|
142
|
+
from users old
|
|
143
|
+
where u.id = ${userId}
|
|
144
|
+
and old.id = u.id
|
|
145
|
+
and u.avatar_locked = false
|
|
146
|
+
returning old.avatar_key, old.avatar_source_key
|
|
147
|
+
`),
|
|
148
|
+
) as Array<{ avatar_key: string | null; avatar_source_key: string | null }>
|
|
149
|
+
|
|
150
|
+
const row = rows[0]
|
|
151
|
+
return { replaced: row === undefined ? [] : droppedKeys(row) }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async lock(input: {
|
|
155
|
+
readonly userId: number
|
|
156
|
+
readonly locked: boolean
|
|
157
|
+
readonly reason: string | null
|
|
158
|
+
}): Promise<void> {
|
|
159
|
+
await this.db.execute(sql`
|
|
160
|
+
update users
|
|
161
|
+
set avatar_locked = ${input.locked}, avatar_locked_reason = ${input.reason}
|
|
162
|
+
where id = ${input.userId}
|
|
163
|
+
`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async stalled(before: Date, limit: number): Promise<readonly number[]> {
|
|
167
|
+
const rows = resultRows(
|
|
168
|
+
await this.db.execute(sql`
|
|
169
|
+
select id from users
|
|
170
|
+
where avatar_status = 'pending' and avatar_updated_at < ${before}
|
|
171
|
+
order by avatar_updated_at
|
|
172
|
+
limit ${limit}
|
|
173
|
+
`),
|
|
174
|
+
) as Array<{ id: number }>
|
|
175
|
+
return rows.map((row) => Number(row.id))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async rememberKey(key: string): Promise<void> {
|
|
179
|
+
await rememberOrphanKey(this.db, key)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async forgetKeys(keys: readonly string[]): Promise<void> {
|
|
183
|
+
await forgetOrphanKeys(this.db, keys)
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/ban-repos.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { and, asc, eq, isNotNull, isNull, lte, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
BanFilter,
|
|
5
|
+
BanFilterRepository,
|
|
6
|
+
BanFilterType,
|
|
7
|
+
BanRecord,
|
|
8
|
+
BanRepository,
|
|
9
|
+
CreateBanInput,
|
|
10
|
+
} from '@meith/accounts'
|
|
11
|
+
|
|
12
|
+
import type { Database } from './client'
|
|
13
|
+
import { banFilters, bans, rememberTokens, sessions, users } from './schema'
|
|
14
|
+
|
|
15
|
+
const BAN_COLUMNS = {
|
|
16
|
+
id: bans.id,
|
|
17
|
+
userId: bans.userId,
|
|
18
|
+
reason: bans.reason,
|
|
19
|
+
publicReason: bans.publicReason,
|
|
20
|
+
previousPrimaryGroupId: bans.previousPrimaryGroupId,
|
|
21
|
+
expiresAt: bans.expiresAt,
|
|
22
|
+
liftedAt: bans.liftedAt,
|
|
23
|
+
} as const
|
|
24
|
+
|
|
25
|
+
type Tx = Parameters<Parameters<Database['transaction']>[0]>[0]
|
|
26
|
+
|
|
27
|
+
async function liftWithin(tx: Tx, ban: BanRecord, now: Date): Promise<void> {
|
|
28
|
+
await tx.update(bans).set({ liftedAt: now }).where(eq(bans.id, ban.id))
|
|
29
|
+
|
|
30
|
+
if (ban.previousPrimaryGroupId !== null) {
|
|
31
|
+
await tx
|
|
32
|
+
.update(users)
|
|
33
|
+
.set({
|
|
34
|
+
primaryGroupId: ban.previousPrimaryGroupId,
|
|
35
|
+
displayGroupId: ban.previousPrimaryGroupId,
|
|
36
|
+
})
|
|
37
|
+
.where(eq(users.id, ban.userId))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class PostgresBanRepository implements BanRepository {
|
|
42
|
+
constructor(private readonly db: Database) {}
|
|
43
|
+
|
|
44
|
+
async findActive(userId: number): Promise<BanRecord | null> {
|
|
45
|
+
const rows = await this.db
|
|
46
|
+
.select(BAN_COLUMNS)
|
|
47
|
+
.from(bans)
|
|
48
|
+
.where(and(eq(bans.userId, userId), isNull(bans.liftedAt)))
|
|
49
|
+
.limit(1)
|
|
50
|
+
|
|
51
|
+
return rows[0] ?? null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async create(input: CreateBanInput): Promise<BanRecord> {
|
|
55
|
+
return this.db.transaction(async (tx) => {
|
|
56
|
+
const [target] = await tx
|
|
57
|
+
.select({
|
|
58
|
+
primaryGroupId: users.primaryGroupId,
|
|
59
|
+
})
|
|
60
|
+
.from(users)
|
|
61
|
+
.where(eq(users.id, input.userId))
|
|
62
|
+
.limit(1)
|
|
63
|
+
|
|
64
|
+
if (!target) throw new Error(`Cannot ban unknown user ${input.userId}`)
|
|
65
|
+
|
|
66
|
+
const [row] = await tx
|
|
67
|
+
.insert(bans)
|
|
68
|
+
.values({
|
|
69
|
+
userId: input.userId,
|
|
70
|
+
bannedByUserId: input.bannedByUserId,
|
|
71
|
+
reason: input.reason,
|
|
72
|
+
publicReason: input.publicReason,
|
|
73
|
+
previousPrimaryGroupId: target.primaryGroupId,
|
|
74
|
+
expiresAt: input.expiresAt,
|
|
75
|
+
createdAt: input.now,
|
|
76
|
+
})
|
|
77
|
+
.returning(BAN_COLUMNS)
|
|
78
|
+
|
|
79
|
+
await tx
|
|
80
|
+
.update(users)
|
|
81
|
+
.set({ primaryGroupId: input.bannedGroupId, displayGroupId: input.bannedGroupId })
|
|
82
|
+
.where(eq(users.id, input.userId))
|
|
83
|
+
|
|
84
|
+
await tx
|
|
85
|
+
.update(sessions)
|
|
86
|
+
.set({ revokedAt: input.now })
|
|
87
|
+
.where(and(eq(sessions.userId, input.userId), isNull(sessions.revokedAt)))
|
|
88
|
+
|
|
89
|
+
await tx
|
|
90
|
+
.update(rememberTokens)
|
|
91
|
+
.set({ revokedAt: input.now, revokedReason: 'account_banned' })
|
|
92
|
+
.where(and(eq(rememberTokens.userId, input.userId), isNull(rememberTokens.revokedAt)))
|
|
93
|
+
|
|
94
|
+
return row as BanRecord
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async lift(banId: number, now: Date): Promise<void> {
|
|
99
|
+
await this.db.transaction(async (tx) => {
|
|
100
|
+
const [ban] = await tx
|
|
101
|
+
.select(BAN_COLUMNS)
|
|
102
|
+
.from(bans)
|
|
103
|
+
.where(and(eq(bans.id, banId), isNull(bans.liftedAt)))
|
|
104
|
+
.limit(1)
|
|
105
|
+
|
|
106
|
+
if (!ban) return
|
|
107
|
+
await liftWithin(tx, ban, now)
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async expireDue(now: Date, limit: number): Promise<number> {
|
|
112
|
+
return this.db.transaction(async (tx) => {
|
|
113
|
+
const due = await tx
|
|
114
|
+
.select(BAN_COLUMNS)
|
|
115
|
+
.from(bans)
|
|
116
|
+
.where(and(isNull(bans.liftedAt), isNotNull(bans.expiresAt), lte(bans.expiresAt, now)))
|
|
117
|
+
.orderBy(asc(bans.expiresAt))
|
|
118
|
+
.limit(limit)
|
|
119
|
+
.for('update', { skipLocked: true })
|
|
120
|
+
|
|
121
|
+
for (const ban of due) await liftWithin(tx, ban, now)
|
|
122
|
+
|
|
123
|
+
if (due.length > 0) {
|
|
124
|
+
await tx.execute(sql`
|
|
125
|
+
insert into cache_versions (key, version)
|
|
126
|
+
values ('permissions', 1)
|
|
127
|
+
on conflict (key) do update set version = cache_versions.version + 1, bumped_at = now()
|
|
128
|
+
`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return due.length
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export class PostgresBanFilterRepository implements BanFilterRepository {
|
|
137
|
+
constructor(private readonly db: Database) {}
|
|
138
|
+
|
|
139
|
+
async listAll(): Promise<readonly BanFilter[]> {
|
|
140
|
+
const rows = await this.db
|
|
141
|
+
.select({ id: banFilters.id, type: banFilters.type, pattern: banFilters.pattern })
|
|
142
|
+
.from(banFilters)
|
|
143
|
+
|
|
144
|
+
return rows.map((r) => ({ id: r.id, type: r.type as BanFilterType, pattern: r.pattern }))
|
|
145
|
+
}
|
|
146
|
+
}
|