@rmdes/indiekit-endpoint-activitypub 1.0.10 → 1.0.11
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/index.js +22 -0
- package/lib/batch-refollow.js +314 -0
- package/lib/controllers/dashboard.js +10 -0
- package/lib/controllers/refollow.js +84 -0
- package/lib/inbox-listeners.js +41 -0
- package/locales/en.json +18 -0
- package/package.json +1 -1
- package/views/activitypub-dashboard.njk +96 -0
- package/views/activitypub-following.njk +6 -1
package/index.js
CHANGED
|
@@ -21,6 +21,12 @@ import {
|
|
|
21
21
|
profileGetController,
|
|
22
22
|
profilePostController,
|
|
23
23
|
} from "./lib/controllers/profile.js";
|
|
24
|
+
import {
|
|
25
|
+
refollowPauseController,
|
|
26
|
+
refollowResumeController,
|
|
27
|
+
refollowStatusController,
|
|
28
|
+
} from "./lib/controllers/refollow.js";
|
|
29
|
+
import { startBatchRefollow } from "./lib/batch-refollow.js";
|
|
24
30
|
import { logActivity } from "./lib/activity-log.js";
|
|
25
31
|
|
|
26
32
|
const defaults = {
|
|
@@ -137,6 +143,9 @@ export default class ActivityPubEndpoint {
|
|
|
137
143
|
"/admin/migrate/import",
|
|
138
144
|
migrateImportController(mp, this.options),
|
|
139
145
|
);
|
|
146
|
+
router.post("/admin/refollow/pause", refollowPauseController(mp, this));
|
|
147
|
+
router.post("/admin/refollow/resume", refollowResumeController(mp, this));
|
|
148
|
+
router.get("/admin/refollow/status", refollowStatusController(mp));
|
|
140
149
|
|
|
141
150
|
return router;
|
|
142
151
|
}
|
|
@@ -575,6 +584,19 @@ export default class ActivityPubEndpoint {
|
|
|
575
584
|
|
|
576
585
|
// Register syndicator (appears in post editing UI)
|
|
577
586
|
Indiekit.addSyndicator(this.syndicator);
|
|
587
|
+
|
|
588
|
+
// Start batch re-follow processor after federation settles
|
|
589
|
+
const refollowOptions = {
|
|
590
|
+
federation: this._federation,
|
|
591
|
+
collections: this._collections,
|
|
592
|
+
handle: this.options.actor.handle,
|
|
593
|
+
publicationUrl: this._publicationUrl,
|
|
594
|
+
};
|
|
595
|
+
setTimeout(() => {
|
|
596
|
+
startBatchRefollow(refollowOptions).catch((error) => {
|
|
597
|
+
console.error("[ActivityPub] Batch refollow start failed:", error.message);
|
|
598
|
+
});
|
|
599
|
+
}, 10_000);
|
|
578
600
|
}
|
|
579
601
|
|
|
580
602
|
/**
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch re-follow processor for imported accounts.
|
|
3
|
+
*
|
|
4
|
+
* After a Mastodon migration, imported accounts (source: "import") exist only
|
|
5
|
+
* locally — no Follow activities were sent. This module gradually sends Follow
|
|
6
|
+
* activities to all imported accounts so remote servers start delivering
|
|
7
|
+
* Create activities to our inbox.
|
|
8
|
+
*
|
|
9
|
+
* Source field state machine:
|
|
10
|
+
* import → refollow:sent → federation (happy path)
|
|
11
|
+
* import → refollow:sent → refollow:failed (after MAX_RETRIES)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Follow } from "@fedify/fedify";
|
|
15
|
+
import { logActivity } from "./activity-log.js";
|
|
16
|
+
|
|
17
|
+
const BATCH_SIZE = 10;
|
|
18
|
+
const DELAY_PER_FOLLOW = 3_000;
|
|
19
|
+
const DELAY_BETWEEN_BATCHES = 30_000;
|
|
20
|
+
const STARTUP_DELAY = 30_000;
|
|
21
|
+
const RETRY_COOLDOWN = 60 * 60 * 1_000; // 1 hour
|
|
22
|
+
const MAX_RETRIES = 3;
|
|
23
|
+
|
|
24
|
+
const KV_KEY = "batch-refollow/state";
|
|
25
|
+
|
|
26
|
+
let _timer = null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Start the batch re-follow processor.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} options
|
|
32
|
+
* @param {import("@fedify/fedify").Federation} options.federation
|
|
33
|
+
* @param {object} options.collections - MongoDB collections
|
|
34
|
+
* @param {string} options.handle - Actor handle
|
|
35
|
+
* @param {string} options.publicationUrl - Publication base URL
|
|
36
|
+
*/
|
|
37
|
+
export async function startBatchRefollow(options) {
|
|
38
|
+
const { collections } = options;
|
|
39
|
+
|
|
40
|
+
// Restart recovery: reset any stale "refollow:pending" back to "import"
|
|
41
|
+
await collections.ap_following.updateMany(
|
|
42
|
+
{ source: "refollow:pending" },
|
|
43
|
+
{ $set: { source: "import" } },
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Check if there's work to do
|
|
47
|
+
const importCount = await collections.ap_following.countDocuments({
|
|
48
|
+
source: "import",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (importCount === 0) {
|
|
52
|
+
console.info("[ActivityPub] Batch refollow: no imported accounts to process");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.info(
|
|
57
|
+
`[ActivityPub] Batch refollow: ${importCount} imported accounts to process`,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Set job state to running
|
|
61
|
+
await setJobState(collections, "running");
|
|
62
|
+
|
|
63
|
+
// Schedule first batch after startup delay
|
|
64
|
+
_timer = setTimeout(() => processNextBatch(options), STARTUP_DELAY);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pause the batch re-follow processor.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} collections - MongoDB collections
|
|
71
|
+
*/
|
|
72
|
+
export async function pauseBatchRefollow(collections) {
|
|
73
|
+
if (_timer) {
|
|
74
|
+
clearTimeout(_timer);
|
|
75
|
+
_timer = null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Reset any pending back to import so they get picked up on resume
|
|
79
|
+
await collections.ap_following.updateMany(
|
|
80
|
+
{ source: "refollow:pending" },
|
|
81
|
+
{ $set: { source: "import" } },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
await setJobState(collections, "paused");
|
|
85
|
+
console.info("[ActivityPub] Batch refollow: paused");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resume the batch re-follow processor.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} options
|
|
92
|
+
* @param {import("@fedify/fedify").Federation} options.federation
|
|
93
|
+
* @param {object} options.collections - MongoDB collections
|
|
94
|
+
* @param {string} options.handle - Actor handle
|
|
95
|
+
* @param {string} options.publicationUrl - Publication base URL
|
|
96
|
+
*/
|
|
97
|
+
export async function resumeBatchRefollow(options) {
|
|
98
|
+
if (_timer) {
|
|
99
|
+
clearTimeout(_timer);
|
|
100
|
+
_timer = null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await setJobState(options.collections, "running");
|
|
104
|
+
_timer = setTimeout(() => processNextBatch(options), DELAY_BETWEEN_BATCHES);
|
|
105
|
+
console.info("[ActivityPub] Batch refollow: resumed");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get current batch re-follow status.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} collections - MongoDB collections
|
|
112
|
+
* @returns {Promise<object>} Status object
|
|
113
|
+
*/
|
|
114
|
+
export async function getBatchRefollowStatus(collections) {
|
|
115
|
+
const state = await collections.ap_kv.findOne({ _id: KV_KEY });
|
|
116
|
+
const status = state?.value?.status || "idle";
|
|
117
|
+
|
|
118
|
+
const [remaining, sent, failed, federated] = await Promise.all([
|
|
119
|
+
collections.ap_following.countDocuments({ source: "import" }),
|
|
120
|
+
collections.ap_following.countDocuments({ source: "refollow:sent" }),
|
|
121
|
+
collections.ap_following.countDocuments({ source: "refollow:failed" }),
|
|
122
|
+
collections.ap_following.countDocuments({ source: "federation" }),
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
const total = remaining + sent + failed;
|
|
126
|
+
const completed = sent + failed;
|
|
127
|
+
const progressPercent =
|
|
128
|
+
total > 0 ? Math.round((completed / total) * 100) : 100;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
status,
|
|
132
|
+
total,
|
|
133
|
+
remaining,
|
|
134
|
+
sent,
|
|
135
|
+
failed,
|
|
136
|
+
federated,
|
|
137
|
+
completed,
|
|
138
|
+
progressPercent,
|
|
139
|
+
startedAt: state?.value?.startedAt || null,
|
|
140
|
+
updatedAt: state?.value?.updatedAt || null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- Internal helpers ---
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Process the next batch of imported accounts.
|
|
148
|
+
*/
|
|
149
|
+
async function processNextBatch(options) {
|
|
150
|
+
const { federation, collections, handle, publicationUrl } = options;
|
|
151
|
+
_timer = null;
|
|
152
|
+
|
|
153
|
+
const state = await collections.ap_kv.findOne({ _id: KV_KEY });
|
|
154
|
+
if (state?.value?.status !== "running") return;
|
|
155
|
+
|
|
156
|
+
// Claim a batch atomically: set source to "refollow:pending"
|
|
157
|
+
const entries = [];
|
|
158
|
+
for (let i = 0; i < BATCH_SIZE; i++) {
|
|
159
|
+
const doc = await collections.ap_following.findOneAndUpdate(
|
|
160
|
+
{ source: "import" },
|
|
161
|
+
{ $set: { source: "refollow:pending" } },
|
|
162
|
+
{ returnDocument: "after" },
|
|
163
|
+
);
|
|
164
|
+
if (!doc) break;
|
|
165
|
+
entries.push(doc);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Also pick up retryable entries (failed but not permanently)
|
|
169
|
+
const retryCutoff = new Date(Date.now() - RETRY_COOLDOWN).toISOString();
|
|
170
|
+
const retrySlots = BATCH_SIZE - entries.length;
|
|
171
|
+
for (let i = 0; i < retrySlots; i++) {
|
|
172
|
+
const doc = await collections.ap_following.findOneAndUpdate(
|
|
173
|
+
{
|
|
174
|
+
source: "refollow:sent",
|
|
175
|
+
refollowAttempts: { $lt: MAX_RETRIES },
|
|
176
|
+
refollowLastAttempt: { $lt: retryCutoff },
|
|
177
|
+
},
|
|
178
|
+
{ $set: { source: "refollow:pending" } },
|
|
179
|
+
{ returnDocument: "after" },
|
|
180
|
+
);
|
|
181
|
+
if (!doc) break;
|
|
182
|
+
entries.push(doc);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (entries.length === 0) {
|
|
186
|
+
// Check if there are still sent entries awaiting Accept
|
|
187
|
+
const pendingAccepts = await collections.ap_following.countDocuments({
|
|
188
|
+
source: "refollow:sent",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (pendingAccepts > 0) {
|
|
192
|
+
console.info(
|
|
193
|
+
`[ActivityPub] Batch refollow: all sent, ${pendingAccepts} awaiting Accept`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await setJobState(collections, "completed");
|
|
198
|
+
console.info("[ActivityPub] Batch refollow: completed");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
console.info(
|
|
203
|
+
`[ActivityPub] Batch refollow: processing batch of ${entries.length}`,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
await processOneFollow(options, entry);
|
|
208
|
+
// Delay between individual follows
|
|
209
|
+
await sleep(DELAY_PER_FOLLOW);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Update job state timestamp
|
|
213
|
+
await setJobState(collections, "running");
|
|
214
|
+
|
|
215
|
+
// Schedule next batch
|
|
216
|
+
_timer = setTimeout(() => processNextBatch(options), DELAY_BETWEEN_BATCHES);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Send a Follow activity for a single imported account.
|
|
221
|
+
*/
|
|
222
|
+
async function processOneFollow(options, entry) {
|
|
223
|
+
const { federation, collections, handle, publicationUrl } = options;
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const ctx = federation.createContext(new URL(publicationUrl), {});
|
|
227
|
+
|
|
228
|
+
// Resolve the remote actor
|
|
229
|
+
const remoteActor = await ctx.lookupObject(entry.actorUrl);
|
|
230
|
+
if (!remoteActor) {
|
|
231
|
+
throw new Error("Could not resolve remote actor");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Send Follow activity
|
|
235
|
+
const follow = new Follow({
|
|
236
|
+
actor: ctx.getActorUri(handle),
|
|
237
|
+
object: new URL(entry.actorUrl),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await ctx.sendActivity({ identifier: handle }, remoteActor, follow);
|
|
241
|
+
|
|
242
|
+
// Mark as sent
|
|
243
|
+
await collections.ap_following.updateOne(
|
|
244
|
+
{ _id: entry._id },
|
|
245
|
+
{
|
|
246
|
+
$set: {
|
|
247
|
+
source: "refollow:sent",
|
|
248
|
+
refollowLastAttempt: new Date().toISOString(),
|
|
249
|
+
refollowError: null,
|
|
250
|
+
},
|
|
251
|
+
$inc: { refollowAttempts: 1 },
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
console.info(
|
|
256
|
+
`[ActivityPub] Batch refollow: sent Follow to ${entry.actorUrl}`,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
await logActivity(collections.ap_activities, {
|
|
260
|
+
direction: "outbound",
|
|
261
|
+
type: "Follow",
|
|
262
|
+
actorUrl: publicationUrl,
|
|
263
|
+
objectUrl: entry.actorUrl,
|
|
264
|
+
actorName: entry.name || entry.actorUrl,
|
|
265
|
+
summary: `Batch refollow: sent Follow to ${entry.name || entry.actorUrl}`,
|
|
266
|
+
});
|
|
267
|
+
} catch (error) {
|
|
268
|
+
const attempts = (entry.refollowAttempts || 0) + 1;
|
|
269
|
+
const newSource =
|
|
270
|
+
attempts >= MAX_RETRIES ? "refollow:failed" : "refollow:sent";
|
|
271
|
+
|
|
272
|
+
await collections.ap_following.updateOne(
|
|
273
|
+
{ _id: entry._id },
|
|
274
|
+
{
|
|
275
|
+
$set: {
|
|
276
|
+
source: newSource,
|
|
277
|
+
refollowLastAttempt: new Date().toISOString(),
|
|
278
|
+
refollowError: error.message,
|
|
279
|
+
},
|
|
280
|
+
$inc: { refollowAttempts: 1 },
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
console.warn(
|
|
285
|
+
`[ActivityPub] Batch refollow: failed for ${entry.actorUrl} (attempt ${attempts}/${MAX_RETRIES}): ${error.message}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Set the batch re-follow job state in ap_kv.
|
|
292
|
+
*/
|
|
293
|
+
async function setJobState(collections, status) {
|
|
294
|
+
const now = new Date().toISOString();
|
|
295
|
+
const update = {
|
|
296
|
+
$set: {
|
|
297
|
+
"value.status": status,
|
|
298
|
+
"value.updatedAt": now,
|
|
299
|
+
},
|
|
300
|
+
$setOnInsert: { _id: KV_KEY },
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// Only set startedAt on initial start or resume
|
|
304
|
+
const existing = await collections.ap_kv.findOne({ _id: KV_KEY });
|
|
305
|
+
if (!existing?.value?.startedAt || status === "running" && existing?.value?.status !== "running") {
|
|
306
|
+
update.$set["value.startedAt"] = now;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
await collections.ap_kv.updateOne({ _id: KV_KEY }, update, { upsert: true });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function sleep(ms) {
|
|
313
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
314
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Dashboard controller — shows follower/following counts and recent activity.
|
|
3
3
|
*/
|
|
4
|
+
|
|
5
|
+
import { getBatchRefollowStatus } from "../batch-refollow.js";
|
|
6
|
+
|
|
4
7
|
export function dashboardController(mountPath) {
|
|
5
8
|
return async (request, response, next) => {
|
|
6
9
|
try {
|
|
@@ -25,11 +28,18 @@ export function dashboardController(mountPath) {
|
|
|
25
28
|
.toArray()
|
|
26
29
|
: [];
|
|
27
30
|
|
|
31
|
+
// Get batch re-follow status for the progress section
|
|
32
|
+
const refollowStatus = await getBatchRefollowStatus({
|
|
33
|
+
ap_following: followingCollection,
|
|
34
|
+
ap_kv: application?.collections?.get("ap_kv"),
|
|
35
|
+
});
|
|
36
|
+
|
|
28
37
|
response.render("activitypub-dashboard", {
|
|
29
38
|
title: response.locals.__("activitypub.title"),
|
|
30
39
|
followerCount,
|
|
31
40
|
followingCount,
|
|
32
41
|
recentActivities,
|
|
42
|
+
refollowStatus,
|
|
33
43
|
mountPath,
|
|
34
44
|
});
|
|
35
45
|
} catch (error) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin controllers for the batch re-follow system.
|
|
3
|
+
*
|
|
4
|
+
* Provides pause, resume, and status endpoints for managing the
|
|
5
|
+
* background batch processor from the admin UI.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
pauseBatchRefollow,
|
|
10
|
+
resumeBatchRefollow,
|
|
11
|
+
getBatchRefollowStatus,
|
|
12
|
+
} from "../batch-refollow.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* POST /admin/refollow/pause — pause the batch processor.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} mountPath - Plugin mount path
|
|
18
|
+
* @param {object} plugin - Plugin instance (for federation/collections access)
|
|
19
|
+
* @returns {Function} Express route handler
|
|
20
|
+
*/
|
|
21
|
+
export function refollowPauseController(mountPath, plugin) {
|
|
22
|
+
return async (request, response, next) => {
|
|
23
|
+
try {
|
|
24
|
+
const { application } = request.app.locals;
|
|
25
|
+
const collections = {
|
|
26
|
+
ap_following: application.collections.get("ap_following"),
|
|
27
|
+
ap_kv: application.collections.get("ap_kv"),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
await pauseBatchRefollow(collections);
|
|
31
|
+
|
|
32
|
+
response.json({ ok: true, status: "paused" });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
next(error);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* POST /admin/refollow/resume — resume the batch processor.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} mountPath - Plugin mount path
|
|
43
|
+
* @param {object} plugin - Plugin instance
|
|
44
|
+
* @returns {Function} Express route handler
|
|
45
|
+
*/
|
|
46
|
+
export function refollowResumeController(mountPath, plugin) {
|
|
47
|
+
return async (request, response, next) => {
|
|
48
|
+
try {
|
|
49
|
+
await resumeBatchRefollow({
|
|
50
|
+
federation: plugin._federation,
|
|
51
|
+
collections: plugin._collections,
|
|
52
|
+
handle: plugin.options.actor.handle,
|
|
53
|
+
publicationUrl: plugin._publicationUrl,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
response.json({ ok: true, status: "running" });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
next(error);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* GET /admin/refollow/status — get current batch processor status.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} mountPath - Plugin mount path
|
|
67
|
+
* @returns {Function} Express route handler
|
|
68
|
+
*/
|
|
69
|
+
export function refollowStatusController(mountPath) {
|
|
70
|
+
return async (request, response, next) => {
|
|
71
|
+
try {
|
|
72
|
+
const { application } = request.app.locals;
|
|
73
|
+
const collections = {
|
|
74
|
+
ap_following: application.collections.get("ap_following"),
|
|
75
|
+
ap_kv: application.collections.get("ap_kv"),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const status = await getBatchRefollowStatus(collections);
|
|
79
|
+
response.json(status);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
next(error);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
package/lib/inbox-listeners.js
CHANGED
|
@@ -119,6 +119,47 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
119
119
|
});
|
|
120
120
|
}
|
|
121
121
|
})
|
|
122
|
+
.on(Accept, async (ctx, accept) => {
|
|
123
|
+
// Handle Accept(Follow) — remote server accepted our Follow request
|
|
124
|
+
const actorObj = await accept.getActor();
|
|
125
|
+
const actorUrl = actorObj?.id?.href || "";
|
|
126
|
+
if (!actorUrl) return;
|
|
127
|
+
|
|
128
|
+
const inner = await accept.getObject();
|
|
129
|
+
if (!(inner instanceof Follow)) return;
|
|
130
|
+
|
|
131
|
+
// Match against our following list for refollow or microsub-reader follows
|
|
132
|
+
const result = await collections.ap_following.findOneAndUpdate(
|
|
133
|
+
{
|
|
134
|
+
actorUrl,
|
|
135
|
+
source: { $in: ["refollow:sent", "microsub-reader"] },
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
$set: {
|
|
139
|
+
source: "federation",
|
|
140
|
+
acceptedAt: new Date().toISOString(),
|
|
141
|
+
},
|
|
142
|
+
$unset: {
|
|
143
|
+
refollowAttempts: "",
|
|
144
|
+
refollowLastAttempt: "",
|
|
145
|
+
refollowError: "",
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{ returnDocument: "after" },
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (result) {
|
|
152
|
+
const actorName =
|
|
153
|
+
result.name || result.handle || actorUrl;
|
|
154
|
+
await logActivity(collections, storeRawActivities, {
|
|
155
|
+
direction: "inbound",
|
|
156
|
+
type: "Accept(Follow)",
|
|
157
|
+
actorUrl,
|
|
158
|
+
actorName,
|
|
159
|
+
summary: `${actorName} accepted our Follow`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
})
|
|
122
163
|
.on(Like, async (ctx, like) => {
|
|
123
164
|
const actorObj = await like.getActor();
|
|
124
165
|
const actorUrl = actorObj?.id?.href || "";
|
package/locales/en.json
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"sourceImport": "Mastodon import",
|
|
17
17
|
"sourceManual": "Manual",
|
|
18
18
|
"sourceFederation": "Federation",
|
|
19
|
+
"sourceRefollowPending": "Re-follow pending",
|
|
20
|
+
"sourceRefollowFailed": "Re-follow failed",
|
|
19
21
|
"direction": "Direction",
|
|
20
22
|
"directionInbound": "Received",
|
|
21
23
|
"directionOutbound": "Sent",
|
|
@@ -64,6 +66,22 @@
|
|
|
64
66
|
"failedList": "Could not resolve: %s",
|
|
65
67
|
"failedListSummary": "Failed handles",
|
|
66
68
|
"aliasSuccess": "Alias saved — your actor document now includes this account as alsoKnownAs."
|
|
69
|
+
},
|
|
70
|
+
"refollow": {
|
|
71
|
+
"title": "Batch re-follow",
|
|
72
|
+
"progress": "Re-follow progress",
|
|
73
|
+
"remaining": "Remaining",
|
|
74
|
+
"awaitingAccept": "Awaiting accept",
|
|
75
|
+
"accepted": "Accepted",
|
|
76
|
+
"failed": "Failed",
|
|
77
|
+
"pause": "Pause",
|
|
78
|
+
"resume": "Resume",
|
|
79
|
+
"status": {
|
|
80
|
+
"idle": "Idle",
|
|
81
|
+
"running": "Running",
|
|
82
|
+
"paused": "Paused",
|
|
83
|
+
"completed": "Completed"
|
|
84
|
+
}
|
|
67
85
|
}
|
|
68
86
|
}
|
|
69
87
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.11",
|
|
4
4
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|
|
@@ -32,6 +32,102 @@
|
|
|
32
32
|
}
|
|
33
33
|
]}) }}
|
|
34
34
|
|
|
35
|
+
{% if refollowStatus and refollowStatus.status !== "idle" %}
|
|
36
|
+
<section x-data="refollowProgress('{{ mountPath }}')" class="s-refollow" style="margin-block-end: var(--space-l);">
|
|
37
|
+
{{ heading({ text: __("activitypub.refollow.title"), level: 2 }) }}
|
|
38
|
+
|
|
39
|
+
{# Progress bar #}
|
|
40
|
+
<div style="background: var(--color-offset); border-radius: 4px; height: 1.5rem; margin-block-end: var(--space-m); overflow: hidden;">
|
|
41
|
+
<div
|
|
42
|
+
x-bind:style="'width:' + progress + '%; background: var(--color-accent); height: 100%; transition: width 0.5s ease;'"
|
|
43
|
+
style="width: {{ refollowStatus.progressPercent }}%; background: var(--color-accent); height: 100%; transition: width 0.5s ease;">
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
{# Stats grid #}
|
|
48
|
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: var(--space-s); margin-block-end: var(--space-m);">
|
|
49
|
+
<div style="padding: var(--space-s); background: var(--color-offset); border-radius: 4px; text-align: center;">
|
|
50
|
+
<div style="font-size: var(--font-size-xl);" x-text="remaining">{{ refollowStatus.remaining }}</div>
|
|
51
|
+
<div style="font-size: var(--font-size-s); color: var(--color-text-offset);">{{ __("activitypub.refollow.remaining") }}</div>
|
|
52
|
+
</div>
|
|
53
|
+
<div style="padding: var(--space-s); background: var(--color-offset); border-radius: 4px; text-align: center;">
|
|
54
|
+
<div style="font-size: var(--font-size-xl);" x-text="sent">{{ refollowStatus.sent }}</div>
|
|
55
|
+
<div style="font-size: var(--font-size-s); color: var(--color-text-offset);">{{ __("activitypub.refollow.awaitingAccept") }}</div>
|
|
56
|
+
</div>
|
|
57
|
+
<div style="padding: var(--space-s); background: var(--color-offset); border-radius: 4px; text-align: center;">
|
|
58
|
+
<div style="font-size: var(--font-size-xl);" x-text="federated">{{ refollowStatus.federated }}</div>
|
|
59
|
+
<div style="font-size: var(--font-size-s); color: var(--color-text-offset);">{{ __("activitypub.refollow.accepted") }}</div>
|
|
60
|
+
</div>
|
|
61
|
+
<div style="padding: var(--space-s); background: var(--color-offset); border-radius: 4px; text-align: center;">
|
|
62
|
+
<div style="font-size: var(--font-size-xl);" x-text="failed">{{ refollowStatus.failed }}</div>
|
|
63
|
+
<div style="font-size: var(--font-size-s); color: var(--color-text-offset);">{{ __("activitypub.refollow.failed") }}</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
{# Status + controls #}
|
|
68
|
+
<div style="display: flex; align-items: center; gap: var(--space-s);">
|
|
69
|
+
{{ badge({ text: __("activitypub.refollow.status." + refollowStatus.status) }) }}
|
|
70
|
+
{% if refollowStatus.status === "running" %}
|
|
71
|
+
<form method="post" action="{{ mountPath }}/admin/refollow/pause" x-on:submit.prevent="pause">
|
|
72
|
+
<button type="submit" class="button" style="font-size: var(--font-size-s);">{{ __("activitypub.refollow.pause") }}</button>
|
|
73
|
+
</form>
|
|
74
|
+
{% elif refollowStatus.status === "paused" %}
|
|
75
|
+
<form method="post" action="{{ mountPath }}/admin/refollow/resume" x-on:submit.prevent="resume">
|
|
76
|
+
<button type="submit" class="button" style="font-size: var(--font-size-s);">{{ __("activitypub.refollow.resume") }}</button>
|
|
77
|
+
</form>
|
|
78
|
+
{% endif %}
|
|
79
|
+
</div>
|
|
80
|
+
</section>
|
|
81
|
+
|
|
82
|
+
<script>
|
|
83
|
+
function refollowProgress(mountPath) {
|
|
84
|
+
return {
|
|
85
|
+
progress: {{ refollowStatus.progressPercent }},
|
|
86
|
+
remaining: {{ refollowStatus.remaining }},
|
|
87
|
+
sent: {{ refollowStatus.sent }},
|
|
88
|
+
federated: {{ refollowStatus.federated }},
|
|
89
|
+
failed: {{ refollowStatus.failed }},
|
|
90
|
+
status: '{{ refollowStatus.status }}',
|
|
91
|
+
interval: null,
|
|
92
|
+
init() {
|
|
93
|
+
if (this.status === 'running' || this.status === 'paused') {
|
|
94
|
+
this.interval = setInterval(() => this.poll(), 10000);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
destroy() {
|
|
98
|
+
if (this.interval) clearInterval(this.interval);
|
|
99
|
+
},
|
|
100
|
+
async poll() {
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetch(mountPath + '/admin/refollow/status');
|
|
103
|
+
const data = await res.json();
|
|
104
|
+
this.progress = data.progressPercent;
|
|
105
|
+
this.remaining = data.remaining;
|
|
106
|
+
this.sent = data.sent;
|
|
107
|
+
this.federated = data.federated;
|
|
108
|
+
this.failed = data.failed;
|
|
109
|
+
this.status = data.status;
|
|
110
|
+
if (data.status === 'completed' || data.status === 'idle') {
|
|
111
|
+
clearInterval(this.interval);
|
|
112
|
+
}
|
|
113
|
+
} catch {}
|
|
114
|
+
},
|
|
115
|
+
async pause() {
|
|
116
|
+
await fetch(mountPath + '/admin/refollow/pause', { method: 'POST' });
|
|
117
|
+
this.status = 'paused';
|
|
118
|
+
},
|
|
119
|
+
async resume() {
|
|
120
|
+
await fetch(mountPath + '/admin/refollow/resume', { method: 'POST' });
|
|
121
|
+
this.status = 'running';
|
|
122
|
+
if (!this.interval) {
|
|
123
|
+
this.interval = setInterval(() => this.poll(), 10000);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
</script>
|
|
129
|
+
{% endif %}
|
|
130
|
+
|
|
35
131
|
{{ heading({ text: __("activitypub.recentActivity"), level: 2 }) }}
|
|
36
132
|
|
|
37
133
|
{% if recentActivities.length > 0 %}
|
|
@@ -16,7 +16,12 @@
|
|
|
16
16
|
url: account.actorUrl,
|
|
17
17
|
description: { text: "@" + account.handle if account.handle },
|
|
18
18
|
published: account.followedAt,
|
|
19
|
-
badges: [{
|
|
19
|
+
badges: [{
|
|
20
|
+
text: __("activitypub.sourceImport") if account.source === "import"
|
|
21
|
+
else __("activitypub.sourceRefollowPending") if account.source === "refollow:sent"
|
|
22
|
+
else __("activitypub.sourceRefollowFailed") if account.source === "refollow:failed"
|
|
23
|
+
else __("activitypub.sourceFederation")
|
|
24
|
+
}]
|
|
20
25
|
}) }}
|
|
21
26
|
{% endfor %}
|
|
22
27
|
|