@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,145 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { CaptchaQuestion, RateLimitStore, RateLimitSubject } from '@meith/antispam'
|
|
4
|
+
import { ValidationError } from '@meith/core'
|
|
5
|
+
import { msg } from '@meith/i18n'
|
|
6
|
+
|
|
7
|
+
import type { Database } from './client'
|
|
8
|
+
import { resultRows } from './result-rows'
|
|
9
|
+
|
|
10
|
+
export class PostgresRateLimitBucketStore implements RateLimitStore {
|
|
11
|
+
constructor(private readonly db: Database) {}
|
|
12
|
+
|
|
13
|
+
async spend(subject: RateLimitSubject, windowStart: Date, cost: number): Promise<number> {
|
|
14
|
+
const rows = resultRows(
|
|
15
|
+
await this.db.execute(sql`
|
|
16
|
+
insert into rate_limits (scope, subject, window_start, used)
|
|
17
|
+
values (${subject.scope}, ${subject.subject}, ${windowStart}, ${cost})
|
|
18
|
+
on conflict (scope, subject, window_start)
|
|
19
|
+
do update set used = rate_limits.used + ${cost}
|
|
20
|
+
returning used
|
|
21
|
+
`),
|
|
22
|
+
) as Array<{ used: number }>
|
|
23
|
+
|
|
24
|
+
return Number(rows[0]?.used ?? cost)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async peek(subject: RateLimitSubject, windowStart: Date): Promise<number> {
|
|
28
|
+
const rows = resultRows(
|
|
29
|
+
await this.db.execute(sql`
|
|
30
|
+
select used from rate_limits
|
|
31
|
+
where scope = ${subject.scope}
|
|
32
|
+
and subject = ${subject.subject}
|
|
33
|
+
and window_start = ${windowStart}
|
|
34
|
+
`),
|
|
35
|
+
) as Array<{ used: number }>
|
|
36
|
+
|
|
37
|
+
return Number(rows[0]?.used ?? 0)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async prune(before: Date, limit = 5000): Promise<number> {
|
|
41
|
+
const rows = resultRows(
|
|
42
|
+
await this.db.execute(sql`
|
|
43
|
+
delete from rate_limits
|
|
44
|
+
where ctid in (
|
|
45
|
+
select ctid from rate_limits where window_start < ${before} limit ${limit}
|
|
46
|
+
)
|
|
47
|
+
returning subject
|
|
48
|
+
`),
|
|
49
|
+
) as Array<unknown>
|
|
50
|
+
|
|
51
|
+
return rows.length
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CaptchaQuestionRow extends CaptchaQuestion {
|
|
56
|
+
readonly enabled: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function splitAnswers(raw: string): readonly string[] {
|
|
60
|
+
return raw
|
|
61
|
+
.split('\n')
|
|
62
|
+
.map((answer) => answer.trim())
|
|
63
|
+
.filter((answer) => answer !== '')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class PostgresCaptchaQuestionRepository {
|
|
67
|
+
constructor(private readonly db: Database) {}
|
|
68
|
+
|
|
69
|
+
async active(): Promise<readonly CaptchaQuestion[]> {
|
|
70
|
+
const rows = resultRows(
|
|
71
|
+
await this.db.execute(sql`
|
|
72
|
+
select id, question, answers from captcha_questions
|
|
73
|
+
where enabled = true and question <> '' and answers <> ''
|
|
74
|
+
order by id
|
|
75
|
+
`),
|
|
76
|
+
) as Array<Record<string, unknown>>
|
|
77
|
+
|
|
78
|
+
return rows
|
|
79
|
+
.map((row) => ({
|
|
80
|
+
id: Number(row.id),
|
|
81
|
+
question: String(row.question),
|
|
82
|
+
answers: splitAnswers(String(row.answers)),
|
|
83
|
+
}))
|
|
84
|
+
.filter((question) => question.answers.length > 0)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async list(): Promise<readonly CaptchaQuestionRow[]> {
|
|
88
|
+
const rows = resultRows(
|
|
89
|
+
await this.db.execute(sql`
|
|
90
|
+
select id, question, answers, enabled from captcha_questions order by id
|
|
91
|
+
`),
|
|
92
|
+
) as Array<Record<string, unknown>>
|
|
93
|
+
|
|
94
|
+
return rows.map((row) => ({
|
|
95
|
+
id: Number(row.id),
|
|
96
|
+
question: String(row.question),
|
|
97
|
+
answers: splitAnswers(String(row.answers)),
|
|
98
|
+
enabled: row.enabled === true,
|
|
99
|
+
}))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async create(input: { readonly question: string; readonly answers: string }): Promise<number> {
|
|
103
|
+
assertUsable(input.question, input.answers)
|
|
104
|
+
|
|
105
|
+
const rows = resultRows(
|
|
106
|
+
await this.db.execute(sql`
|
|
107
|
+
insert into captcha_questions (question, answers)
|
|
108
|
+
values (${input.question.trim()}, ${input.answers})
|
|
109
|
+
returning id
|
|
110
|
+
`),
|
|
111
|
+
) as Array<{ id: number }>
|
|
112
|
+
|
|
113
|
+
return Number(rows[0]?.id)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async update(
|
|
117
|
+
id: number,
|
|
118
|
+
input: { readonly question: string; readonly answers: string; readonly enabled: boolean },
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
assertUsable(input.question, input.answers)
|
|
121
|
+
|
|
122
|
+
const rows = resultRows(
|
|
123
|
+
await this.db.execute(sql`
|
|
124
|
+
update captcha_questions
|
|
125
|
+
set question = ${input.question.trim()}, answers = ${input.answers},
|
|
126
|
+
enabled = ${input.enabled}
|
|
127
|
+
where id = ${id}
|
|
128
|
+
returning id
|
|
129
|
+
`),
|
|
130
|
+
) as Array<{ id: number }>
|
|
131
|
+
|
|
132
|
+
if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-question'))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async delete(id: number): Promise<void> {
|
|
136
|
+
await this.db.execute(sql`delete from captcha_questions where id = ${id}`)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function assertUsable(question: string, answers: string): void {
|
|
141
|
+
if (question.trim() === '') throw new ValidationError(msg('error.db.question-needs-asked'))
|
|
142
|
+
if (splitAnswers(answers).length === 0) {
|
|
143
|
+
throw new ValidationError(msg('error.db.question-needs-at-least-one'))
|
|
144
|
+
}
|
|
145
|
+
}
|
package/src/api-repo.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { ApiTokenRecord, RateLimitStore, Scope } from '@meith/api'
|
|
4
|
+
|
|
5
|
+
import type { Database } from './client'
|
|
6
|
+
|
|
7
|
+
interface TokenRow {
|
|
8
|
+
id: number
|
|
9
|
+
user_id: number
|
|
10
|
+
name: string
|
|
11
|
+
lookup: string
|
|
12
|
+
secret_hash: string
|
|
13
|
+
scopes: unknown
|
|
14
|
+
expires_at: Date | null
|
|
15
|
+
revoked_at: Date | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseScopes(raw: unknown, known: (value: string) => value is Scope): readonly Scope[] {
|
|
19
|
+
if (!Array.isArray(raw)) return []
|
|
20
|
+
return raw.filter((value): value is Scope => typeof value === 'string' && known(value))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ApiTokenSummary {
|
|
24
|
+
readonly id: number
|
|
25
|
+
readonly userId: number
|
|
26
|
+
readonly username: string
|
|
27
|
+
readonly name: string
|
|
28
|
+
readonly lookup: string
|
|
29
|
+
readonly scopes: readonly Scope[]
|
|
30
|
+
readonly createdAt: Date
|
|
31
|
+
readonly expiresAt: Date | null
|
|
32
|
+
readonly revokedAt: Date | null
|
|
33
|
+
readonly lastUsedAt: Date | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class PostgresApiTokenRepository {
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly db: Database,
|
|
39
|
+
private readonly isScope: (value: string) => value is Scope,
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
async findByLookup(lookup: string): Promise<ApiTokenRecord | null> {
|
|
43
|
+
const rows = (await this.db.execute(sql`
|
|
44
|
+
select id, user_id, name, lookup, secret_hash, scopes, expires_at, revoked_at
|
|
45
|
+
from api_tokens
|
|
46
|
+
where lookup = ${lookup}
|
|
47
|
+
limit 1
|
|
48
|
+
`)) as unknown as TokenRow[]
|
|
49
|
+
|
|
50
|
+
const row = rows[0]
|
|
51
|
+
if (row === undefined) return null
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
id: row.id,
|
|
55
|
+
userId: row.user_id,
|
|
56
|
+
name: row.name,
|
|
57
|
+
lookup: row.lookup,
|
|
58
|
+
secretHash: row.secret_hash,
|
|
59
|
+
scopes: parseScopes(row.scopes, this.isScope),
|
|
60
|
+
expiresAt: row.expires_at,
|
|
61
|
+
revokedAt: row.revoked_at,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async listAll(limit = 200): Promise<readonly ApiTokenSummary[]> {
|
|
66
|
+
const rows = (await this.db.execute(sql`
|
|
67
|
+
select t.id, t.user_id, u.username, t.name, t.lookup, t.scopes,
|
|
68
|
+
t.created_at, t.expires_at, t.revoked_at, t.last_used_at
|
|
69
|
+
from api_tokens t
|
|
70
|
+
join users u on u.id = t.user_id
|
|
71
|
+
order by t.revoked_at nulls first, t.created_at desc
|
|
72
|
+
limit ${limit}
|
|
73
|
+
`)) as unknown as Array<{
|
|
74
|
+
id: number
|
|
75
|
+
user_id: number
|
|
76
|
+
username: string
|
|
77
|
+
name: string
|
|
78
|
+
lookup: string
|
|
79
|
+
scopes: unknown
|
|
80
|
+
created_at: Date | string
|
|
81
|
+
expires_at: Date | string | null
|
|
82
|
+
revoked_at: Date | string | null
|
|
83
|
+
last_used_at: Date | string | null
|
|
84
|
+
}>
|
|
85
|
+
|
|
86
|
+
return rows.map((row) => ({
|
|
87
|
+
id: row.id,
|
|
88
|
+
userId: row.user_id,
|
|
89
|
+
username: row.username,
|
|
90
|
+
name: row.name,
|
|
91
|
+
lookup: row.lookup,
|
|
92
|
+
scopes: parseScopes(row.scopes, this.isScope),
|
|
93
|
+
createdAt: new Date(row.created_at),
|
|
94
|
+
expiresAt: row.expires_at === null ? null : new Date(row.expires_at),
|
|
95
|
+
revokedAt: row.revoked_at === null ? null : new Date(row.revoked_at),
|
|
96
|
+
lastUsedAt: row.last_used_at === null ? null : new Date(row.last_used_at),
|
|
97
|
+
}))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async create(input: {
|
|
101
|
+
readonly userId: number
|
|
102
|
+
readonly name: string
|
|
103
|
+
readonly lookup: string
|
|
104
|
+
readonly secretHash: string
|
|
105
|
+
readonly scopes: readonly Scope[]
|
|
106
|
+
readonly expiresAt: Date | null
|
|
107
|
+
}): Promise<number> {
|
|
108
|
+
const rows = (await this.db.execute(sql`
|
|
109
|
+
insert into api_tokens (user_id, name, lookup, secret_hash, scopes, expires_at)
|
|
110
|
+
values (${input.userId}, ${input.name}, ${input.lookup}, ${input.secretHash},
|
|
111
|
+
${JSON.stringify(input.scopes)}, ${input.expiresAt})
|
|
112
|
+
returning id
|
|
113
|
+
`)) as unknown as Array<{ id: number }>
|
|
114
|
+
|
|
115
|
+
return rows[0]!.id
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async revoke(id: number, at: Date): Promise<boolean> {
|
|
119
|
+
const rows = (await this.db.execute(sql`
|
|
120
|
+
update api_tokens set revoked_at = ${at}
|
|
121
|
+
where id = ${id} and revoked_at is null
|
|
122
|
+
returning id
|
|
123
|
+
`)) as unknown as Array<{ id: number }>
|
|
124
|
+
|
|
125
|
+
return rows.length > 0
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async touch(id: number, at: Date): Promise<void> {
|
|
129
|
+
await this.db.execute(sql`
|
|
130
|
+
update api_tokens
|
|
131
|
+
set last_used_at = ${at}
|
|
132
|
+
where id = ${id}
|
|
133
|
+
and (last_used_at is null or last_used_at < ${new Date(at.getTime() - 60_000)})
|
|
134
|
+
`)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class PostgresRateLimitStore implements RateLimitStore {
|
|
139
|
+
constructor(private readonly db: Database) {}
|
|
140
|
+
|
|
141
|
+
async spend(tokenId: number, windowStart: Date, cost: number): Promise<number> {
|
|
142
|
+
const rows = (await this.db.execute(sql`
|
|
143
|
+
insert into api_rate_limits (token_id, window_start, used)
|
|
144
|
+
values (${tokenId}, ${windowStart}, ${cost})
|
|
145
|
+
on conflict (token_id, window_start)
|
|
146
|
+
do update set used = api_rate_limits.used + ${cost}
|
|
147
|
+
returning used
|
|
148
|
+
`)) as unknown as { used: number }[]
|
|
149
|
+
|
|
150
|
+
return rows[0]?.used ?? cost
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async peek(tokenId: number, windowStart: Date): Promise<number> {
|
|
154
|
+
const rows = (await this.db.execute(sql`
|
|
155
|
+
select used from api_rate_limits
|
|
156
|
+
where token_id = ${tokenId} and window_start = ${windowStart}
|
|
157
|
+
`)) as unknown as { used: number }[]
|
|
158
|
+
|
|
159
|
+
return rows[0]?.used ?? 0
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async prune(before: Date, limit = 5000): Promise<number> {
|
|
163
|
+
const rows = (await this.db.execute(sql`
|
|
164
|
+
delete from api_rate_limits
|
|
165
|
+
where ctid in (
|
|
166
|
+
select ctid from api_rate_limits where window_start < ${before} limit ${limit}
|
|
167
|
+
)
|
|
168
|
+
returning token_id
|
|
169
|
+
`)) as unknown as unknown[]
|
|
170
|
+
return rows.length
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface WebhookDeliveryRow {
|
|
175
|
+
readonly id: number
|
|
176
|
+
readonly webhookId: number
|
|
177
|
+
readonly deliveryId: string
|
|
178
|
+
readonly topic: string
|
|
179
|
+
readonly payload: Record<string, unknown>
|
|
180
|
+
readonly attempts: number
|
|
181
|
+
readonly url: string
|
|
182
|
+
readonly secret: string
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export class PostgresWebhookRepository {
|
|
186
|
+
constructor(private readonly db: Database) {}
|
|
187
|
+
|
|
188
|
+
async enqueue(
|
|
189
|
+
topic: string,
|
|
190
|
+
deliveryId: string,
|
|
191
|
+
payload: Record<string, unknown>,
|
|
192
|
+
): Promise<number> {
|
|
193
|
+
const rows = (await this.db.execute(sql`
|
|
194
|
+
insert into webhook_deliveries (webhook_id, delivery_id, topic, payload)
|
|
195
|
+
select w.id, ${deliveryId}, ${topic}, ${JSON.stringify(payload)}::jsonb
|
|
196
|
+
from webhooks w
|
|
197
|
+
where w.active = true and w.topics ? ${topic}
|
|
198
|
+
on conflict (webhook_id, delivery_id) do nothing
|
|
199
|
+
returning id
|
|
200
|
+
`)) as unknown as unknown[]
|
|
201
|
+
|
|
202
|
+
return rows.length
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async claimDue(now: Date, limit: number): Promise<readonly WebhookDeliveryRow[]> {
|
|
206
|
+
const rows = (await this.db.execute(sql`
|
|
207
|
+
with due as (
|
|
208
|
+
select d.id
|
|
209
|
+
from webhook_deliveries d
|
|
210
|
+
where d.status = 'pending' and d.next_attempt_at <= ${now}
|
|
211
|
+
order by d.next_attempt_at
|
|
212
|
+
limit ${limit}
|
|
213
|
+
for update skip locked
|
|
214
|
+
)
|
|
215
|
+
update webhook_deliveries d
|
|
216
|
+
set attempts = d.attempts + 1
|
|
217
|
+
from due, webhooks w
|
|
218
|
+
where d.id = due.id and w.id = d.webhook_id
|
|
219
|
+
returning d.id, d.webhook_id, d.delivery_id, d.topic, d.payload, d.attempts, w.url, w.secret
|
|
220
|
+
`)) as unknown as {
|
|
221
|
+
id: number
|
|
222
|
+
webhook_id: number
|
|
223
|
+
delivery_id: string
|
|
224
|
+
topic: string
|
|
225
|
+
payload: Record<string, unknown>
|
|
226
|
+
attempts: number
|
|
227
|
+
url: string
|
|
228
|
+
secret: string
|
|
229
|
+
}[]
|
|
230
|
+
|
|
231
|
+
return rows.map((row) => ({
|
|
232
|
+
id: row.id,
|
|
233
|
+
webhookId: row.webhook_id,
|
|
234
|
+
deliveryId: row.delivery_id,
|
|
235
|
+
topic: row.topic,
|
|
236
|
+
payload: row.payload,
|
|
237
|
+
attempts: row.attempts,
|
|
238
|
+
url: row.url,
|
|
239
|
+
secret: row.secret,
|
|
240
|
+
}))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async markDelivered(id: number, status: number, at: Date): Promise<void> {
|
|
244
|
+
await this.db.execute(sql`
|
|
245
|
+
update webhook_deliveries
|
|
246
|
+
set status = 'delivered', last_status_code = ${status}, completed_at = ${at}, last_error = null
|
|
247
|
+
where id = ${id}
|
|
248
|
+
`)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async scheduleRetry(id: number, at: Date, status: number | null, error: string): Promise<void> {
|
|
252
|
+
await this.db.execute(sql`
|
|
253
|
+
update webhook_deliveries
|
|
254
|
+
set next_attempt_at = ${at}, last_status_code = ${status}, last_error = ${error}
|
|
255
|
+
where id = ${id}
|
|
256
|
+
`)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async markDead(id: number, status: number | null, error: string, at: Date): Promise<void> {
|
|
260
|
+
await this.db.execute(sql`
|
|
261
|
+
update webhook_deliveries
|
|
262
|
+
set status = 'dead', last_status_code = ${status}, last_error = ${error}, completed_at = ${at}
|
|
263
|
+
where id = ${id}
|
|
264
|
+
`)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async retryDead(id: number, at: Date): Promise<boolean> {
|
|
268
|
+
const rows = (await this.db.execute(sql`
|
|
269
|
+
update webhook_deliveries
|
|
270
|
+
set status = 'pending', attempts = 0, next_attempt_at = ${at}, last_error = null
|
|
271
|
+
where id = ${id} and status = 'dead'
|
|
272
|
+
returning id
|
|
273
|
+
`)) as unknown as unknown[]
|
|
274
|
+
return rows.length > 0
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { Database } from './client'
|
|
4
|
+
import { resultRows } from './result-rows'
|
|
5
|
+
import { likeFragment } from './user-admin-repo'
|
|
6
|
+
|
|
7
|
+
export interface AttachmentAdminRow {
|
|
8
|
+
readonly id: number
|
|
9
|
+
readonly postId: number
|
|
10
|
+
readonly threadSlug: string | null
|
|
11
|
+
readonly filename: string
|
|
12
|
+
readonly contentType: string
|
|
13
|
+
readonly sizeBytes: number
|
|
14
|
+
readonly status: string
|
|
15
|
+
readonly failureReason: string | null
|
|
16
|
+
readonly downloadCount: number
|
|
17
|
+
readonly uploaderUserId: number | null
|
|
18
|
+
readonly uploaderUsername: string | null
|
|
19
|
+
readonly createdAt: Date
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AttachmentAdminFilter {
|
|
23
|
+
readonly filename?: string | undefined
|
|
24
|
+
readonly status?: string | undefined
|
|
25
|
+
readonly uploaderUserId?: number | undefined
|
|
26
|
+
readonly beforeId?: number | undefined
|
|
27
|
+
readonly offset?: number | undefined
|
|
28
|
+
readonly limit: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AttachmentAdminPage {
|
|
32
|
+
readonly rows: readonly AttachmentAdminRow[]
|
|
33
|
+
readonly nextBeforeId: number | null
|
|
34
|
+
/** How many attachments the filter matches, whichever page was asked for. */
|
|
35
|
+
readonly total: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AttachmentTotals {
|
|
39
|
+
readonly count: number
|
|
40
|
+
readonly bytes: number
|
|
41
|
+
readonly pending: number
|
|
42
|
+
readonly failed: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const STATUSES = new Set(['pending', 'ready', 'failed'])
|
|
46
|
+
|
|
47
|
+
export class PostgresAttachmentAdminRepository {
|
|
48
|
+
constructor(private readonly db: Database) {}
|
|
49
|
+
|
|
50
|
+
async list(filter: AttachmentAdminFilter): Promise<AttachmentAdminPage> {
|
|
51
|
+
const conditions = [sql`true`]
|
|
52
|
+
|
|
53
|
+
if (filter.filename !== undefined && filter.filename !== '') {
|
|
54
|
+
conditions.push(sql`a.filename ilike ${`%${likeFragment(filter.filename)}%`} escape '\\'`)
|
|
55
|
+
}
|
|
56
|
+
if (filter.status !== undefined && STATUSES.has(filter.status)) {
|
|
57
|
+
conditions.push(sql`a.status = ${filter.status}`)
|
|
58
|
+
}
|
|
59
|
+
if (filter.uploaderUserId !== undefined) {
|
|
60
|
+
conditions.push(sql`a.uploader_user_id = ${filter.uploaderUserId}`)
|
|
61
|
+
}
|
|
62
|
+
if (filter.beforeId !== undefined) {
|
|
63
|
+
conditions.push(sql`a.id < ${filter.beforeId}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const where = sql.join(conditions, sql` and `)
|
|
67
|
+
|
|
68
|
+
const [listed, counted] = await Promise.all([
|
|
69
|
+
this.db.execute(sql`
|
|
70
|
+
select a.id, a.post_id, a.filename, a.content_type, a.size_bytes, a.status,
|
|
71
|
+
a.failure_reason, a.download_count, a.uploader_user_id, a.created_at,
|
|
72
|
+
u.username as uploader_username, t.slug as thread_slug
|
|
73
|
+
from attachments a
|
|
74
|
+
left join users u on u.id = a.uploader_user_id
|
|
75
|
+
left join posts p on p.id = a.post_id
|
|
76
|
+
left join threads t on t.id = p.thread_id
|
|
77
|
+
where ${where}
|
|
78
|
+
order by a.id desc
|
|
79
|
+
limit ${filter.limit + 1} offset ${filter.offset ?? 0}
|
|
80
|
+
`),
|
|
81
|
+
this.db.execute(sql`
|
|
82
|
+
select count(*)::int as total from attachments a where ${where}
|
|
83
|
+
`),
|
|
84
|
+
])
|
|
85
|
+
|
|
86
|
+
const rows = resultRows(listed) as Array<Record<string, unknown>>
|
|
87
|
+
|
|
88
|
+
const page = rows.slice(0, filter.limit).map(
|
|
89
|
+
(row): AttachmentAdminRow => ({
|
|
90
|
+
id: Number(row.id),
|
|
91
|
+
postId: Number(row.post_id),
|
|
92
|
+
threadSlug: row.thread_slug === null ? null : String(row.thread_slug),
|
|
93
|
+
filename: String(row.filename),
|
|
94
|
+
contentType: String(row.content_type),
|
|
95
|
+
sizeBytes: Number(row.size_bytes),
|
|
96
|
+
status: String(row.status),
|
|
97
|
+
failureReason: row.failure_reason === null ? null : String(row.failure_reason),
|
|
98
|
+
downloadCount: Number(row.download_count),
|
|
99
|
+
uploaderUserId: row.uploader_user_id === null ? null : Number(row.uploader_user_id),
|
|
100
|
+
uploaderUsername: row.uploader_username === null ? null : String(row.uploader_username),
|
|
101
|
+
createdAt: new Date(String(row.created_at)),
|
|
102
|
+
}),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const total = Number((resultRows(counted) as Array<Record<string, unknown>>)[0]?.total ?? 0)
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
rows: page,
|
|
109
|
+
nextBeforeId: rows.length > filter.limit ? (page[page.length - 1]?.id ?? null) : null,
|
|
110
|
+
total,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async totals(): Promise<AttachmentTotals> {
|
|
115
|
+
const rows = resultRows(
|
|
116
|
+
await this.db.execute(sql`
|
|
117
|
+
select count(*)::int as count,
|
|
118
|
+
coalesce(sum(size_bytes), 0)::bigint as bytes,
|
|
119
|
+
count(*) filter (where status = 'pending')::int as pending,
|
|
120
|
+
count(*) filter (where status = 'failed')::int as failed
|
|
121
|
+
from attachments
|
|
122
|
+
`),
|
|
123
|
+
) as Array<Record<string, unknown>>
|
|
124
|
+
|
|
125
|
+
const row = rows[0]
|
|
126
|
+
return {
|
|
127
|
+
count: Number(row?.count ?? 0),
|
|
128
|
+
bytes: Number(row?.bytes ?? 0),
|
|
129
|
+
pending: Number(row?.pending ?? 0),
|
|
130
|
+
failed: Number(row?.failed ?? 0),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async delete(id: number): Promise<boolean> {
|
|
135
|
+
return this.db.transaction(async (tx) => {
|
|
136
|
+
const rows = resultRows(
|
|
137
|
+
await tx.execute(sql`
|
|
138
|
+
delete from attachments
|
|
139
|
+
where id = ${id}
|
|
140
|
+
returning storage_key, source_key, thumbnail_key
|
|
141
|
+
`),
|
|
142
|
+
) as Array<Record<string, unknown>>
|
|
143
|
+
|
|
144
|
+
const row = rows[0]
|
|
145
|
+
if (row === undefined) return false
|
|
146
|
+
|
|
147
|
+
const keys = [row.storage_key, row.source_key, row.thumbnail_key].filter(
|
|
148
|
+
(key): key is string => typeof key === 'string' && key !== '',
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
for (const key of keys) {
|
|
152
|
+
await tx.execute(sql`
|
|
153
|
+
insert into attachment_orphans (storage_key) values (${key})
|
|
154
|
+
on conflict (storage_key) do nothing
|
|
155
|
+
`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return true
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|