@meith/db 0.30.1 → 0.32.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/migrations/0060_webhook_payload_format.sql +1 -0
- package/migrations/0061_task_schedule.sql +1 -0
- package/migrations/0062_pretty_zombie.sql +12 -0
- package/migrations/0063_board_digest.sql +3 -0
- package/migrations/0064_redundant_lady_bullseye.sql +2 -0
- package/migrations/0065_serious_puff_adder.sql +1 -0
- package/migrations/meta/0061_snapshot.json +9515 -0
- package/migrations/meta/0062_snapshot.json +9617 -0
- package/migrations/meta/0063_snapshot.json +9653 -0
- package/migrations/meta/0064_snapshot.json +9667 -0
- package/migrations/meta/0065_snapshot.json +9674 -0
- package/migrations/meta/_journal.json +42 -0
- package/package.json +31 -30
- package/src/account-repos.ts +32 -15
- package/src/api-repo.ts +192 -21
- package/src/attachment-repo.ts +26 -0
- package/src/board-digest-repo.ts +45 -0
- package/src/content-counters.ts +14 -0
- package/src/discovery-repo.ts +22 -0
- package/src/draft-repo.ts +40 -1
- package/src/feed-token-repo.ts +103 -0
- package/src/forum-admin-repo.ts +43 -18
- package/src/index.ts +10 -0
- package/src/member-settings-repo.ts +25 -1
- package/src/moderation-queue.ts +61 -21
- package/src/plugin-data.ts +19 -0
- package/src/plugin-grants.ts +83 -0
- package/src/plugin-role.ts +79 -0
- package/src/post-repo.ts +26 -0
- package/src/post-writes.ts +32 -2
- package/src/presence-repo.ts +29 -1
- package/src/read-state-repo.ts +21 -1
- package/src/report-repo.ts +151 -7
- package/src/schema/content.ts +5 -0
- package/src/schema/identity.ts +33 -1
- package/src/schema/platform.ts +2 -0
- package/src/search-repo.ts +48 -17
- package/src/task-repo.ts +19 -7
- package/src/thread-writes.ts +7 -5
- package/src/upgrade-repo.ts +15 -7
- package/src/user-merge-map.ts +1 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { FeedTokenRecord } from '@meith/api'
|
|
4
|
+
|
|
5
|
+
import type { Database } from './client'
|
|
6
|
+
import { resultRows } from './result-rows'
|
|
7
|
+
import { toDate } from './row-values'
|
|
8
|
+
|
|
9
|
+
export interface FeedTokenSummary {
|
|
10
|
+
readonly userId: number
|
|
11
|
+
readonly lookup: string
|
|
12
|
+
readonly createdAt: Date
|
|
13
|
+
readonly lastUsedAt: Date | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TOUCH_INTERVAL_MS = 60_000
|
|
17
|
+
|
|
18
|
+
export class PostgresFeedTokenRepository {
|
|
19
|
+
constructor(private readonly db: Database) {}
|
|
20
|
+
|
|
21
|
+
async findByLookup(lookup: string): Promise<FeedTokenRecord | null> {
|
|
22
|
+
const rows = resultRows<{
|
|
23
|
+
id: number
|
|
24
|
+
user_id: number
|
|
25
|
+
lookup: string
|
|
26
|
+
secret_hash: string
|
|
27
|
+
}>(
|
|
28
|
+
await this.db.execute(sql`
|
|
29
|
+
select id, user_id, lookup, secret_hash
|
|
30
|
+
from feed_tokens
|
|
31
|
+
where lookup = ${lookup}
|
|
32
|
+
limit 1
|
|
33
|
+
`),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const row = rows[0]
|
|
37
|
+
if (row === undefined) return null
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
id: Number(row.id),
|
|
41
|
+
userId: Number(row.user_id),
|
|
42
|
+
lookup: row.lookup,
|
|
43
|
+
secretHash: row.secret_hash,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async summaryForUser(userId: number): Promise<FeedTokenSummary | null> {
|
|
48
|
+
const rows = resultRows<{
|
|
49
|
+
user_id: number
|
|
50
|
+
lookup: string
|
|
51
|
+
created_at: Date | string
|
|
52
|
+
last_used_at: Date | string | null
|
|
53
|
+
}>(
|
|
54
|
+
await this.db.execute(sql`
|
|
55
|
+
select user_id, lookup, created_at, last_used_at
|
|
56
|
+
from feed_tokens
|
|
57
|
+
where user_id = ${userId}
|
|
58
|
+
limit 1
|
|
59
|
+
`),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const row = rows[0]
|
|
63
|
+
if (row === undefined) return null
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
userId: Number(row.user_id),
|
|
67
|
+
lookup: row.lookup,
|
|
68
|
+
createdAt: toDate(row.created_at),
|
|
69
|
+
lastUsedAt: row.last_used_at === null ? null : toDate(row.last_used_at),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async regenerate(input: {
|
|
74
|
+
readonly userId: number
|
|
75
|
+
readonly lookup: string
|
|
76
|
+
readonly secretHash: string
|
|
77
|
+
}): Promise<void> {
|
|
78
|
+
await this.db.execute(sql`
|
|
79
|
+
insert into feed_tokens (user_id, lookup, secret_hash, created_at, last_used_at)
|
|
80
|
+
values (${input.userId}, ${input.lookup}, ${input.secretHash}, now(), null)
|
|
81
|
+
on conflict (user_id) do update
|
|
82
|
+
set lookup = excluded.lookup,
|
|
83
|
+
secret_hash = excluded.secret_hash,
|
|
84
|
+
created_at = now(),
|
|
85
|
+
last_used_at = null
|
|
86
|
+
`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async revokeForUser(userId: number): Promise<void> {
|
|
90
|
+
await this.db.execute(sql`
|
|
91
|
+
delete from feed_tokens where user_id = ${userId}
|
|
92
|
+
`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async touch(id: number, at: Date): Promise<void> {
|
|
96
|
+
await this.db.execute(sql`
|
|
97
|
+
update feed_tokens
|
|
98
|
+
set last_used_at = ${at}
|
|
99
|
+
where id = ${id}
|
|
100
|
+
and (last_used_at is null or last_used_at < ${new Date(at.getTime() - TOUCH_INTERVAL_MS)})
|
|
101
|
+
`)
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/forum-admin-repo.ts
CHANGED
|
@@ -227,32 +227,57 @@ export class PostgresForumAdminRepository {
|
|
|
227
227
|
})
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
private async writeOverrides(
|
|
231
|
+
executor: Pick<Database, 'execute'>,
|
|
232
|
+
forumId: number,
|
|
233
|
+
groupId: number,
|
|
234
|
+
values: Readonly<Record<string, boolean | number | null>>,
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
if (FORUM_PERMISSION_FIELDS.every((field) => values[field.key] == null)) {
|
|
237
|
+
await executor.execute(sql`
|
|
238
|
+
delete from forum_permissions
|
|
239
|
+
where forum_id = ${forumId} and group_id = ${groupId}
|
|
240
|
+
`)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const columns = FORUM_PERMISSION_FIELDS.map((field) => sql.raw(columnName(field.key)))
|
|
245
|
+
const literals = FORUM_PERMISSION_FIELDS.map((field) => sql`${values[field.key] ?? null}`)
|
|
246
|
+
const assignments = FORUM_PERMISSION_FIELDS.map(
|
|
247
|
+
(field) =>
|
|
248
|
+
sql`${sql.raw(columnName(field.key))} = excluded.${sql.raw(columnName(field.key))}`,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
await executor.execute(sql`
|
|
252
|
+
insert into forum_permissions (forum_id, group_id, ${sql.join(columns, sql`, `)})
|
|
253
|
+
values (${forumId}, ${groupId}, ${sql.join(literals, sql`, `)})
|
|
254
|
+
on conflict (forum_id, group_id) do update set ${sql.join(assignments, sql`, `)}
|
|
255
|
+
`)
|
|
256
|
+
}
|
|
257
|
+
|
|
230
258
|
async saveOverrides(
|
|
231
259
|
forumId: number,
|
|
232
260
|
groupId: number,
|
|
233
261
|
values: Readonly<Record<string, boolean | number | null>>,
|
|
234
262
|
): Promise<void> {
|
|
235
263
|
await this.db.transaction(async (tx) => {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
where forum_id = ${forumId} and group_id = ${groupId}
|
|
240
|
-
`)
|
|
241
|
-
return
|
|
242
|
-
}
|
|
264
|
+
await this.writeOverrides(tx, forumId, groupId, values)
|
|
265
|
+
})
|
|
266
|
+
}
|
|
243
267
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
268
|
+
async saveOverridesForGroups(
|
|
269
|
+
forumId: number,
|
|
270
|
+
changes: readonly {
|
|
271
|
+
readonly groupId: number
|
|
272
|
+
readonly values: Readonly<Record<string, boolean | number | null>>
|
|
273
|
+
}[],
|
|
274
|
+
): Promise<void> {
|
|
275
|
+
if (changes.length === 0) return
|
|
250
276
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
`)
|
|
277
|
+
await this.db.transaction(async (tx) => {
|
|
278
|
+
for (const change of changes) {
|
|
279
|
+
await this.writeOverrides(tx, forumId, change.groupId, change.values)
|
|
280
|
+
}
|
|
256
281
|
})
|
|
257
282
|
}
|
|
258
283
|
|
package/src/index.ts
CHANGED
|
@@ -36,7 +36,10 @@ export {
|
|
|
36
36
|
PostgresApiTokenRepository,
|
|
37
37
|
PostgresRateLimitStore,
|
|
38
38
|
PostgresWebhookRepository,
|
|
39
|
+
type WebhookActiveSubscription,
|
|
40
|
+
type WebhookDeliveryLogRow,
|
|
39
41
|
type WebhookDeliveryRow,
|
|
42
|
+
type WebhookSummary,
|
|
40
43
|
} from './api-repo'
|
|
41
44
|
export {
|
|
42
45
|
type AttachmentAdminFilter,
|
|
@@ -55,6 +58,7 @@ export {
|
|
|
55
58
|
PostgresBanFilterRepository,
|
|
56
59
|
PostgresBanRepository,
|
|
57
60
|
} from './ban-repos'
|
|
61
|
+
export { PostgresBoardDigestRepository } from './board-digest-repo'
|
|
58
62
|
export {
|
|
59
63
|
closeDb,
|
|
60
64
|
createIsolatedDb,
|
|
@@ -97,6 +101,7 @@ export {
|
|
|
97
101
|
type SitemapForum,
|
|
98
102
|
type SitemapThread,
|
|
99
103
|
} from './feed-repo'
|
|
104
|
+
export { type FeedTokenSummary, PostgresFeedTokenRepository } from './feed-token-repo'
|
|
100
105
|
export {
|
|
101
106
|
type AppointModeratorInput,
|
|
102
107
|
type ForumOptionsInput,
|
|
@@ -203,11 +208,13 @@ export {
|
|
|
203
208
|
pluginOwnedTables,
|
|
204
209
|
purgePlugin,
|
|
205
210
|
} from './plugin-purge-repo'
|
|
211
|
+
export { ensurePluginDataRole, pluginDbRole } from './plugin-role'
|
|
206
212
|
export { pluginUsers } from './plugin-users'
|
|
207
213
|
export { PostgresPollRepository } from './poll-repo'
|
|
208
214
|
export { PostgresPostRepository } from './post-repo'
|
|
209
215
|
export { PostgresPostWriteRepository } from './post-writes'
|
|
210
216
|
export {
|
|
217
|
+
type MemberPresence,
|
|
211
218
|
ONLINE_WINDOW_MINUTES,
|
|
212
219
|
type OnlineMember,
|
|
213
220
|
type OnlineRecord,
|
|
@@ -230,9 +237,12 @@ export { resultRows } from './result-rows'
|
|
|
230
237
|
export * from './schema'
|
|
231
238
|
export { expectedTables, missingTables } from './schema-state'
|
|
232
239
|
export {
|
|
240
|
+
DEFAULT_SEARCH_CONFIG,
|
|
233
241
|
indexedSubjectSql,
|
|
234
242
|
PostgresSearchRepository,
|
|
235
243
|
type ReindexResult,
|
|
244
|
+
readSearchConfig,
|
|
245
|
+
resolveSearchConfig,
|
|
236
246
|
SEARCH_DOCUMENT_VERSION,
|
|
237
247
|
SEARCH_WINDOW,
|
|
238
248
|
searchVectorSql,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { sql } from 'drizzle-orm'
|
|
2
2
|
|
|
3
3
|
import type { MemberGroupChoice, MemberSettings, MemberSettingsRepository } from '@meith/accounts'
|
|
4
|
+
import { parseSubscriptionMode } from '@meith/subscriptions'
|
|
4
5
|
|
|
5
6
|
import type { Database } from './client'
|
|
6
7
|
import { resultRows } from './result-rows'
|
|
@@ -19,6 +20,9 @@ interface RawSettings {
|
|
|
19
20
|
bio: string | null
|
|
20
21
|
display_group_id: number | null
|
|
21
22
|
mass_mail_opt_in_at: Date | string | null
|
|
23
|
+
board_digest_cadence: string
|
|
24
|
+
auto_watch_own_threads: string
|
|
25
|
+
auto_watch_replied_threads: string
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
interface RawGroupChoice {
|
|
@@ -36,7 +40,8 @@ export class PostgresMemberSettingsRepository implements MemberSettingsRepositor
|
|
|
36
40
|
await this.db.execute(sql`
|
|
37
41
|
select id, email, timezone, locale, posts_per_page, threads_per_page,
|
|
38
42
|
invisible, location, website, bio, display_group_id,
|
|
39
|
-
mass_mail_opt_in_at
|
|
43
|
+
mass_mail_opt_in_at, board_digest_cadence,
|
|
44
|
+
auto_watch_own_threads, auto_watch_replied_threads
|
|
40
45
|
from users
|
|
41
46
|
where id = ${userId} and state <> 'deleted'
|
|
42
47
|
`),
|
|
@@ -58,6 +63,9 @@ export class PostgresMemberSettingsRepository implements MemberSettingsRepositor
|
|
|
58
63
|
bio: row.bio,
|
|
59
64
|
displayGroupId: row.display_group_id === null ? null : Number(row.display_group_id),
|
|
60
65
|
massMailOptInAt: row.mass_mail_opt_in_at === null ? null : new Date(row.mass_mail_opt_in_at),
|
|
66
|
+
boardDigestCadence: row.board_digest_cadence,
|
|
67
|
+
autoWatchOwnThreads: parseSubscriptionMode(row.auto_watch_own_threads) ?? 'none',
|
|
68
|
+
autoWatchRepliedThreads: parseSubscriptionMode(row.auto_watch_replied_threads) ?? 'none',
|
|
61
69
|
}
|
|
62
70
|
}
|
|
63
71
|
|
|
@@ -123,6 +131,8 @@ export class PostgresMemberSettingsRepository implements MemberSettingsRepositor
|
|
|
123
131
|
readonly postsPerPage: number | null
|
|
124
132
|
readonly threadsPerPage: number | null
|
|
125
133
|
readonly invisible: boolean
|
|
134
|
+
readonly autoWatchOwnThreads: string
|
|
135
|
+
readonly autoWatchRepliedThreads: string
|
|
126
136
|
}): Promise<void> {
|
|
127
137
|
await this.db.execute(sql`
|
|
128
138
|
update users
|
|
@@ -131,6 +141,8 @@ export class PostgresMemberSettingsRepository implements MemberSettingsRepositor
|
|
|
131
141
|
posts_per_page = ${input.postsPerPage},
|
|
132
142
|
threads_per_page = ${input.threadsPerPage},
|
|
133
143
|
invisible = ${input.invisible},
|
|
144
|
+
auto_watch_own_threads = ${input.autoWatchOwnThreads},
|
|
145
|
+
auto_watch_replied_threads = ${input.autoWatchRepliedThreads},
|
|
134
146
|
updated_at = now()
|
|
135
147
|
where id = ${input.userId}
|
|
136
148
|
`)
|
|
@@ -150,6 +162,18 @@ export class PostgresMemberSettingsRepository implements MemberSettingsRepositor
|
|
|
150
162
|
`)
|
|
151
163
|
}
|
|
152
164
|
|
|
165
|
+
async saveBoardDigestCadence(input: {
|
|
166
|
+
readonly userId: number
|
|
167
|
+
readonly cadence: string
|
|
168
|
+
}): Promise<void> {
|
|
169
|
+
await this.db.execute(sql`
|
|
170
|
+
update users
|
|
171
|
+
set board_digest_cadence = ${input.cadence},
|
|
172
|
+
updated_at = now()
|
|
173
|
+
where id = ${input.userId} and state <> 'deleted'
|
|
174
|
+
`)
|
|
175
|
+
}
|
|
176
|
+
|
|
153
177
|
async adoptEmail(input: {
|
|
154
178
|
readonly userId: number
|
|
155
179
|
readonly email: string
|
package/src/moderation-queue.ts
CHANGED
|
@@ -66,6 +66,37 @@ function toItem(row: QueueRow): QueueItem {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
async function flagHeldPosts(
|
|
70
|
+
tx: { execute(query: ReturnType<typeof sql>): Promise<unknown> },
|
|
71
|
+
threadIds: readonly number[],
|
|
72
|
+
): Promise<Map<number, number[]>> {
|
|
73
|
+
const map = new Map<number, number[]>()
|
|
74
|
+
if (threadIds.length === 0) return map
|
|
75
|
+
|
|
76
|
+
const rows = resultRows(
|
|
77
|
+
await tx.execute(sql`
|
|
78
|
+
select (l.detail->>'threadId')::int as thread_id, l.detail as detail
|
|
79
|
+
from admin_log l
|
|
80
|
+
where l.action = 'post.autohold'
|
|
81
|
+
and (l.detail->>'threadId')::int in ${idList(threadIds)}
|
|
82
|
+
order by l.created_at desc, l.id desc
|
|
83
|
+
`),
|
|
84
|
+
) as Array<{ thread_id: number; detail: Record<string, unknown> | null }>
|
|
85
|
+
|
|
86
|
+
for (const row of rows) {
|
|
87
|
+
const threadId = Number(row.thread_id)
|
|
88
|
+
if (map.has(threadId)) continue
|
|
89
|
+
const ids = row.detail?.heldPostIds
|
|
90
|
+
if (Array.isArray(ids)) {
|
|
91
|
+
map.set(
|
|
92
|
+
threadId,
|
|
93
|
+
ids.map((value) => Number(value)).filter((value) => Number.isSafeInteger(value)),
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return map
|
|
98
|
+
}
|
|
99
|
+
|
|
69
100
|
export class PostgresModerationQueueRepository implements ModerationQueueRepository {
|
|
70
101
|
constructor(private readonly db: Database) {}
|
|
71
102
|
|
|
@@ -183,40 +214,49 @@ export class PostgresModerationQueueRepository implements ModerationQueueReposit
|
|
|
183
214
|
let applied = 0
|
|
184
215
|
const touched = new Set<number>()
|
|
185
216
|
|
|
217
|
+
const heldByThread = await flagHeldPosts(tx, input.threadIds)
|
|
218
|
+
|
|
186
219
|
for (const threadId of input.threadIds) {
|
|
187
220
|
const moved = resultRows(
|
|
188
221
|
await tx.execute(sql`
|
|
189
222
|
update threads set visibility = ${to}, updated_at = now()
|
|
190
223
|
where id = ${threadId} and visibility = ${PENDING_APPROVAL}
|
|
191
|
-
returning id, forum_id, first_post_id
|
|
224
|
+
returning id, forum_id, first_post_id
|
|
192
225
|
`),
|
|
193
|
-
) as Array<{
|
|
194
|
-
id: number
|
|
195
|
-
forum_id: number
|
|
196
|
-
first_post_id: number | null
|
|
197
|
-
author_user_id: number | null
|
|
198
|
-
}>
|
|
226
|
+
) as Array<{ id: number; forum_id: number; first_post_id: number | null }>
|
|
199
227
|
const thread = moved[0]
|
|
200
228
|
if (!thread) continue
|
|
201
229
|
applied += 1
|
|
202
|
-
|
|
230
|
+
const forumId = Number(thread.forum_id)
|
|
231
|
+
touched.add(forumId)
|
|
232
|
+
|
|
233
|
+
const held = heldByThread.get(threadId) ?? []
|
|
234
|
+
const targetIds =
|
|
235
|
+
held.length > 0
|
|
236
|
+
? held
|
|
237
|
+
: thread.first_post_id === null
|
|
238
|
+
? []
|
|
239
|
+
: [Number(thread.first_post_id)]
|
|
240
|
+
if (targetIds.length === 0) continue
|
|
203
241
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
)
|
|
242
|
+
const posts = resultRows(
|
|
243
|
+
await tx.execute(sql`
|
|
244
|
+
update posts set visibility = ${to}
|
|
245
|
+
where thread_id = ${threadId}
|
|
246
|
+
and id in ${idList(targetIds)}
|
|
247
|
+
and visibility = ${PENDING_APPROVAL}
|
|
248
|
+
returning id, author_user_id, is_first_post
|
|
249
|
+
`),
|
|
250
|
+
) as Array<{ id: number; author_user_id: number | null; is_first_post: boolean }>
|
|
212
251
|
|
|
213
|
-
|
|
252
|
+
if (approving) {
|
|
253
|
+
for (const post of posts) {
|
|
214
254
|
await applyVisibilityChangeCounters(tx, {
|
|
215
|
-
postId: Number(post
|
|
255
|
+
postId: Number(post.id),
|
|
216
256
|
threadId: Number(thread.id),
|
|
217
|
-
forumId
|
|
218
|
-
authorId:
|
|
219
|
-
isFirstPost: true,
|
|
257
|
+
forumId,
|
|
258
|
+
authorId: post.author_user_id === null ? null : Number(post.author_user_id),
|
|
259
|
+
isFirstPost: post.is_first_post === true,
|
|
220
260
|
delta: 1,
|
|
221
261
|
})
|
|
222
262
|
}
|
package/src/plugin-data.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { PluginData } from '@meith/plugin-kit'
|
|
|
5
5
|
|
|
6
6
|
import type { Database } from './client'
|
|
7
7
|
import type { Tx } from './permission-version'
|
|
8
|
+
import { ensurePluginDataRole, pluginDbRole } from './plugin-role'
|
|
8
9
|
import { resultRows } from './result-rows'
|
|
9
10
|
|
|
10
11
|
export interface PluginDataOptions {
|
|
@@ -13,6 +14,21 @@ export interface PluginDataOptions {
|
|
|
13
14
|
|
|
14
15
|
const DEFAULT_TIMEOUT_MS = 3_000
|
|
15
16
|
|
|
17
|
+
const ensuredRoles = new WeakMap<Database, Set<string>>()
|
|
18
|
+
|
|
19
|
+
async function ensureRoleOnce(db: Database, pluginKey: string): Promise<void> {
|
|
20
|
+
let seen = ensuredRoles.get(db)
|
|
21
|
+
if (seen === undefined) {
|
|
22
|
+
seen = new Set<string>()
|
|
23
|
+
ensuredRoles.set(db, seen)
|
|
24
|
+
}
|
|
25
|
+
if (seen.has(pluginKey)) return
|
|
26
|
+
try {
|
|
27
|
+
await db.transaction((tx) => ensurePluginDataRole(tx, pluginKey))
|
|
28
|
+
} catch {}
|
|
29
|
+
seen.add(pluginKey)
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
export function bindPluginSql(
|
|
17
33
|
text: string,
|
|
18
34
|
params: readonly unknown[],
|
|
@@ -66,10 +82,13 @@ export function pluginData(
|
|
|
66
82
|
): PluginData {
|
|
67
83
|
const timeoutMs = Math.max(1, Math.trunc(options.statementTimeoutMs ?? DEFAULT_TIMEOUT_MS))
|
|
68
84
|
const where = `plugin "${pluginKey}"`
|
|
85
|
+
const role = pluginDbRole(pluginKey)
|
|
69
86
|
|
|
70
87
|
const inTransaction = async <T>(work: (data: PluginData) => Promise<T>): Promise<T> => {
|
|
88
|
+
await ensureRoleOnce(db, pluginKey)
|
|
71
89
|
return db.transaction(async (tx) => {
|
|
72
90
|
await tx.execute(sql.raw(`set local statement_timeout = ${timeoutMs}`))
|
|
91
|
+
await tx.execute(sql.raw(`set local role "${role.replace(/"/g, '""')}"`))
|
|
73
92
|
return work(onExecutor(tx, where))
|
|
74
93
|
})
|
|
75
94
|
}
|
package/src/plugin-grants.ts
CHANGED
|
@@ -157,6 +157,38 @@ async function grantableGroup(
|
|
|
157
157
|
return { id: Number(row.id), key: String(row.key) }
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
async function readableGroup(
|
|
161
|
+
db: Database,
|
|
162
|
+
pluginKey: string,
|
|
163
|
+
groupKey: string,
|
|
164
|
+
): Promise<GrantableGroup | null> {
|
|
165
|
+
const rows = resultRows(
|
|
166
|
+
await db.execute(sql`select * from usergroups where key = ${groupKey} limit 1`),
|
|
167
|
+
) as Array<Record<string, unknown>>
|
|
168
|
+
|
|
169
|
+
const row = rows[0]
|
|
170
|
+
if (row === undefined || row.plugin_grantable !== true) return null
|
|
171
|
+
|
|
172
|
+
const where = `plugin "${pluginKey}"`
|
|
173
|
+
if (row.is_system === true) {
|
|
174
|
+
throw new ValidationError(
|
|
175
|
+
`${where}: "${groupKey}" is a system group. The board resolves it by key; its membership is not a plugin's to read.`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
if (row.is_staff_group === true) {
|
|
179
|
+
throw new ValidationError(
|
|
180
|
+
`${where}: "${groupKey}" is a staff group, and staff standing is not a plugin's to read.`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
if (permissionsCarryPower(groupRowToPermissionSet(camelise(row)))) {
|
|
184
|
+
throw new ValidationError(
|
|
185
|
+
`${where}: "${groupKey}" carries administrative or moderation power, so no plugin may read its membership.`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { id: Number(row.id), key: String(row.key) }
|
|
190
|
+
}
|
|
191
|
+
|
|
160
192
|
function checkedUntil(pluginKey: string, until: Date, now: Date): Date {
|
|
161
193
|
if (!(until instanceof Date) || Number.isNaN(until.getTime())) {
|
|
162
194
|
throw new ValidationError(`plugin "${pluginKey}": the grant needs a valid expiry date.`)
|
|
@@ -330,6 +362,57 @@ export function pluginGrants(
|
|
|
330
362
|
.filter((row): row is { groupKey: string; expiresAt: Date } => row.expiresAt !== null)
|
|
331
363
|
.map((row): PluginGrantRow => ({ groupKey: row.groupKey, expiresAt: row.expiresAt }))
|
|
332
364
|
},
|
|
365
|
+
|
|
366
|
+
async holds(userId, groupKey) {
|
|
367
|
+
const group = await readableGroup(db, pluginKey, groupKey)
|
|
368
|
+
if (group === null) return false
|
|
369
|
+
|
|
370
|
+
const now = clock()
|
|
371
|
+
|
|
372
|
+
const userRows = resultRows(
|
|
373
|
+
await db.execute(
|
|
374
|
+
sql`select primary_group_id from users where id = ${userId} and deleted_at is null limit 1`,
|
|
375
|
+
),
|
|
376
|
+
) as Array<{ primary_group_id: number }>
|
|
377
|
+
const user = userRows[0]
|
|
378
|
+
if (user === undefined) return false
|
|
379
|
+
|
|
380
|
+
const membershipRows = resultRows(
|
|
381
|
+
await db.execute(sql`
|
|
382
|
+
select group_id, expires_at, previous_primary_group_id
|
|
383
|
+
from user_group_memberships
|
|
384
|
+
where user_id = ${userId}
|
|
385
|
+
`),
|
|
386
|
+
) as Array<{
|
|
387
|
+
group_id: number
|
|
388
|
+
expires_at: Date | string | null
|
|
389
|
+
previous_primary_group_id: number | null
|
|
390
|
+
}>
|
|
391
|
+
|
|
392
|
+
const rows = membershipRows.map((row) => ({
|
|
393
|
+
groupId: Number(row.group_id),
|
|
394
|
+
expiresAt: row.expires_at === null ? null : new Date(row.expires_at),
|
|
395
|
+
previousPrimary:
|
|
396
|
+
row.previous_primary_group_id === null ? null : Number(row.previous_primary_group_id),
|
|
397
|
+
}))
|
|
398
|
+
|
|
399
|
+
const held = Number(user.primary_group_id)
|
|
400
|
+
const lapsed = rows.find(
|
|
401
|
+
(row) =>
|
|
402
|
+
row.groupId === held &&
|
|
403
|
+
row.previousPrimary !== null &&
|
|
404
|
+
row.expiresAt !== null &&
|
|
405
|
+
row.expiresAt.getTime() <= now.getTime(),
|
|
406
|
+
)
|
|
407
|
+
const effectivePrimary = lapsed?.previousPrimary ?? held
|
|
408
|
+
if (effectivePrimary === group.id) return true
|
|
409
|
+
|
|
410
|
+
return rows.some(
|
|
411
|
+
(row) =>
|
|
412
|
+
row.groupId === group.id &&
|
|
413
|
+
(row.expiresAt === null || row.expiresAt.getTime() > now.getTime()),
|
|
414
|
+
)
|
|
415
|
+
},
|
|
333
416
|
}
|
|
334
417
|
}
|
|
335
418
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import { pluginTablePrefix } from '@meith/plugin-kit'
|
|
4
|
+
|
|
5
|
+
import type { Tx } from './permission-version'
|
|
6
|
+
import { resultRows } from './result-rows'
|
|
7
|
+
|
|
8
|
+
const ROLE_PATTERN = /^plugin_[a-z][a-z0-9_]{1,63}$/
|
|
9
|
+
|
|
10
|
+
export function pluginDbRole(pluginKey: string): string {
|
|
11
|
+
const role = `plugin_${pluginKey.replace(/-/g, '_')}`
|
|
12
|
+
if (!ROLE_PATTERN.test(role)) {
|
|
13
|
+
throw new Error(`plugin key "${pluginKey}" does not map to a usable database role name.`)
|
|
14
|
+
}
|
|
15
|
+
return role
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function quoteIdentifier(name: string): string {
|
|
19
|
+
return `"${name.replace(/"/g, '""')}"`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function quoteLiteral(value: string): string {
|
|
23
|
+
return `'${value.replace(/'/g, "''")}'`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function ensurePluginDataRole(executor: Tx, pluginKey: string): Promise<void> {
|
|
27
|
+
const role = pluginDbRole(pluginKey)
|
|
28
|
+
const roleIdent = quoteIdentifier(role)
|
|
29
|
+
const prefix = pluginTablePrefix(pluginKey)
|
|
30
|
+
|
|
31
|
+
await executor.execute(
|
|
32
|
+
sql.raw(
|
|
33
|
+
`do $meith$ begin
|
|
34
|
+
if not exists (select 1 from pg_roles where rolname = ${quoteLiteral(role)}) then
|
|
35
|
+
create role ${roleIdent} with nologin;
|
|
36
|
+
end if;
|
|
37
|
+
exception when duplicate_object then null;
|
|
38
|
+
end $meith$;`,
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
await executor.execute(sql.raw(`grant ${roleIdent} to current_user`))
|
|
43
|
+
await executor.execute(sql.raw(`grant usage on schema public to ${roleIdent}`))
|
|
44
|
+
|
|
45
|
+
const tables = resultRows(
|
|
46
|
+
await executor.execute(sql`
|
|
47
|
+
select table_name
|
|
48
|
+
from information_schema.tables
|
|
49
|
+
where table_schema = 'public'
|
|
50
|
+
and table_type = 'BASE TABLE'
|
|
51
|
+
and table_name like ${`${prefix}%`}
|
|
52
|
+
`),
|
|
53
|
+
) as Array<{ table_name: string }>
|
|
54
|
+
|
|
55
|
+
const sequences = resultRows(
|
|
56
|
+
await executor.execute(sql`
|
|
57
|
+
select sequence_name
|
|
58
|
+
from information_schema.sequences
|
|
59
|
+
where sequence_schema = 'public'
|
|
60
|
+
and sequence_name like ${`${prefix}%`}
|
|
61
|
+
`),
|
|
62
|
+
) as Array<{ sequence_name: string }>
|
|
63
|
+
|
|
64
|
+
for (const { table_name } of tables) {
|
|
65
|
+
await executor.execute(
|
|
66
|
+
sql.raw(
|
|
67
|
+
`grant select, insert, update, delete on ${quoteIdentifier(String(table_name))} to ${roleIdent}`,
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const { sequence_name } of sequences) {
|
|
73
|
+
await executor.execute(
|
|
74
|
+
sql.raw(
|
|
75
|
+
`grant usage, select, update on ${quoteIdentifier(String(sequence_name))} to ${roleIdent}`,
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/post-repo.ts
CHANGED
|
@@ -176,6 +176,32 @@ export class PostgresPostRepository implements PostRepository {
|
|
|
176
176
|
return { number, page, afterId: cursor[0]?.id ?? null }
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
async locateFirstUnread(
|
|
180
|
+
threadId: number,
|
|
181
|
+
after: { readonly postId: number; readonly since: Date | null },
|
|
182
|
+
options: { readonly scope: ContentScope; readonly pageSize: number },
|
|
183
|
+
): Promise<PostLocation | null> {
|
|
184
|
+
const visible: SQL = visibleIn(posts.visibility, options.scope)
|
|
185
|
+
|
|
186
|
+
const target = await this.db
|
|
187
|
+
.select({ id: posts.id })
|
|
188
|
+
.from(posts)
|
|
189
|
+
.where(
|
|
190
|
+
and(
|
|
191
|
+
eq(posts.threadId, threadId),
|
|
192
|
+
visible,
|
|
193
|
+
gt(posts.id, after.postId),
|
|
194
|
+
...(after.since === null ? [] : [gt(posts.createdAt, after.since)]),
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
.orderBy(asc(posts.id))
|
|
198
|
+
.limit(1)
|
|
199
|
+
|
|
200
|
+
const firstId = target[0]?.id
|
|
201
|
+
if (firstId === undefined) return null
|
|
202
|
+
return this.locate(threadId, firstId, options)
|
|
203
|
+
}
|
|
204
|
+
|
|
179
205
|
async findVisibleById(threadId: number, postId: number): Promise<number | null> {
|
|
180
206
|
const rows = await this.db
|
|
181
207
|
.select({ id: posts.id })
|