@meith/runtime 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +165 -0
- package/package.json +42 -0
- package/src/event-handlers.ts +98 -0
- package/src/groups.ts +9 -0
- package/src/index.ts +14 -0
- package/src/mail-brand.ts +83 -0
- package/src/plugin-context.ts +56 -0
- package/src/plugin-lifecycle.ts +41 -0
- package/src/plugin-rendering.ts +60 -0
- package/src/plugin-tasks.ts +52 -0
- package/src/scheduler-bundle.ts +373 -0
- package/src/task-workers.ts +293 -0
- package/src/visible-forums.ts +23 -0
- package/src/webhook-delivery.ts +117 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { AttachmentService, type ImageProcessor } from '@meith/attachments'
|
|
2
|
+
import { Authorizer } from '@meith/authorization'
|
|
3
|
+
import { AvatarService } from '@meith/avatars'
|
|
4
|
+
import type { FileStore, MailDriver, QueueDriver } from '@meith/core'
|
|
5
|
+
import { env, logger, metrics, optional, withSpan } from '@meith/core'
|
|
6
|
+
import {
|
|
7
|
+
ActorBuilder,
|
|
8
|
+
type Database,
|
|
9
|
+
expireTimedGroupMemberships,
|
|
10
|
+
getDb,
|
|
11
|
+
PostgresAttachmentRepository,
|
|
12
|
+
PostgresAuthEventRepository,
|
|
13
|
+
PostgresAuthorizationSource,
|
|
14
|
+
PostgresAvatarRepository,
|
|
15
|
+
PostgresBanRepository,
|
|
16
|
+
PostgresContentCounterRepository,
|
|
17
|
+
PostgresCounterRecount,
|
|
18
|
+
PostgresMaintenanceRepository,
|
|
19
|
+
PostgresMarketplaceCacheRepository,
|
|
20
|
+
PostgresNotificationRepository,
|
|
21
|
+
PostgresOutboxReader,
|
|
22
|
+
PostgresPresenceRepository,
|
|
23
|
+
PostgresPromotionRepository,
|
|
24
|
+
PostgresRateLimitBucketStore,
|
|
25
|
+
PostgresRenderBackfill,
|
|
26
|
+
PostgresSearchRepository,
|
|
27
|
+
PostgresSettingsRepository,
|
|
28
|
+
PostgresStatsRepository,
|
|
29
|
+
PostgresSubscriptionRepository,
|
|
30
|
+
PostgresTaskRepository,
|
|
31
|
+
PostgresThreadViewBuffer,
|
|
32
|
+
PostgresUserBulkRepository,
|
|
33
|
+
PostgresWarningRepository,
|
|
34
|
+
PostgresWebhookRepository,
|
|
35
|
+
readPluginHealth,
|
|
36
|
+
recordPluginFailure,
|
|
37
|
+
syncRenderSignature,
|
|
38
|
+
} from '@meith/db'
|
|
39
|
+
import { EN_CATALOG, sourceTranslator } from '@meith/i18n'
|
|
40
|
+
import { renderMail } from '@meith/mail'
|
|
41
|
+
import {
|
|
42
|
+
deliverNotificationEmail,
|
|
43
|
+
deliverNotificationPush,
|
|
44
|
+
NotificationService,
|
|
45
|
+
type NotificationTranslatorResolver,
|
|
46
|
+
type VapidDetails,
|
|
47
|
+
} from '@meith/notifications'
|
|
48
|
+
import { type PluginDefinition, PluginHost, renderingSignature } from '@meith/plugin-kit'
|
|
49
|
+
import { resolvePushConfig, SettingsSnapshot } from '@meith/settings'
|
|
50
|
+
import { builtinTasks, type TaskDefinition, type TaskRepository } from '@meith/tasks'
|
|
51
|
+
|
|
52
|
+
import { buildEventRegistry } from './event-handlers'
|
|
53
|
+
import { SEED_GROUP } from './groups'
|
|
54
|
+
import { resolveMailBrand, type ThemeTokenRegistry } from './mail-brand'
|
|
55
|
+
import { pluginMarkdownPipeline, sendAudited } from './plugin-rendering'
|
|
56
|
+
import { pluginTasks } from './plugin-tasks'
|
|
57
|
+
import { defaultPromotionGuards, taskWorkers } from './task-workers'
|
|
58
|
+
import { visibleForumSource } from './visible-forums'
|
|
59
|
+
|
|
60
|
+
export interface SchedulerBundle {
|
|
61
|
+
readonly repository: TaskRepository
|
|
62
|
+
readonly tasks: readonly TaskDefinition[]
|
|
63
|
+
readonly onTaskFailure: (taskId: string, error: unknown) => void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function buildSchedulerBundle(deps: {
|
|
67
|
+
readonly queue: QueueDriver
|
|
68
|
+
readonly db?: Database
|
|
69
|
+
readonly mail?: MailDriver
|
|
70
|
+
readonly themeKey?: string
|
|
71
|
+
readonly themeTokens?: ThemeTokenRegistry
|
|
72
|
+
readonly files?: FileStore
|
|
73
|
+
readonly images?: ImageProcessor
|
|
74
|
+
readonly plugins?: readonly PluginDefinition[]
|
|
75
|
+
readonly translatorForLocale?: NotificationTranslatorResolver
|
|
76
|
+
}): SchedulerBundle {
|
|
77
|
+
const db = deps.db ?? getDb()
|
|
78
|
+
const themeDeps = {
|
|
79
|
+
...optional(deps.themeKey, (themeKey) => ({ themeKey })),
|
|
80
|
+
...optional(deps.themeTokens, (themeTokens) => ({ themeTokens })),
|
|
81
|
+
}
|
|
82
|
+
const threadViews = new PostgresThreadViewBuffer(db)
|
|
83
|
+
const notifications = new PostgresNotificationRepository(db)
|
|
84
|
+
const mail = deps.mail
|
|
85
|
+
|
|
86
|
+
const attachmentService =
|
|
87
|
+
deps.files === undefined || deps.images === undefined
|
|
88
|
+
? undefined
|
|
89
|
+
: new AttachmentService({
|
|
90
|
+
attachments: new PostgresAttachmentRepository(db),
|
|
91
|
+
files: deps.files,
|
|
92
|
+
images: deps.images,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const avatarService =
|
|
96
|
+
deps.files === undefined || deps.images === undefined
|
|
97
|
+
? undefined
|
|
98
|
+
: new AvatarService({
|
|
99
|
+
avatars: new PostgresAvatarRepository(db),
|
|
100
|
+
files: deps.files,
|
|
101
|
+
images: deps.images,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const contributed = pluginTasks({ db, plugins: deps.plugins ?? [] })
|
|
105
|
+
const plugins = deps.plugins ?? []
|
|
106
|
+
const renderHost = new PluginHost({
|
|
107
|
+
plugins,
|
|
108
|
+
health: {
|
|
109
|
+
failed: (failure) => {
|
|
110
|
+
void recordPluginFailure(db, {
|
|
111
|
+
pluginKey: failure.pluginKey,
|
|
112
|
+
threshold: failure.threshold,
|
|
113
|
+
reason: `${failure.threshold} failures, most recently in "${failure.hook}": ${failure.message}`,
|
|
114
|
+
}).catch((error: unknown) => {
|
|
115
|
+
logger({ module: 'scheduler' }).warn(
|
|
116
|
+
{ err: String(error), plugin: failure.pluginKey },
|
|
117
|
+
'could not record plugin failure',
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
const backfill = new PostgresRenderBackfill(db, pluginMarkdownPipeline(renderHost))
|
|
124
|
+
|
|
125
|
+
const taskRuns = metrics.counter(
|
|
126
|
+
'meith_task_runs_total',
|
|
127
|
+
'Scheduled task runs, labelled by task and outcome.',
|
|
128
|
+
)
|
|
129
|
+
const taskRunSeconds = metrics.histogram(
|
|
130
|
+
'meith_task_run_duration_seconds',
|
|
131
|
+
'Scheduled task run duration in seconds, labelled by task and outcome.',
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Every task, announced. The host is told before and after each run — a
|
|
136
|
+
* plugin watching its own task, or the board's, gets the same two events
|
|
137
|
+
* whichever scheduled it.
|
|
138
|
+
*/
|
|
139
|
+
const announced = (tasks: readonly TaskDefinition[]): TaskDefinition[] =>
|
|
140
|
+
tasks.map((task) => ({
|
|
141
|
+
...task,
|
|
142
|
+
async run(context) {
|
|
143
|
+
await renderHost.emit('task.run.before', { taskId: task.id }, {})
|
|
144
|
+
|
|
145
|
+
const startedAt = Date.now()
|
|
146
|
+
try {
|
|
147
|
+
const result = await withSpan('task.run', { 'task.id': task.id }, () => task.run(context))
|
|
148
|
+
const durationMs = Date.now() - startedAt
|
|
149
|
+
taskRuns.inc(1, { task: task.id, status: 'ok' })
|
|
150
|
+
taskRunSeconds.observe(durationMs / 1000, { task: task.id })
|
|
151
|
+
await renderHost.emit('task.run.after', { taskId: task.id, ok: true, durationMs }, {})
|
|
152
|
+
return result
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const durationMs = Date.now() - startedAt
|
|
155
|
+
taskRuns.inc(1, { task: task.id, status: 'error' })
|
|
156
|
+
taskRunSeconds.observe(durationMs / 1000, { task: task.id })
|
|
157
|
+
await renderHost.emit('task.run.after', { taskId: task.id, ok: false, durationMs }, {})
|
|
158
|
+
throw error
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
}))
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
repository: new PostgresTaskRepository(db),
|
|
165
|
+
onTaskFailure: taskFailureNotifier(notifications),
|
|
166
|
+
tasks: announced([
|
|
167
|
+
...builtinTasks(
|
|
168
|
+
taskWorkers({
|
|
169
|
+
queue: deps.queue,
|
|
170
|
+
bans: new PostgresBanRepository(db),
|
|
171
|
+
promotions: new PostgresPromotionRepository(db),
|
|
172
|
+
guards: defaultPromotionGuards(),
|
|
173
|
+
maintenance: new PostgresMaintenanceRepository(db),
|
|
174
|
+
rateLimits: new PostgresRateLimitBucketStore(db),
|
|
175
|
+
authEvents: {
|
|
176
|
+
retentionDays: () => authEventRetentionDays(db),
|
|
177
|
+
pruneBefore: (cutoff, limit) =>
|
|
178
|
+
new PostgresAuthEventRepository(db).pruneBefore(cutoff, limit),
|
|
179
|
+
},
|
|
180
|
+
timedGroups: { expire: (limit) => expireTimedGroupMemberships(db, limit) },
|
|
181
|
+
outbox: new PostgresOutboxReader(db),
|
|
182
|
+
...optional(attachmentService, (attachments) => ({ attachments })),
|
|
183
|
+
...optional(avatarService, (avatars) => ({ avatars })),
|
|
184
|
+
events: buildEventRegistry({
|
|
185
|
+
counters: new PostgresContentCounterRepository(db),
|
|
186
|
+
...optional(attachmentService, (attachments) => ({
|
|
187
|
+
attachments: { process: (id: number) => attachments.process(id) },
|
|
188
|
+
})),
|
|
189
|
+
...optional(avatarService, (avatars) => ({
|
|
190
|
+
avatars: { process: (id: number) => avatars.process(id) },
|
|
191
|
+
})),
|
|
192
|
+
notifications: {
|
|
193
|
+
...optional(mail, (mail) => ({
|
|
194
|
+
async deliverEmail(notificationId: number) {
|
|
195
|
+
await deliverNotificationEmail({
|
|
196
|
+
notifications,
|
|
197
|
+
mail,
|
|
198
|
+
brand: await resolveMailBrand({ db, ...themeDeps }),
|
|
199
|
+
notificationId,
|
|
200
|
+
...optional(deps.translatorForLocale, (translatorForLocale) => ({
|
|
201
|
+
translatorForLocale,
|
|
202
|
+
})),
|
|
203
|
+
})
|
|
204
|
+
},
|
|
205
|
+
})),
|
|
206
|
+
async deliverPush(notificationId: number) {
|
|
207
|
+
const vapid = await vapidDetails(db)
|
|
208
|
+
if (vapid === null) return
|
|
209
|
+
await deliverNotificationPush({
|
|
210
|
+
notifications,
|
|
211
|
+
vapid,
|
|
212
|
+
notificationId,
|
|
213
|
+
...optional(deps.translatorForLocale, (translatorForLocale) => ({
|
|
214
|
+
translatorForLocale,
|
|
215
|
+
})),
|
|
216
|
+
})
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
...optional(mail, (mail) => ({
|
|
220
|
+
massMail: {
|
|
221
|
+
async send({ massMailId, email }) {
|
|
222
|
+
const campaign = await new PostgresUserBulkRepository(db).readMassMail(massMailId)
|
|
223
|
+
if (campaign === null) return
|
|
224
|
+
|
|
225
|
+
const brand = await resolveMailBrand({ db, ...themeDeps })
|
|
226
|
+
const t = await (
|
|
227
|
+
deps.translatorForLocale ?? (() => sourceTranslator(EN_CATALOG))
|
|
228
|
+
)('')
|
|
229
|
+
|
|
230
|
+
const rendered = renderMail({
|
|
231
|
+
brand,
|
|
232
|
+
t,
|
|
233
|
+
body: {
|
|
234
|
+
title: campaign.subject,
|
|
235
|
+
paragraphs: campaign.body.split(/\n{2,}/),
|
|
236
|
+
footer: [
|
|
237
|
+
{
|
|
238
|
+
text: t.t('mail.footer.sentBy', {
|
|
239
|
+
board:
|
|
240
|
+
brand.boardName === '' ? t.t('mail.boardFallback') : brand.boardName,
|
|
241
|
+
}),
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const fromName = brand.fromName ?? ''
|
|
248
|
+
await sendAudited(renderHost, mail, 'mass-mail', {
|
|
249
|
+
to: email,
|
|
250
|
+
subject: campaign.subject,
|
|
251
|
+
text: rendered.text,
|
|
252
|
+
html: rendered.html,
|
|
253
|
+
...(fromName === '' ? {} : { fromName }),
|
|
254
|
+
})
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
})),
|
|
258
|
+
}),
|
|
259
|
+
recount: new PostgresCounterRecount(db),
|
|
260
|
+
renderBackfill: {
|
|
261
|
+
run: async (batchSize) => {
|
|
262
|
+
renderHost.setDurablyDisabled(
|
|
263
|
+
(await readPluginHealth(db))
|
|
264
|
+
.filter((row) => row.disabledAt !== null)
|
|
265
|
+
.map((row) => ({
|
|
266
|
+
key: row.pluginKey,
|
|
267
|
+
reason: row.reason ?? 'repeated failures',
|
|
268
|
+
})),
|
|
269
|
+
)
|
|
270
|
+
await syncRenderSignature(db, renderingSignature(plugins))
|
|
271
|
+
return backfill.run(batchSize)
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
searchIndex: new PostgresSearchRepository(db),
|
|
275
|
+
...(env.DEMO_MODE ? {} : { webhooks: new PostgresWebhookRepository(db) }),
|
|
276
|
+
statistics: {
|
|
277
|
+
stats: new PostgresStatsRepository(db),
|
|
278
|
+
presence: new PostgresPresenceRepository(db),
|
|
279
|
+
},
|
|
280
|
+
threadViews,
|
|
281
|
+
warnings: new PostgresWarningRepository(db),
|
|
282
|
+
subscriptions: {
|
|
283
|
+
repository: new PostgresSubscriptionRepository(db),
|
|
284
|
+
notifications: new NotificationService({ notifications }),
|
|
285
|
+
forums: visibleForumSource({
|
|
286
|
+
authorizer: new Authorizer(new PostgresAuthorizationSource(db), {}),
|
|
287
|
+
actors: new ActorBuilder(db, { guestGroupId: SEED_GROUP.guest }),
|
|
288
|
+
}),
|
|
289
|
+
unsubscribeSecret: env.AUTH_SECRET ?? null,
|
|
290
|
+
},
|
|
291
|
+
marketplace: {
|
|
292
|
+
repository: new PostgresMarketplaceCacheRepository(db),
|
|
293
|
+
plugins,
|
|
294
|
+
feedUrl: () => marketplaceFeedUrl(db),
|
|
295
|
+
async notifyUpdate(listing) {
|
|
296
|
+
await new NotificationService({ notifications }).raiseForAdministrators({
|
|
297
|
+
kind: 'marketplace.update_available',
|
|
298
|
+
data: {
|
|
299
|
+
key: listing.key,
|
|
300
|
+
name: listing.name,
|
|
301
|
+
package: listing.package,
|
|
302
|
+
version: listing.version,
|
|
303
|
+
},
|
|
304
|
+
href: '/admin/plugins/browse',
|
|
305
|
+
dedupeKey: `marketplace.update_available:${listing.key}:${listing.version}`,
|
|
306
|
+
})
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
}),
|
|
310
|
+
),
|
|
311
|
+
...contributed,
|
|
312
|
+
]),
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function marketplaceFeedUrl(db: Database): Promise<string> {
|
|
317
|
+
try {
|
|
318
|
+
const overrides = await new PostgresSettingsRepository(db).loadAll()
|
|
319
|
+
return SettingsSnapshot.fromOverrides(new Map(overrides)).get('marketplace.feed_url')
|
|
320
|
+
} catch (err) {
|
|
321
|
+
logger({ module: 'tick' }).warn({ err }, 'could not read the marketplace feed URL setting')
|
|
322
|
+
return SettingsSnapshot.fromOverrides(new Map()).get('marketplace.feed_url')
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function vapidDetails(db: Database): Promise<VapidDetails | null> {
|
|
327
|
+
try {
|
|
328
|
+
const overrides = await new PostgresSettingsRepository(db).loadAll()
|
|
329
|
+
const { config } = resolvePushConfig({
|
|
330
|
+
environment: env,
|
|
331
|
+
settings: SettingsSnapshot.fromOverrides(new Map(overrides)),
|
|
332
|
+
})
|
|
333
|
+
return config
|
|
334
|
+
} catch (err) {
|
|
335
|
+
logger({ module: 'tick' }).warn({ err }, 'could not read the web push configuration')
|
|
336
|
+
return null
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function authEventRetentionDays(db: Database): Promise<number> {
|
|
341
|
+
try {
|
|
342
|
+
const overrides = await new PostgresSettingsRepository(db).loadAll()
|
|
343
|
+
return SettingsSnapshot.fromOverrides(new Map(overrides)).get(
|
|
344
|
+
'security.auth_event_retention_days',
|
|
345
|
+
)
|
|
346
|
+
} catch (err) {
|
|
347
|
+
logger({ module: 'tick' }).warn({ err }, 'could not read the sign-in activity retention')
|
|
348
|
+
return 0
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const ERROR_DETAIL_MAX = 500
|
|
353
|
+
|
|
354
|
+
function taskFailureNotifier(
|
|
355
|
+
notifications: PostgresNotificationRepository,
|
|
356
|
+
): (taskId: string, error: unknown) => void {
|
|
357
|
+
const log = logger({ module: 'tick' })
|
|
358
|
+
|
|
359
|
+
return (taskId, error) => {
|
|
360
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
361
|
+
log.error({ taskId, err: error }, 'scheduled task failed')
|
|
362
|
+
|
|
363
|
+
void new NotificationService({ notifications })
|
|
364
|
+
.raiseForAdministrators({
|
|
365
|
+
kind: 'system.task_failed',
|
|
366
|
+
data: { taskId, error: message.slice(0, ERROR_DETAIL_MAX) },
|
|
367
|
+
dedupeKey: `system.task_failed:${taskId}`,
|
|
368
|
+
})
|
|
369
|
+
.catch((err: unknown) => {
|
|
370
|
+
log.error({ taskId, err }, 'could not raise the task-failure notification')
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { type BanRepository, BanService } from '@meith/accounts'
|
|
2
|
+
import type { AttachmentService } from '@meith/attachments'
|
|
3
|
+
import type { AvatarService } from '@meith/avatars'
|
|
4
|
+
import { optional, type QueueDriver } from '@meith/core'
|
|
5
|
+
import { type EventRegistry, type OutboxReader, relayOutbox as runOutboxRelay } from '@meith/events'
|
|
6
|
+
import { type PromotionGuards, PromotionService } from '@meith/groups'
|
|
7
|
+
import {
|
|
8
|
+
type MarketplaceCacheRepository,
|
|
9
|
+
type MarketplaceListing,
|
|
10
|
+
MEITH_VERSION,
|
|
11
|
+
PLUGIN_API_MAJOR,
|
|
12
|
+
refreshCatalog,
|
|
13
|
+
THEME_API_MAJOR,
|
|
14
|
+
} from '@meith/marketplace'
|
|
15
|
+
import { type WarningRepository, WarningService } from '@meith/moderation'
|
|
16
|
+
import type { NotificationService } from '@meith/notifications'
|
|
17
|
+
import type { PluginDefinition } from '@meith/plugin-kit'
|
|
18
|
+
import {
|
|
19
|
+
SubscriptionNotifier,
|
|
20
|
+
type SubscriptionRepository,
|
|
21
|
+
type VisibleForumSource,
|
|
22
|
+
} from '@meith/subscriptions'
|
|
23
|
+
import type { TaskWorkers } from '@meith/tasks'
|
|
24
|
+
|
|
25
|
+
import { SEED_GROUP } from './groups'
|
|
26
|
+
import { deliverWebhooks, type WebhookDeliveryStore } from './webhook-delivery'
|
|
27
|
+
|
|
28
|
+
export interface TaskWorkerDeps {
|
|
29
|
+
readonly queue: QueueDriver
|
|
30
|
+
readonly bans: BanRepository
|
|
31
|
+
readonly promotions: ConstructorParameters<typeof PromotionService>[0]['promotions']
|
|
32
|
+
readonly guards: PromotionGuards
|
|
33
|
+
readonly maintenance: {
|
|
34
|
+
pruneSessions(now: Date, limit?: number): Promise<number>
|
|
35
|
+
pruneExpiredTokens(now: Date, limit?: number): Promise<number>
|
|
36
|
+
}
|
|
37
|
+
readonly rateLimits?: { prune(before: Date, limit?: number): Promise<number> }
|
|
38
|
+
readonly authEvents?: {
|
|
39
|
+
retentionDays(): Promise<number>
|
|
40
|
+
pruneBefore(cutoff: Date, limit?: number): Promise<number>
|
|
41
|
+
}
|
|
42
|
+
readonly timedGroups?: { expire(limit: number): Promise<number> }
|
|
43
|
+
readonly outbox: OutboxReader
|
|
44
|
+
readonly events: EventRegistry
|
|
45
|
+
readonly recount: { run(batchSize: number): Promise<{ corrected: number }> }
|
|
46
|
+
readonly threadViews: { flush(limit: number): Promise<number> }
|
|
47
|
+
readonly renderBackfill: { run(batchSize: number): Promise<{ rendered: number }> }
|
|
48
|
+
readonly searchIndex?:
|
|
49
|
+
| { reindexChunk(afterPostId: number, limit: number): Promise<{ indexed: number }> }
|
|
50
|
+
| undefined
|
|
51
|
+
readonly webhooks?: WebhookDeliveryStore | undefined
|
|
52
|
+
readonly warnings: WarningRepository
|
|
53
|
+
readonly subscriptions?: {
|
|
54
|
+
readonly repository: SubscriptionRepository
|
|
55
|
+
readonly notifications: NotificationService
|
|
56
|
+
readonly forums: VisibleForumSource
|
|
57
|
+
readonly unsubscribeSecret: string | null
|
|
58
|
+
}
|
|
59
|
+
readonly attachments?: AttachmentService
|
|
60
|
+
readonly avatars?: AvatarService
|
|
61
|
+
readonly statistics?: {
|
|
62
|
+
readonly stats: { rollUp(now: Date): Promise<{ memberCount: number }> }
|
|
63
|
+
readonly presence: {
|
|
64
|
+
concurrentCount(now: Date): Promise<number>
|
|
65
|
+
recordIfHigher(count: number, now: Date): Promise<boolean>
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
readonly marketplace?: {
|
|
69
|
+
readonly repository: MarketplaceCacheRepository
|
|
70
|
+
readonly plugins: readonly PluginDefinition[]
|
|
71
|
+
readonly feedUrl: () => Promise<string>
|
|
72
|
+
readonly notifyUpdate: (listing: MarketplaceListing) => Promise<void>
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function defaultPromotionGuards(): PromotionGuards {
|
|
77
|
+
return {
|
|
78
|
+
protectedGroupIds: [SEED_GROUP.banned, SEED_GROUP.administrators, SEED_GROUP.superModerators],
|
|
79
|
+
rank: new Map([
|
|
80
|
+
[SEED_GROUP.guest, 0],
|
|
81
|
+
[SEED_GROUP.registered, 2],
|
|
82
|
+
[SEED_GROUP.superModerators, 8],
|
|
83
|
+
[SEED_GROUP.administrators, 9],
|
|
84
|
+
]),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function taskWorkers(deps: TaskWorkerDeps): Partial<TaskWorkers> {
|
|
89
|
+
const bans = new BanService({ bans: deps.bans, bannedGroupId: SEED_GROUP.banned })
|
|
90
|
+
const promotions = new PromotionService({
|
|
91
|
+
promotions: deps.promotions,
|
|
92
|
+
guards: deps.guards,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
async relayOutbox(batchSize) {
|
|
97
|
+
const { claimed } = await runOutboxRelay({
|
|
98
|
+
reader: deps.outbox,
|
|
99
|
+
target: {
|
|
100
|
+
async enqueue(jobs) {
|
|
101
|
+
for (const job of jobs) {
|
|
102
|
+
await deps.queue.enqueue(job.name, job.payload, {
|
|
103
|
+
dedupeKey: job.idempotencyKey,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
handlerIdsFor: (event) => deps.events.handlerIdsFor(event),
|
|
109
|
+
batchSize,
|
|
110
|
+
})
|
|
111
|
+
return claimed
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async drainQueue(batchSize, signal) {
|
|
115
|
+
const { processed } = await deps.queue.drain(
|
|
116
|
+
batchSize,
|
|
117
|
+
async (job) => {
|
|
118
|
+
await deps.events.dispatch(job.kind, job.payload)
|
|
119
|
+
},
|
|
120
|
+
{ signal },
|
|
121
|
+
)
|
|
122
|
+
return processed
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async reconcileCounters(batchSize) {
|
|
126
|
+
const { corrected } = await deps.recount.run(batchSize)
|
|
127
|
+
return corrected
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async flushThreadViews(batchSize) {
|
|
131
|
+
return deps.threadViews.flush(batchSize)
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
async backfillPostRenders(batchSize) {
|
|
135
|
+
const { rendered } = await deps.renderBackfill.run(batchSize)
|
|
136
|
+
return rendered
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
...optional(deps.searchIndex, (searchIndex) => ({
|
|
140
|
+
async reindexSearch(batchSize: number) {
|
|
141
|
+
const { indexed } = await searchIndex.reindexChunk(0, batchSize)
|
|
142
|
+
return indexed
|
|
143
|
+
},
|
|
144
|
+
})),
|
|
145
|
+
|
|
146
|
+
...optional(deps.webhooks, (webhooks) => ({
|
|
147
|
+
async deliverWebhooks(batchSize: number) {
|
|
148
|
+
return deliverWebhooks(webhooks, batchSize)
|
|
149
|
+
},
|
|
150
|
+
})),
|
|
151
|
+
|
|
152
|
+
async pruneSessions() {
|
|
153
|
+
return deps.maintenance.pruneSessions(new Date())
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
async pruneExpiredTokens() {
|
|
157
|
+
return deps.maintenance.pruneExpiredTokens(new Date())
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
...(deps.rateLimits === undefined
|
|
161
|
+
? {}
|
|
162
|
+
: {
|
|
163
|
+
async pruneRateLimits() {
|
|
164
|
+
const before = new Date(Date.now() - 2 * 3600 * 1000)
|
|
165
|
+
return deps.rateLimits!.prune(before)
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
|
|
169
|
+
...optional(deps.authEvents, (authEvents) => ({
|
|
170
|
+
async pruneAuthEvents() {
|
|
171
|
+
const days = await authEvents.retentionDays()
|
|
172
|
+
if (days <= 0) return 0
|
|
173
|
+
|
|
174
|
+
return authEvents.pruneBefore(new Date(Date.now() - days * 86_400_000))
|
|
175
|
+
},
|
|
176
|
+
})),
|
|
177
|
+
|
|
178
|
+
async applyPromotions(batchSize) {
|
|
179
|
+
const result = await promotions.apply(batchSize)
|
|
180
|
+
return result.outcomes.length
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async expireBans(batchSize) {
|
|
184
|
+
return bans.expireDue(batchSize)
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
...optional(deps.timedGroups, (timedGroups) => ({
|
|
188
|
+
async expireGroupMemberships(batchSize: number) {
|
|
189
|
+
return timedGroups.expire(batchSize)
|
|
190
|
+
},
|
|
191
|
+
})),
|
|
192
|
+
|
|
193
|
+
async expireWarnings(batchSize) {
|
|
194
|
+
return new WarningService({ warnings: deps.warnings, bans: banPort(bans) }).expireDue(
|
|
195
|
+
batchSize,
|
|
196
|
+
)
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
...optional(deps.subscriptions, () => ({
|
|
200
|
+
async notifySubscribers(batchSize: number, signal: AbortSignal) {
|
|
201
|
+
const { notified } = await subscriptionNotifier(deps).runInstant(batchSize, signal)
|
|
202
|
+
return notified
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
async sendDigests(batchSize: number, signal: AbortSignal) {
|
|
206
|
+
const notifier = subscriptionNotifier(deps)
|
|
207
|
+
const daily = await notifier.runDigest('daily', batchSize, signal)
|
|
208
|
+
if (signal.aborted) return daily.notified
|
|
209
|
+
|
|
210
|
+
const weekly = await notifier.runDigest('weekly', batchSize, signal)
|
|
211
|
+
return daily.notified + weekly.notified
|
|
212
|
+
},
|
|
213
|
+
})),
|
|
214
|
+
|
|
215
|
+
...optional(deps.attachments, (attachments) => ({
|
|
216
|
+
async sweepAttachments(batchSize: number) {
|
|
217
|
+
return attachments.sweep(batchSize)
|
|
218
|
+
},
|
|
219
|
+
})),
|
|
220
|
+
|
|
221
|
+
...optional(deps.avatars, (avatars) => ({
|
|
222
|
+
async sweepAvatars(batchSize: number) {
|
|
223
|
+
return avatars.sweep(batchSize)
|
|
224
|
+
},
|
|
225
|
+
})),
|
|
226
|
+
|
|
227
|
+
...optional(deps.marketplace, (marketplace) => ({
|
|
228
|
+
async refreshMarketplaceCatalog() {
|
|
229
|
+
const url = await marketplace.feedUrl()
|
|
230
|
+
return refreshCatalog({
|
|
231
|
+
url,
|
|
232
|
+
repository: marketplace.repository,
|
|
233
|
+
build: {
|
|
234
|
+
meithVersion: MEITH_VERSION,
|
|
235
|
+
pluginApiMajor: PLUGIN_API_MAJOR,
|
|
236
|
+
themeApiMajor: THEME_API_MAJOR,
|
|
237
|
+
},
|
|
238
|
+
resolveInstalled: (listing) => {
|
|
239
|
+
const definition = marketplace.plugins.find((plugin) => plugin.key === listing.key)
|
|
240
|
+
return definition === undefined ? null : { enabled: true, version: definition.version }
|
|
241
|
+
},
|
|
242
|
+
notifyUpdate: marketplace.notifyUpdate,
|
|
243
|
+
})
|
|
244
|
+
},
|
|
245
|
+
})),
|
|
246
|
+
|
|
247
|
+
...optional(deps.statistics, (statistics) => ({
|
|
248
|
+
async rollUpStatistics() {
|
|
249
|
+
const now = new Date()
|
|
250
|
+
const { memberCount } = await statistics.stats.rollUp(now)
|
|
251
|
+
const online = await statistics.presence.concurrentCount(now)
|
|
252
|
+
const record = await statistics.presence.recordIfHigher(online, now)
|
|
253
|
+
return { memberCount, online, record }
|
|
254
|
+
},
|
|
255
|
+
})),
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function subscriptionNotifier(deps: TaskWorkerDeps): SubscriptionNotifier {
|
|
260
|
+
const wiring = deps.subscriptions!
|
|
261
|
+
return new SubscriptionNotifier({
|
|
262
|
+
subscriptions: wiring.repository,
|
|
263
|
+
forums: wiring.forums,
|
|
264
|
+
unsubscribeSecret: wiring.unsubscribeSecret,
|
|
265
|
+
notifications: {
|
|
266
|
+
async raise(input) {
|
|
267
|
+
await wiring.notifications.raise({
|
|
268
|
+
userId: input.userId,
|
|
269
|
+
kind: input.kind,
|
|
270
|
+
data: input.data as Parameters<NotificationService['raise']>[0]['data'],
|
|
271
|
+
href: input.href,
|
|
272
|
+
dedupeKey: input.dedupeKey,
|
|
273
|
+
})
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function banPort(bans: BanService): {
|
|
280
|
+
ban: (input: {
|
|
281
|
+
userId: number
|
|
282
|
+
bannedByUserId: number
|
|
283
|
+
reason: string
|
|
284
|
+
publicReason: string
|
|
285
|
+
expiresAt?: Date | undefined
|
|
286
|
+
}) => Promise<void>
|
|
287
|
+
} {
|
|
288
|
+
return {
|
|
289
|
+
async ban(input) {
|
|
290
|
+
await bans.ban(input)
|
|
291
|
+
},
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ActorSource, Authorizer } from '@meith/authorization'
|
|
2
|
+
import { logger } from '@meith/core'
|
|
3
|
+
import type { VisibleForumSource } from '@meith/subscriptions'
|
|
4
|
+
|
|
5
|
+
export function visibleForumSource(deps: {
|
|
6
|
+
readonly authorizer: Authorizer
|
|
7
|
+
readonly actors: ActorSource
|
|
8
|
+
}): VisibleForumSource {
|
|
9
|
+
const log = logger({ module: 'subscriptions' })
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
async visibleForumIdsFor(userId) {
|
|
13
|
+
try {
|
|
14
|
+
const actor = await deps.actors.buildForUser(userId)
|
|
15
|
+
if (actor === null) return []
|
|
16
|
+
return await deps.authorizer.visibleForumIds(actor)
|
|
17
|
+
} catch (err) {
|
|
18
|
+
log.warn({ err, userId }, 'could not resolve visible forums for a subscriber')
|
|
19
|
+
return []
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|