@evanp/activitypub-bot 0.21.1 → 0.22.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/bin/activitypub-bot.js +3 -1
- package/lib/activitydeliverer.js +15 -35
- package/lib/activitydistributor.js +30 -68
- package/lib/app.js +68 -8
- package/lib/authorizer.js +114 -11
- package/lib/deliveryworker.js +96 -0
- package/lib/distributionworker.js +79 -0
- package/lib/jobqueue.js +224 -0
- package/lib/jobreaper.js +38 -0
- package/lib/migrations/003-jobqueue.js +20 -0
- package/lib/migrations/004-retry-after.js +8 -0
- package/lib/migrations/index.js +15 -6
- package/lib/routes/server.js +27 -15
- package/package.json +1 -1
- package/web/index.html +17 -0
package/bin/activitypub-bot.js
CHANGED
|
@@ -53,7 +53,9 @@ const LOG_LEVEL =
|
|
|
53
53
|
|
|
54
54
|
const bots = (await import(BOTS_CONFIG_FILE)).default
|
|
55
55
|
|
|
56
|
-
const app = await makeApp(
|
|
56
|
+
const app = await makeApp({
|
|
57
|
+
databaseUrl: DATABASE_URL, origin: ORIGIN, bots, logLevel: LOG_LEVEL
|
|
58
|
+
})
|
|
57
59
|
|
|
58
60
|
const server = app.listen(parseInt(PORT), () => {
|
|
59
61
|
app.locals.logger.info(`Listening on port ${PORT}`)
|
package/lib/activitydeliverer.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import BotMaker from './botmaker.js'
|
|
2
2
|
import assert from 'node:assert'
|
|
3
3
|
import as2 from './activitystreams.js'
|
|
4
|
-
import PQueue from 'p-queue'
|
|
5
4
|
import * as ttlcachePkg from '@isaacs/ttlcache'
|
|
6
5
|
|
|
7
6
|
const TTLCache =
|
|
@@ -21,27 +20,28 @@ const COLLECTION_TYPES = [
|
|
|
21
20
|
]
|
|
22
21
|
|
|
23
22
|
export class ActivityDeliverer {
|
|
24
|
-
static #
|
|
25
|
-
static #MAX_BOT_QUEUE = 32
|
|
23
|
+
static #QUEUE_ID = 'delivery'
|
|
26
24
|
static #TTL_SEEN_PUBLIC = 3 * 24 * 60 * 60 * 1000 // 3 days
|
|
27
25
|
static #MAX_SEEN_PUBLIC = 1000000
|
|
28
26
|
#actorStorage
|
|
29
|
-
#activityHandler
|
|
30
27
|
#formatter
|
|
31
28
|
#logger
|
|
32
29
|
#client
|
|
33
|
-
#
|
|
34
|
-
#deliverBotQueue
|
|
30
|
+
#jobQueue
|
|
35
31
|
#seenPublic
|
|
36
32
|
|
|
37
|
-
constructor (actorStorage,
|
|
33
|
+
constructor (actorStorage, formatter, logger, client, jobQueue) {
|
|
34
|
+
assert.strictEqual(typeof actorStorage, 'object')
|
|
35
|
+
assert.strictEqual(typeof formatter, 'object')
|
|
36
|
+
assert.strictEqual(typeof logger, 'object')
|
|
37
|
+
assert.strictEqual(typeof client, 'object')
|
|
38
|
+
assert.strictEqual(typeof jobQueue, 'object')
|
|
39
|
+
|
|
38
40
|
this.#actorStorage = actorStorage
|
|
39
|
-
this.#activityHandler = activityHandler
|
|
40
41
|
this.#formatter = formatter
|
|
41
42
|
this.#client = client
|
|
42
43
|
this.#logger = logger.child({ class: this.constructor.name })
|
|
43
|
-
this.#
|
|
44
|
-
this.#deliverBotQueue = new PQueue({ max: ActivityDeliverer.#MAX_BOT_QUEUE })
|
|
44
|
+
this.#jobQueue = jobQueue
|
|
45
45
|
this.#seenPublic = new TTLCache({
|
|
46
46
|
ttl: ActivityDeliverer.#TTL_SEEN_PUBLIC,
|
|
47
47
|
max: ActivityDeliverer.#MAX_SEEN_PUBLIC
|
|
@@ -96,40 +96,20 @@ export class ActivityDeliverer {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
async deliverTo (activity, bot) {
|
|
99
|
-
this.#
|
|
100
|
-
|
|
99
|
+
await this.#jobQueue.enqueue(
|
|
100
|
+
ActivityDeliverer.#QUEUE_ID,
|
|
101
|
+
{ botUsername: bot.username, activity: await activity.export() }
|
|
101
102
|
)
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
async onIdle () {
|
|
105
106
|
this.#logger.debug('Awaiting delivery queues')
|
|
106
|
-
await this.#
|
|
107
|
-
await this.#deliverBotQueue.onIdle()
|
|
107
|
+
await this.#jobQueue.onIdle(ActivityDeliverer.#QUEUE_ID)
|
|
108
108
|
this.#logger.debug('Done awaiting delivery queues')
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
async #deliverTo (activity, bot) {
|
|
112
|
-
if (await this.#actorStorage.isInCollection(bot.username, 'inbox', activity)) {
|
|
113
|
-
this.#logger.info(
|
|
114
|
-
{ activity: activity.id, username: bot.username },
|
|
115
|
-
'skipping redelivery for activity already in the inbox'
|
|
116
|
-
)
|
|
117
|
-
return
|
|
118
|
-
}
|
|
119
|
-
try {
|
|
120
|
-
await this.#activityHandler.handleActivity(bot, activity)
|
|
121
|
-
} catch (err) {
|
|
122
|
-
this.#logger.warn(err)
|
|
123
|
-
}
|
|
124
|
-
this.#logger.debug(`Adding ${activity.id} to ${bot.username} inbox`)
|
|
125
|
-
await this.#actorStorage.addToCollection(bot.username, 'inbox', activity)
|
|
126
|
-
this.#logger.debug(`Done adding ${activity.id} to ${bot.username} inbox`)
|
|
127
|
-
}
|
|
128
|
-
|
|
129
111
|
async deliverToAll (activity, bots) {
|
|
130
|
-
this.#
|
|
131
|
-
this.#deliverToAll(activity, bots)
|
|
132
|
-
)
|
|
112
|
+
await this.#deliverToAll(activity, bots)
|
|
133
113
|
}
|
|
134
114
|
|
|
135
115
|
async #deliverToAll (activity, bots) {
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert'
|
|
2
2
|
import as2 from './activitystreams.js'
|
|
3
3
|
import { LRUCache } from 'lru-cache'
|
|
4
|
-
import PQueue from 'p-queue'
|
|
5
|
-
import { setTimeout } from 'node:timers/promises'
|
|
6
4
|
|
|
7
5
|
const NS = 'https://www.w3.org/ns/activitystreams#'
|
|
8
6
|
|
|
@@ -12,9 +10,9 @@ const COLLECTION_TYPES = [
|
|
|
12
10
|
]
|
|
13
11
|
|
|
14
12
|
export class ActivityDistributor {
|
|
13
|
+
static #DISTRIBUTION_QUEUE_ID = 'distribution'
|
|
14
|
+
static #DELIVERY_QUEUE_ID = 'delivery'
|
|
15
15
|
static #MAX_CACHE_SIZE = 1000000
|
|
16
|
-
static #CONCURRENCY = 32
|
|
17
|
-
static #MAX_ATTEMPTS = 16
|
|
18
16
|
static #PUBLIC = [
|
|
19
17
|
'https://www.w3.org/ns/activitystreams#Public',
|
|
20
18
|
'as:Public',
|
|
@@ -26,23 +24,27 @@ export class ActivityDistributor {
|
|
|
26
24
|
#actorStorage = null
|
|
27
25
|
#directInboxCache = null
|
|
28
26
|
#sharedInboxCache = null
|
|
29
|
-
#queue = null
|
|
30
|
-
#retryQueue = null
|
|
31
27
|
#logger = null
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
#jobQueue = null
|
|
29
|
+
|
|
30
|
+
constructor (client, formatter, actorStorage, logger, jobQueue) {
|
|
31
|
+
assert.ok(client)
|
|
32
|
+
assert.ok(formatter)
|
|
33
|
+
assert.ok(actorStorage)
|
|
34
|
+
assert.ok(logger)
|
|
35
|
+
assert.ok(jobQueue)
|
|
34
36
|
this.#client = client
|
|
35
37
|
this.#formatter = formatter
|
|
36
38
|
this.#actorStorage = actorStorage
|
|
37
39
|
this.#logger = logger.child({ class: this.constructor.name })
|
|
40
|
+
this.#jobQueue = jobQueue
|
|
38
41
|
this.#directInboxCache = new LRUCache({ max: ActivityDistributor.#MAX_CACHE_SIZE })
|
|
39
42
|
this.#sharedInboxCache = new LRUCache({ max: ActivityDistributor.#MAX_CACHE_SIZE })
|
|
40
|
-
this.#queue = new PQueue({ concurrency: ActivityDistributor.#CONCURRENCY })
|
|
41
|
-
this.#retryQueue = new PQueue()
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
async distribute (activity, username) {
|
|
45
46
|
const stripped = await this.#strip(activity)
|
|
47
|
+
const raw = await stripped.export()
|
|
46
48
|
const actorId = this.#formatter.format({ username })
|
|
47
49
|
|
|
48
50
|
const delivered = new Set()
|
|
@@ -52,8 +54,11 @@ export class ActivityDistributor {
|
|
|
52
54
|
if (await this.#isLocal(recipient)) {
|
|
53
55
|
if (recipient !== actorId && !localDelivered.has(recipient)) {
|
|
54
56
|
localDelivered.add(recipient)
|
|
55
|
-
this.#
|
|
56
|
-
|
|
57
|
+
const parts = this.#formatter.unformat(recipient)
|
|
58
|
+
await this.#jobQueue.enqueue(
|
|
59
|
+
ActivityDistributor.#DELIVERY_QUEUE_ID,
|
|
60
|
+
{ botUsername: parts.username, activity: raw }
|
|
61
|
+
)
|
|
57
62
|
}
|
|
58
63
|
} else {
|
|
59
64
|
const inbox = await this.#getInbox(recipient, username)
|
|
@@ -61,8 +66,9 @@ export class ActivityDistributor {
|
|
|
61
66
|
this.#logger.warn({ id: recipient.id }, 'No inbox')
|
|
62
67
|
} else if (!delivered.has(inbox)) {
|
|
63
68
|
delivered.add(inbox)
|
|
64
|
-
this.#
|
|
65
|
-
|
|
69
|
+
await this.#jobQueue.enqueue(
|
|
70
|
+
ActivityDistributor.#DISTRIBUTION_QUEUE_ID,
|
|
71
|
+
{ inbox, activity: raw, username }
|
|
66
72
|
)
|
|
67
73
|
}
|
|
68
74
|
}
|
|
@@ -72,8 +78,11 @@ export class ActivityDistributor {
|
|
|
72
78
|
if (await this.#isLocal(recipient)) {
|
|
73
79
|
if (recipient !== actorId && !localDelivered.has(recipient)) {
|
|
74
80
|
localDelivered.add(recipient)
|
|
75
|
-
this.#
|
|
76
|
-
|
|
81
|
+
const parts = this.#formatter.unformat(recipient)
|
|
82
|
+
await this.#jobQueue.enqueue(
|
|
83
|
+
ActivityDistributor.#DELIVERY_QUEUE_ID,
|
|
84
|
+
{ botUsername: parts.username, activity: raw }
|
|
85
|
+
)
|
|
77
86
|
}
|
|
78
87
|
} else {
|
|
79
88
|
const inbox = await this.#getDirectInbox(recipient, username)
|
|
@@ -81,8 +90,9 @@ export class ActivityDistributor {
|
|
|
81
90
|
this.#logger.warn({ id: recipient.id }, 'No direct inbox')
|
|
82
91
|
} else if (!delivered.has(inbox)) {
|
|
83
92
|
delivered.add(inbox)
|
|
84
|
-
this.#
|
|
85
|
-
|
|
93
|
+
await this.#jobQueue.enqueue(
|
|
94
|
+
ActivityDistributor.#DISTRIBUTION_QUEUE_ID,
|
|
95
|
+
{ inbox, activity: raw, username }
|
|
86
96
|
)
|
|
87
97
|
}
|
|
88
98
|
}
|
|
@@ -90,8 +100,8 @@ export class ActivityDistributor {
|
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
async onIdle () {
|
|
93
|
-
await this.#
|
|
94
|
-
await this.#
|
|
103
|
+
await this.#jobQueue.onIdle(ActivityDistributor.#DELIVERY_QUEUE_ID)
|
|
104
|
+
await this.#jobQueue.onIdle(ActivityDistributor.#DISTRIBUTION_QUEUE_ID)
|
|
95
105
|
}
|
|
96
106
|
|
|
97
107
|
async * #public (activity, username) {
|
|
@@ -237,58 +247,10 @@ export class ActivityDistributor {
|
|
|
237
247
|
return await as2.import(exported)
|
|
238
248
|
}
|
|
239
249
|
|
|
240
|
-
async #deliver (inbox, activity, username, attempt = 1) {
|
|
241
|
-
try {
|
|
242
|
-
await this.#client.post(inbox, activity, username)
|
|
243
|
-
this.#logInfo(`Delivered ${activity.id} to ${inbox}`)
|
|
244
|
-
} catch (error) {
|
|
245
|
-
if (!error.status) {
|
|
246
|
-
this.#logError(`Could not deliver ${activity.id} to ${inbox}: ${error.message}`)
|
|
247
|
-
this.#logError(error.stack)
|
|
248
|
-
} else if (error.status >= 300 && error.status < 400) {
|
|
249
|
-
this.#logError(`Unexpected redirect code delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}`)
|
|
250
|
-
} else if (error.status >= 400 && error.status < 500) {
|
|
251
|
-
this.#logError(`Bad request delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}`)
|
|
252
|
-
} else if (error.status >= 500 && error.status < 600) {
|
|
253
|
-
if (attempt >= ActivityDistributor.#MAX_ATTEMPTS) {
|
|
254
|
-
this.#logError(`Server error delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}; giving up after ${attempt} attempts`)
|
|
255
|
-
}
|
|
256
|
-
const delay = Math.round((2 ** (attempt - 1) * 1000) * (0.5 + Math.random()))
|
|
257
|
-
this.#logWarning(`Server error delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}; will retry in ${delay} ms (${attempt} of ${ActivityDistributor.#MAX_ATTEMPTS})`)
|
|
258
|
-
this.#retryQueue.add(() => setTimeout(delay).then(() => this.#deliver(inbox, activity, username, attempt + 1)))
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
#logError (message) {
|
|
264
|
-
if (this.#logger) {
|
|
265
|
-
this.#logger.error(message)
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
#logWarning (message) {
|
|
270
|
-
if (this.#logger) {
|
|
271
|
-
this.#logger.warn(message)
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
#logInfo (message) {
|
|
276
|
-
if (this.#logger) {
|
|
277
|
-
this.#logger.info(message)
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
250
|
#isLocal (id) {
|
|
282
251
|
return this.#formatter.isLocal(id)
|
|
283
252
|
}
|
|
284
253
|
|
|
285
|
-
async #deliverLocal (id, activity) {
|
|
286
|
-
const username = this.#formatter.getUserName(id)
|
|
287
|
-
if (username) {
|
|
288
|
-
await this.#actorStorage.addToCollection(username, 'inbox', activity)
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
254
|
#isLocalActor (parts) {
|
|
293
255
|
return parts.username && !parts.type && !parts.collection
|
|
294
256
|
}
|
package/lib/app.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
import { resolve, dirname } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
1
5
|
import { Sequelize } from 'sequelize'
|
|
2
6
|
import express from 'express'
|
|
3
7
|
import Logger from 'pino'
|
|
4
8
|
import HTTPLogger from 'pino-http'
|
|
5
|
-
|
|
9
|
+
|
|
6
10
|
import { ActivityDistributor } from './activitydistributor.js'
|
|
7
11
|
import { ActivityPubClient } from './activitypubclient.js'
|
|
8
12
|
import { ActorStorage } from './actorstorage.js'
|
|
@@ -29,8 +33,15 @@ import { HTTPSignatureAuthenticator } from './httpsignatureauthenticator.js'
|
|
|
29
33
|
import { Digester } from './digester.js'
|
|
30
34
|
import { runMigrations } from './migrations/index.js'
|
|
31
35
|
import { ActivityDeliverer } from './activitydeliverer.js'
|
|
36
|
+
import { JobQueue } from './jobqueue.js'
|
|
37
|
+
import { JobReaper } from './jobreaper.js'
|
|
38
|
+
import { DeliveryWorker } from './deliveryworker.js'
|
|
39
|
+
import { DistributionWorker } from './distributionworker.js'
|
|
40
|
+
|
|
41
|
+
const currentDir = dirname(fileURLToPath(import.meta.url))
|
|
42
|
+
const DEFAULT_INDEX_FILENAME = resolve(currentDir, '..', 'web', 'index.html')
|
|
32
43
|
|
|
33
|
-
export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
|
|
44
|
+
export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent', deliveryWorkerCount = 2, distributionWorkerCount = 8, indexFileName = DEFAULT_INDEX_FILENAME }) {
|
|
34
45
|
const logger = Logger({
|
|
35
46
|
level: logLevel
|
|
36
47
|
})
|
|
@@ -50,11 +61,13 @@ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
|
|
|
50
61
|
new ActivityPubClient(keyStorage, formatter, signer, digester, logger)
|
|
51
62
|
const remoteKeyStorage = new RemoteKeyStorage(client, connection, logger)
|
|
52
63
|
const signature = new HTTPSignatureAuthenticator(remoteKeyStorage, signer, digester, logger)
|
|
64
|
+
const jobQueue = new JobQueue(connection, logger)
|
|
53
65
|
const distributor = new ActivityDistributor(
|
|
54
66
|
client,
|
|
55
67
|
formatter,
|
|
56
68
|
actorStorage,
|
|
57
|
-
logger
|
|
69
|
+
logger,
|
|
70
|
+
jobQueue
|
|
58
71
|
)
|
|
59
72
|
const authorizer = new Authorizer(actorStorage, formatter, client)
|
|
60
73
|
const cache = new ObjectCache({
|
|
@@ -74,10 +87,10 @@ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
|
|
|
74
87
|
)
|
|
75
88
|
const deliverer = new ActivityDeliverer(
|
|
76
89
|
actorStorage,
|
|
77
|
-
activityHandler,
|
|
78
90
|
formatter,
|
|
79
91
|
logger,
|
|
80
|
-
client
|
|
92
|
+
client,
|
|
93
|
+
jobQueue
|
|
81
94
|
)
|
|
82
95
|
|
|
83
96
|
// TODO: Make an endpoint for tagged objects
|
|
@@ -99,6 +112,41 @@ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
|
|
|
99
112
|
))
|
|
100
113
|
)
|
|
101
114
|
|
|
115
|
+
const deliveryWorkers = new Array(deliveryWorkerCount)
|
|
116
|
+
const deliveryWorkerRuns = new Array(deliveryWorkerCount)
|
|
117
|
+
|
|
118
|
+
for (let i = 0; i < deliveryWorkers.length; i++) {
|
|
119
|
+
deliveryWorkers[i] = new DeliveryWorker(
|
|
120
|
+
jobQueue,
|
|
121
|
+
actorStorage,
|
|
122
|
+
activityHandler,
|
|
123
|
+
logger,
|
|
124
|
+
bots
|
|
125
|
+
)
|
|
126
|
+
deliveryWorkerRuns[i] = deliveryWorkers[i].run().catch((err) => {
|
|
127
|
+
logger.error({ err, workerIndex: i }, 'unexpected error in delivery worker')
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const distributionWorkers = new Array(distributionWorkerCount)
|
|
132
|
+
const distributionWorkerRuns = new Array(distributionWorkerCount)
|
|
133
|
+
|
|
134
|
+
for (let i = 0; i < distributionWorkers.length; i++) {
|
|
135
|
+
distributionWorkers[i] = new DistributionWorker(
|
|
136
|
+
jobQueue,
|
|
137
|
+
client,
|
|
138
|
+
logger
|
|
139
|
+
)
|
|
140
|
+
distributionWorkerRuns[i] = distributionWorkers[i].run().catch((err) => {
|
|
141
|
+
logger.error({ err, workerIndex: i }, 'unexpected error in distribution worker')
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const jobReaper = new JobReaper(jobQueue, logger)
|
|
146
|
+
const jobReaperRun = jobReaper.run().catch((err) => {
|
|
147
|
+
logger.error({ err }, 'unexpected error in job reaper')
|
|
148
|
+
})
|
|
149
|
+
|
|
102
150
|
const app = express()
|
|
103
151
|
|
|
104
152
|
app.locals = {
|
|
@@ -117,7 +165,9 @@ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
|
|
|
117
165
|
bots,
|
|
118
166
|
activityHandler,
|
|
119
167
|
origin,
|
|
120
|
-
deliverer
|
|
168
|
+
deliverer,
|
|
169
|
+
deliveryWorkers,
|
|
170
|
+
indexFileName
|
|
121
171
|
}
|
|
122
172
|
|
|
123
173
|
app.use(HTTPLogger({
|
|
@@ -196,10 +246,20 @@ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
|
|
|
196
246
|
|
|
197
247
|
app.cleanup = async () => {
|
|
198
248
|
logger.info('Closing app')
|
|
199
|
-
|
|
200
|
-
|
|
249
|
+
for (const worker of deliveryWorkers) {
|
|
250
|
+
worker.stop()
|
|
251
|
+
}
|
|
252
|
+
for (const worker of distributionWorkers) {
|
|
253
|
+
worker.stop()
|
|
254
|
+
}
|
|
255
|
+
jobReaper.stop()
|
|
256
|
+
jobQueue.abort()
|
|
257
|
+
await Promise.allSettled(deliveryWorkerRuns)
|
|
258
|
+
await Promise.allSettled(distributionWorkerRuns)
|
|
259
|
+
await jobReaperRun
|
|
201
260
|
logger.info('Closing database connection')
|
|
202
261
|
await connection.close()
|
|
262
|
+
logger.info('Done')
|
|
203
263
|
}
|
|
204
264
|
|
|
205
265
|
return app
|
package/lib/authorizer.js
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import as2 from './activitystreams.js'
|
|
2
2
|
|
|
3
|
+
const NS = 'https://www.w3.org/ns/activitystreams#'
|
|
4
|
+
|
|
5
|
+
const PUBLICS = [
|
|
6
|
+
`${NS}Public`,
|
|
7
|
+
'as:Public',
|
|
8
|
+
'Public'
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
const COLLECTION_TYPES = [
|
|
12
|
+
`${NS}Collection`,
|
|
13
|
+
`${NS}OrderedCollection`
|
|
14
|
+
]
|
|
15
|
+
|
|
3
16
|
export class Authorizer {
|
|
4
|
-
#PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'
|
|
5
17
|
#actorStorage = null
|
|
6
18
|
#formatter = null
|
|
7
|
-
#
|
|
8
|
-
constructor (actorStorage, formatter,
|
|
19
|
+
#client = null
|
|
20
|
+
constructor (actorStorage, formatter, client) {
|
|
9
21
|
this.#actorStorage = actorStorage
|
|
10
22
|
this.#formatter = formatter
|
|
11
|
-
this.#
|
|
23
|
+
this.#client = client
|
|
12
24
|
}
|
|
13
25
|
|
|
14
26
|
async canRead (actor, object) {
|
|
@@ -40,7 +52,7 @@ export class Authorizer {
|
|
|
40
52
|
async #canReadLocal (actor, object) {
|
|
41
53
|
const recipients = this.#getRecipients(object)
|
|
42
54
|
if (!actor) {
|
|
43
|
-
return recipients.has(
|
|
55
|
+
return PUBLICS.some(id => recipients.has(id))
|
|
44
56
|
}
|
|
45
57
|
const ownerId = (await this.#getOwner(object))?.id
|
|
46
58
|
if (!ownerId) {
|
|
@@ -63,25 +75,38 @@ export class Authorizer {
|
|
|
63
75
|
if (recipients.has(actor.id)) {
|
|
64
76
|
return true
|
|
65
77
|
}
|
|
66
|
-
if (recipients.has(
|
|
78
|
+
if (PUBLICS.some(id => recipients.has(id))) {
|
|
67
79
|
return true
|
|
68
80
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
|
|
82
|
+
const lcolls = this.#getLocalCollections(recipients)
|
|
83
|
+
|
|
84
|
+
for (const id of lcolls) {
|
|
85
|
+
if (await this.#isInLocalCollection(actor, id)) {
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const rcolls = await this.#getRemoteCollections(recipients, ownerName)
|
|
91
|
+
|
|
92
|
+
for (const id of rcolls) {
|
|
93
|
+
if (await this.#isInRemoteCollection(actor, id, ownerName)) {
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
72
96
|
}
|
|
97
|
+
|
|
73
98
|
return false
|
|
74
99
|
}
|
|
75
100
|
|
|
76
101
|
async #canReadRemote (actor, object) {
|
|
77
102
|
const recipients = this.#getRecipients(object)
|
|
78
103
|
if (!actor) {
|
|
79
|
-
return recipients.has(
|
|
104
|
+
return PUBLICS.some(id => recipients.has(id))
|
|
80
105
|
}
|
|
81
106
|
if (recipients.has(actor.id)) {
|
|
82
107
|
return true
|
|
83
108
|
}
|
|
84
|
-
if (recipients.has(
|
|
109
|
+
if (PUBLICS.some(id => recipients.has(id))) {
|
|
85
110
|
return true
|
|
86
111
|
}
|
|
87
112
|
// TODO: check if it's to followers, actor is local, and actor
|
|
@@ -137,4 +162,82 @@ export class Authorizer {
|
|
|
137
162
|
}
|
|
138
163
|
return recipientIds
|
|
139
164
|
}
|
|
165
|
+
|
|
166
|
+
#getLocalCollections (recipients) {
|
|
167
|
+
const lcolls = new Set()
|
|
168
|
+
for (const recipient of recipients) {
|
|
169
|
+
if (this.#isLocalCollection(recipient)) {
|
|
170
|
+
lcolls.add(recipient)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return lcolls
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#isLocalCollection (recipient) {
|
|
177
|
+
if (!this.#formatter.isLocal(recipient)) {
|
|
178
|
+
return false
|
|
179
|
+
} else {
|
|
180
|
+
const parts = this.#formatter.unformat(recipient)
|
|
181
|
+
return !!(parts.username && parts.collection && !parts.type)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#isInLocalCollection (actor, recipient) {
|
|
186
|
+
const parts = this.#formatter.unformat(recipient)
|
|
187
|
+
return this.#actorStorage.isInCollection(
|
|
188
|
+
parts.username,
|
|
189
|
+
parts.collection,
|
|
190
|
+
actor
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async #getRemoteCollections (recipients, ownerName) {
|
|
195
|
+
const rcolls = new Set()
|
|
196
|
+
for (const recipient of recipients) {
|
|
197
|
+
if (await this.#isRemoteCollection(recipient, ownerName)) {
|
|
198
|
+
rcolls.add(recipient)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return rcolls
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async #isRemoteCollection (recipient, ownerName) {
|
|
205
|
+
if (this.#formatter.isLocal(recipient)) {
|
|
206
|
+
return false
|
|
207
|
+
} else {
|
|
208
|
+
const obj = await this.#client.get(recipient, ownerName)
|
|
209
|
+
|
|
210
|
+
return (Array.isArray(obj.type))
|
|
211
|
+
? obj.type.some(item => COLLECTION_TYPES.includes(item))
|
|
212
|
+
: COLLECTION_TYPES.includes(obj.type)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async #isInRemoteCollection (actor, id, ownerName) {
|
|
217
|
+
const coll = await this.#client.get(id, ownerName)
|
|
218
|
+
const collOwner = await this.#getOwner(coll)
|
|
219
|
+
const collOwnerFull = await this.#client.get(collOwner.id, ownerName)
|
|
220
|
+
|
|
221
|
+
// Special case for followers, following collections, since we track
|
|
222
|
+
// that information locally, too
|
|
223
|
+
|
|
224
|
+
if (this.#formatter.isLocal(actor.id)) {
|
|
225
|
+
const { username } = this.#formatter.unformat(actor.id)
|
|
226
|
+
if (coll.id === collOwnerFull.followers.first.id) {
|
|
227
|
+
return await this.#actorStorage.isInCollection(username, 'following', collOwnerFull)
|
|
228
|
+
} else if (coll.id === collOwnerFull.following.first.id) {
|
|
229
|
+
return await this.#actorStorage.isInCollection(username, 'followers', collOwnerFull)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Worst case!
|
|
234
|
+
|
|
235
|
+
for await (const item of this.#client.items(coll.id, ownerName)) {
|
|
236
|
+
if (item.id === actor.id) {
|
|
237
|
+
return true
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
140
243
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
|
|
3
|
+
import { nanoid } from 'nanoid'
|
|
4
|
+
|
|
5
|
+
import as2 from './activitystreams.js'
|
|
6
|
+
import BotMaker from './botmaker.js'
|
|
7
|
+
|
|
8
|
+
export class DeliveryWorker {
|
|
9
|
+
static #QUEUE_ID = 'delivery'
|
|
10
|
+
#queue
|
|
11
|
+
#actorStorage
|
|
12
|
+
#activityHandler
|
|
13
|
+
#logger
|
|
14
|
+
#running
|
|
15
|
+
#workerId
|
|
16
|
+
#bots
|
|
17
|
+
|
|
18
|
+
constructor (queue, actorStorage, activityHandler, logger, bots) {
|
|
19
|
+
this.#queue = queue
|
|
20
|
+
this.#actorStorage = actorStorage
|
|
21
|
+
this.#activityHandler = activityHandler
|
|
22
|
+
this.#workerId = nanoid()
|
|
23
|
+
this.#logger = logger.child({
|
|
24
|
+
class: this.constructor.name,
|
|
25
|
+
worker: this.#workerId
|
|
26
|
+
})
|
|
27
|
+
this.#bots = bots
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async run () {
|
|
31
|
+
this.#running = true
|
|
32
|
+
while (this.#running) {
|
|
33
|
+
let jobId
|
|
34
|
+
let payload
|
|
35
|
+
try {
|
|
36
|
+
this.#logger.debug('dequeueing');
|
|
37
|
+
({ jobId, payload } = await this.#queue.dequeue(
|
|
38
|
+
DeliveryWorker.#QUEUE_ID,
|
|
39
|
+
this.#workerId
|
|
40
|
+
))
|
|
41
|
+
this.#logger.debug({ jobId, payload }, 'got a job')
|
|
42
|
+
const activity = await as2.import(payload.activity)
|
|
43
|
+
assert.ok(payload.botUsername)
|
|
44
|
+
const bot = await BotMaker.makeBot(this.#bots, payload.botUsername)
|
|
45
|
+
assert.ok(bot)
|
|
46
|
+
assert.ok(bot.username)
|
|
47
|
+
this.#logger.debug(
|
|
48
|
+
{ bot: bot.username, activity: activity.id, jobId },
|
|
49
|
+
'delivering to bot'
|
|
50
|
+
)
|
|
51
|
+
await this.#deliverTo(activity, bot)
|
|
52
|
+
this.#logger.debug({ bot: bot.username, activity: activity.id, jobId }, 'done')
|
|
53
|
+
await this.#queue.complete(jobId, this.#workerId)
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err?.name === 'AbortError') {
|
|
56
|
+
this.#logger.info('Worker received abort signal')
|
|
57
|
+
break
|
|
58
|
+
}
|
|
59
|
+
this.#logger.warn({ err, jobId }, 'Error delivering to bot')
|
|
60
|
+
if (jobId) {
|
|
61
|
+
await this.#queue.release(jobId, this.#workerId)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
stop () {
|
|
68
|
+
this.#running = false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async #deliverTo (activity, bot) {
|
|
72
|
+
assert.ok(activity)
|
|
73
|
+
assert.strictEqual(typeof activity, 'object')
|
|
74
|
+
assert.ok(bot)
|
|
75
|
+
assert.strictEqual(typeof bot, 'object')
|
|
76
|
+
assert.strictEqual(typeof bot.username, 'string')
|
|
77
|
+
if (await this.#actorStorage.isInCollection(bot.username, 'inbox', activity)) {
|
|
78
|
+
this.#logger.info(
|
|
79
|
+
{ activity: activity.id, username: bot.username },
|
|
80
|
+
'skipping redelivery for activity already in the inbox'
|
|
81
|
+
)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
await this.#activityHandler.handleActivity(bot, activity)
|
|
86
|
+
} catch (err) {
|
|
87
|
+
this.#logger.warn(
|
|
88
|
+
{ err, activity: activity.id, bot: bot.username },
|
|
89
|
+
'handler failed for activity'
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
this.#logger.debug(`Adding ${activity.id} to ${bot.username} inbox`)
|
|
93
|
+
await this.#actorStorage.addToCollection(bot.username, 'inbox', activity)
|
|
94
|
+
this.#logger.debug(`Done adding ${activity.id} to ${bot.username} inbox`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid'
|
|
2
|
+
|
|
3
|
+
import as2 from './activitystreams.js'
|
|
4
|
+
|
|
5
|
+
export class DistributionWorker {
|
|
6
|
+
static #QUEUE_ID = 'distribution'
|
|
7
|
+
static #MAX_ATTEMPTS = 16
|
|
8
|
+
#jobQueue
|
|
9
|
+
#client
|
|
10
|
+
#logger
|
|
11
|
+
#running
|
|
12
|
+
#workerId
|
|
13
|
+
|
|
14
|
+
constructor (jobQueue, client, logger) {
|
|
15
|
+
this.#jobQueue = jobQueue
|
|
16
|
+
this.#client = client
|
|
17
|
+
this.#workerId = nanoid()
|
|
18
|
+
this.#logger = logger.child({
|
|
19
|
+
class: this.constructor.name,
|
|
20
|
+
workerId: this.#workerId
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async run () {
|
|
25
|
+
this.#running = true
|
|
26
|
+
while (this.#running) {
|
|
27
|
+
let jobId
|
|
28
|
+
let payload
|
|
29
|
+
let attempts
|
|
30
|
+
try {
|
|
31
|
+
this.#logger.debug('dequeueing');
|
|
32
|
+
({ jobId, payload, attempts } = await this.#jobQueue.dequeue(
|
|
33
|
+
DistributionWorker.#QUEUE_ID,
|
|
34
|
+
this.#workerId
|
|
35
|
+
))
|
|
36
|
+
this.#logger.debug({ jobId, payload, attempts }, 'got a job')
|
|
37
|
+
const { inbox, activity, username } = payload
|
|
38
|
+
const activityObj = await as2.import(activity)
|
|
39
|
+
try {
|
|
40
|
+
await this.#client.post(inbox, activityObj, username)
|
|
41
|
+
this.#logger.info(`Delivered ${activity.id} to ${inbox}`)
|
|
42
|
+
await this.#jobQueue.complete(jobId, this.#workerId)
|
|
43
|
+
this.#logger.info({ jobId }, 'completed job')
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (!error.status) {
|
|
46
|
+
this.#logger.error(`Could not deliver ${activity.id} to ${inbox}: ${error.message}`)
|
|
47
|
+
this.#logger.error(error.stack)
|
|
48
|
+
} else if (error.status >= 300 && error.status < 400) {
|
|
49
|
+
this.#logger.error(`Unexpected redirect code delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}`)
|
|
50
|
+
} else if (error.status >= 400 && error.status < 500) {
|
|
51
|
+
this.#logger.error(`Bad request delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}`)
|
|
52
|
+
} else if (error.status >= 500 && error.status < 600) {
|
|
53
|
+
if (attempts >= DistributionWorker.#MAX_ATTEMPTS) {
|
|
54
|
+
this.#logger.error(`Server error delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}; giving up after ${attempts} attempts`)
|
|
55
|
+
await this.#jobQueue.complete(jobId, this.#workerId)
|
|
56
|
+
} else {
|
|
57
|
+
const delay = Math.round((2 ** (attempts - 1) * 1000) * (0.5 + Math.random()))
|
|
58
|
+
this.#logger.warn(`Server error delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}; will retry in ${delay} ms (${attempts} of ${DistributionWorker.#MAX_ATTEMPTS})`)
|
|
59
|
+
await this.#jobQueue.retryAfter(jobId, this.#workerId, delay)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (err?.name === 'AbortError') {
|
|
65
|
+
this.#logger.info('Worker received abort signal')
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
this.#logger.warn({ err, jobId }, 'Error delivering to bot')
|
|
69
|
+
if (jobId) {
|
|
70
|
+
await this.#jobQueue.release(jobId, this.#workerId)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
stop () {
|
|
77
|
+
this.#running = false
|
|
78
|
+
}
|
|
79
|
+
}
|
package/lib/jobqueue.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
|
|
4
|
+
import { nanoid } from 'nanoid'
|
|
5
|
+
import { QueryTypes } from 'sequelize'
|
|
6
|
+
|
|
7
|
+
const MAX_DELAY = 30000
|
|
8
|
+
const DEFAULT_PRIORITY = 1000000
|
|
9
|
+
|
|
10
|
+
export class JobQueue {
|
|
11
|
+
#connection
|
|
12
|
+
#logger
|
|
13
|
+
#ac
|
|
14
|
+
#wakeAc
|
|
15
|
+
|
|
16
|
+
constructor (connection, logger) {
|
|
17
|
+
this.#connection = connection
|
|
18
|
+
this.#logger = logger.child({ class: this.constructor.name })
|
|
19
|
+
this.#ac = new AbortController()
|
|
20
|
+
this.#wakeAc = new AbortController()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async enqueue (queueId, payload = {}, options = { priority: DEFAULT_PRIORITY }) {
|
|
24
|
+
const { priority } = options
|
|
25
|
+
const jobId = nanoid()
|
|
26
|
+
const encoded = JSON.stringify(payload)
|
|
27
|
+
await this.#connection.query(
|
|
28
|
+
`INSERT INTO job (job_id, queue_id, payload, priority)
|
|
29
|
+
VALUES (?, ?, ?, ?);`,
|
|
30
|
+
{ replacements: [jobId, queueId, encoded, priority] }
|
|
31
|
+
)
|
|
32
|
+
this.#logger.debug({ method: 'enqueue', queueId, jobId }, 'enqueued job')
|
|
33
|
+
const old = this.#wakeAc
|
|
34
|
+
this.#wakeAc = new AbortController()
|
|
35
|
+
old.abort()
|
|
36
|
+
return jobId
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async dequeue (queueId, jobRunnerId) {
|
|
40
|
+
let res
|
|
41
|
+
let delay = 50
|
|
42
|
+
|
|
43
|
+
this.#logger.debug(
|
|
44
|
+
{ method: 'dequeue', queueId, jobRunnerId },
|
|
45
|
+
'dequeueing job'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
while (!res) {
|
|
49
|
+
res = await this.#claimNextJob(queueId, jobRunnerId)
|
|
50
|
+
if (res) break
|
|
51
|
+
delay = Math.min(delay * 2, MAX_DELAY)
|
|
52
|
+
this.#logger.debug({ method: 'dequeue', delay }, 'sleeping')
|
|
53
|
+
try {
|
|
54
|
+
await sleep(delay, null, { signal: AbortSignal.any([this.#ac.signal, this.#wakeAc.signal]) })
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (this.#ac.signal.aborted) throw err
|
|
57
|
+
delay = 50
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const jobId = res.job_id
|
|
62
|
+
const payload = JSON.parse(res.payload)
|
|
63
|
+
const attempts = res.attempts
|
|
64
|
+
|
|
65
|
+
this.#logger.debug({ method: 'dequeue', queueId, jobId }, 'dequeued job')
|
|
66
|
+
return { jobId, payload, attempts }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async complete (jobId, jobRunnerId) {
|
|
70
|
+
await this.#connection.query(`
|
|
71
|
+
DELETE FROM job
|
|
72
|
+
WHERE job_id = ? AND claimed_by = ?;`,
|
|
73
|
+
{ replacements: [jobId, jobRunnerId] }
|
|
74
|
+
)
|
|
75
|
+
this.#logger.debug({ method: 'complete', jobRunnerId, jobId }, 'completed job')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async release (jobId, jobRunnerId) {
|
|
79
|
+
await this.#connection.query(`
|
|
80
|
+
UPDATE job
|
|
81
|
+
SET claimed_by = NULL,
|
|
82
|
+
claimed_at = NULL,
|
|
83
|
+
updated_at = CURRENT_TIMESTAMP
|
|
84
|
+
WHERE job_id = ? AND claimed_by = ?;`,
|
|
85
|
+
{ replacements: [jobId, jobRunnerId] }
|
|
86
|
+
)
|
|
87
|
+
this.#logger.debug({ method: 'release', jobRunnerId, jobId }, 'released job')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async retryAfter (jobId, jobRunnerId, delay) {
|
|
91
|
+
assert.ok(jobId)
|
|
92
|
+
assert.strictEqual(typeof jobId, 'string')
|
|
93
|
+
assert.ok(jobRunnerId)
|
|
94
|
+
assert.strictEqual(typeof jobRunnerId, 'string')
|
|
95
|
+
assert.ok(delay)
|
|
96
|
+
assert.strictEqual(typeof delay, 'number')
|
|
97
|
+
|
|
98
|
+
const retryAfter = new Date(Date.now() + delay)
|
|
99
|
+
|
|
100
|
+
await this.#connection.query(`
|
|
101
|
+
UPDATE job
|
|
102
|
+
SET claimed_by = NULL,
|
|
103
|
+
claimed_at = NULL,
|
|
104
|
+
updated_at = CURRENT_TIMESTAMP,
|
|
105
|
+
retry_after = ?
|
|
106
|
+
WHERE job_id = ? AND claimed_by = ?;`,
|
|
107
|
+
{ replacements: [retryAfter, jobId, jobRunnerId] }
|
|
108
|
+
)
|
|
109
|
+
this.#logger.debug(
|
|
110
|
+
{ method: 'retry', jobRunnerId, jobId, delay },
|
|
111
|
+
'released job'
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async onIdle (queueId) {
|
|
116
|
+
assert.ok(queueId)
|
|
117
|
+
assert.strictEqual(typeof queueId, 'string')
|
|
118
|
+
|
|
119
|
+
let delay = 50
|
|
120
|
+
|
|
121
|
+
this.#logger.debug({ method: 'onIdle', queueId }, 'getting queue size')
|
|
122
|
+
|
|
123
|
+
let jobCount = await this.#countJobs(queueId)
|
|
124
|
+
|
|
125
|
+
this.#logger.debug({ method: 'onIdle', queueId, jobCount }, 'got queue size')
|
|
126
|
+
|
|
127
|
+
while (jobCount > 0) {
|
|
128
|
+
delay = Math.min(delay * 2, MAX_DELAY)
|
|
129
|
+
this.#logger.debug({ method: 'onIdle', delay }, 'sleeping')
|
|
130
|
+
await sleep(delay, null, { signal: this.#ac.signal })
|
|
131
|
+
jobCount = await this.#countJobs(queueId)
|
|
132
|
+
this.#logger.debug({ method: 'onIdle', queueId, jobCount }, 'got queue size')
|
|
133
|
+
}
|
|
134
|
+
this.#logger.debug({ method: 'onIdle', queueId }, 'Now idle')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async sweep (timeout) {
|
|
138
|
+
const cutoff = new Date(Date.now() - timeout)
|
|
139
|
+
const [, meta] = await this.#connection.query(`
|
|
140
|
+
UPDATE job
|
|
141
|
+
SET claimed_by = NULL,
|
|
142
|
+
claimed_at = NULL,
|
|
143
|
+
updated_at = CURRENT_TIMESTAMP
|
|
144
|
+
WHERE claimed_at < ?;`,
|
|
145
|
+
{ replacements: [cutoff] }
|
|
146
|
+
)
|
|
147
|
+
const count = meta?.changes ?? meta?.rowCount ?? 0
|
|
148
|
+
if (count > 0) {
|
|
149
|
+
this.#logger.warn({ method: 'sweep', count }, 'released stalled jobs')
|
|
150
|
+
} else {
|
|
151
|
+
this.#logger.debug({ method: 'sweep' }, 'no stalled jobs found')
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
abort () {
|
|
156
|
+
this.#logger.debug('Aborting queue server')
|
|
157
|
+
this.#ac.abort()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async #countJobs (queueId) {
|
|
161
|
+
this.#logger.debug({ method: '#countJobs', queueId }, 'checking queue size')
|
|
162
|
+
const rows = await this.#connection.query(`
|
|
163
|
+
SELECT COUNT(*) as job_count
|
|
164
|
+
FROM JOB
|
|
165
|
+
WHERE queue_id = ?;
|
|
166
|
+
`, { replacements: [queueId] })
|
|
167
|
+
if (rows[0]) {
|
|
168
|
+
const size = rows[0][0].job_count
|
|
169
|
+
this.#logger.debug({ method: '#countJobs', queueId, size }, 'got queue size')
|
|
170
|
+
return size
|
|
171
|
+
} else {
|
|
172
|
+
this.#logger.debug({ method: '#countJobs', queueId }, 'no queue size')
|
|
173
|
+
return 0
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async #claimNextJob (queueId, jobRunnerId) {
|
|
178
|
+
this.#logger.debug(
|
|
179
|
+
{ method: '#claimNextJob', queueId, jobRunnerId },
|
|
180
|
+
'claiming next job'
|
|
181
|
+
)
|
|
182
|
+
const skipLocked = this.#connection.getDialect() === 'postgres' ? 'FOR UPDATE SKIP LOCKED' : ''
|
|
183
|
+
const rows = await this.#connection.query(`
|
|
184
|
+
UPDATE job
|
|
185
|
+
SET claimed_by = ?,
|
|
186
|
+
claimed_at = CURRENT_TIMESTAMP,
|
|
187
|
+
updated_at = CURRENT_TIMESTAMP,
|
|
188
|
+
attempts = attempts + 1
|
|
189
|
+
WHERE job_id = (
|
|
190
|
+
SELECT job_id
|
|
191
|
+
FROM job
|
|
192
|
+
WHERE queue_id = ?
|
|
193
|
+
AND (
|
|
194
|
+
claimed_by IS NULL
|
|
195
|
+
)
|
|
196
|
+
AND (
|
|
197
|
+
retry_after IS NULL OR
|
|
198
|
+
retry_after < ?
|
|
199
|
+
)
|
|
200
|
+
ORDER BY priority ASC, created_at ASC
|
|
201
|
+
LIMIT 1
|
|
202
|
+
${skipLocked}
|
|
203
|
+
)
|
|
204
|
+
RETURNING job_id, payload, attempts;`,
|
|
205
|
+
{
|
|
206
|
+
replacements: [jobRunnerId, queueId, new Date()],
|
|
207
|
+
type: QueryTypes.SELECT
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
if (rows[0]) {
|
|
211
|
+
this.#logger.debug(
|
|
212
|
+
{ method: '#claimNextJob', queueId, jobRunnerId, jobId: rows[0].job_id },
|
|
213
|
+
'got a job'
|
|
214
|
+
)
|
|
215
|
+
return rows[0]
|
|
216
|
+
} else {
|
|
217
|
+
this.#logger.debug(
|
|
218
|
+
{ method: '#claimNextJob', queueId, jobRunnerId },
|
|
219
|
+
'no job found'
|
|
220
|
+
)
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
package/lib/jobreaper.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TIMEOUT = 5 * 60 * 1000 // 5 minutes
|
|
4
|
+
const DEFAULT_INTERVAL = 60 * 1000 // 1 minute
|
|
5
|
+
|
|
6
|
+
export class JobReaper {
|
|
7
|
+
#jobQueue
|
|
8
|
+
#logger
|
|
9
|
+
#timeout
|
|
10
|
+
#interval
|
|
11
|
+
#ac
|
|
12
|
+
|
|
13
|
+
constructor (jobQueue, logger, { timeout = DEFAULT_TIMEOUT, interval = DEFAULT_INTERVAL } = {}) {
|
|
14
|
+
this.#jobQueue = jobQueue
|
|
15
|
+
this.#logger = logger.child({ class: this.constructor.name })
|
|
16
|
+
this.#timeout = timeout
|
|
17
|
+
this.#interval = interval
|
|
18
|
+
this.#ac = new AbortController()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async run () {
|
|
22
|
+
this.#logger.debug('JobReaper started')
|
|
23
|
+
while (!this.#ac.signal.aborted) {
|
|
24
|
+
try {
|
|
25
|
+
await sleep(this.#interval, null, { signal: this.#ac.signal })
|
|
26
|
+
} catch {
|
|
27
|
+
break
|
|
28
|
+
}
|
|
29
|
+
await this.#jobQueue.sweep(this.#timeout)
|
|
30
|
+
}
|
|
31
|
+
this.#logger.debug('JobReaper stopped')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
stop () {
|
|
35
|
+
this.#logger.debug('Stopping JobReaper')
|
|
36
|
+
this.#ac.abort()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const id = '003-jobqueue'
|
|
2
|
+
|
|
3
|
+
export async function up (connection, queryOptions = {}) {
|
|
4
|
+
await connection.query(`
|
|
5
|
+
CREATE TABLE job (
|
|
6
|
+
job_id char(21) NOT NULL PRIMARY KEY,
|
|
7
|
+
queue_id varchar(64) NOT NULL,
|
|
8
|
+
priority INTEGER,
|
|
9
|
+
payload TEXT,
|
|
10
|
+
claimed_at TIMESTAMP,
|
|
11
|
+
claimed_by varchar(64),
|
|
12
|
+
attempts INTEGER default 0,
|
|
13
|
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
14
|
+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
15
|
+
);
|
|
16
|
+
`, queryOptions)
|
|
17
|
+
await connection.query(`
|
|
18
|
+
CREATE INDEX job_queue_id on job (queue_id);
|
|
19
|
+
`, queryOptions)
|
|
20
|
+
}
|
package/lib/migrations/index.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { fileURLToPath } from 'url'
|
|
2
|
+
import { dirname, resolve } from 'path'
|
|
3
|
+
import { readdirSync } from 'fs'
|
|
3
4
|
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
6
|
+
|
|
7
|
+
const migrationFiles = readdirSync(__dirname)
|
|
8
|
+
.filter(file => /^\d{3}-.*\.js$/.test(file))
|
|
9
|
+
.sort()
|
|
10
|
+
|
|
11
|
+
const migrations = await Promise.all(
|
|
12
|
+
migrationFiles.map(async (file) => {
|
|
13
|
+
const module = await import(resolve(__dirname, file))
|
|
14
|
+
return { id: module.id, up: module.up }
|
|
15
|
+
})
|
|
16
|
+
)
|
|
8
17
|
|
|
9
18
|
async function runMigrationsInternal (connection, queryOptions = {}) {
|
|
10
19
|
await connection.query(`
|
package/lib/routes/server.js
CHANGED
|
@@ -4,22 +4,34 @@ import as2 from '../activitystreams.js'
|
|
|
4
4
|
const router = express.Router()
|
|
5
5
|
|
|
6
6
|
router.get('/', async (req, res) => {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
|
|
8
|
+
res.format({
|
|
9
|
+
html: async () => {
|
|
10
|
+
const { indexFileName } = req.app.locals
|
|
11
|
+
res.sendFile(indexFileName)
|
|
12
|
+
},
|
|
13
|
+
json: async () => {
|
|
14
|
+
const { formatter } = req.app.locals
|
|
15
|
+
const server = await as2.import({
|
|
16
|
+
'@context': [
|
|
17
|
+
'https://www.w3.org/ns/activitystreams',
|
|
18
|
+
'https://w3id.org/security/v1'
|
|
19
|
+
],
|
|
20
|
+
id: formatter.format({ server: true }),
|
|
21
|
+
type: 'Service',
|
|
22
|
+
publicKey: formatter.format({ server: true, type: 'publickey' }),
|
|
23
|
+
url: {
|
|
24
|
+
type: 'Link',
|
|
25
|
+
mediaType: 'text/html',
|
|
26
|
+
href: fullUrl
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
const body = await server.export({ useOriginalContext: true })
|
|
30
|
+
res.status(200)
|
|
31
|
+
res.type(as2.mediaType)
|
|
32
|
+
res.json(body)
|
|
33
|
+
}
|
|
16
34
|
})
|
|
17
|
-
res.status(200)
|
|
18
|
-
res.type(as2.mediaType)
|
|
19
|
-
const body = await server.prettyWrite(
|
|
20
|
-
{ additional_context: 'https://w3id.org/security/v1' }
|
|
21
|
-
)
|
|
22
|
-
res.end(body)
|
|
23
35
|
})
|
|
24
36
|
|
|
25
37
|
router.get('/publickey', async (req, res) => {
|
package/package.json
CHANGED
package/web/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>activitypub.bot</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<h1>activitypub.bot</h1>
|
|
10
|
+
<p>
|
|
11
|
+
This is an activitypub.bot server. It hosts automated "bot" accounts on the <a href="https://en.wikipedia.org/wiki/Fediverse">Fediverse</a>.
|
|
12
|
+
</p>
|
|
13
|
+
<p>
|
|
14
|
+
<a href="https://github.com/evanp/activitypub-bot">Source</a>
|
|
15
|
+
</p>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|