@rmdes/indiekit-endpoint-activitypub 3.12.5 → 3.13.1
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 +16 -0
- package/lib/batch-broadcast.js +11 -9
- package/lib/batch-refollow.js +13 -8
- package/lib/controllers/settings.js +92 -0
- package/lib/inbox-handlers.js +3 -1
- package/lib/mastodon/middleware/load-settings.js +35 -0
- package/lib/mastodon/router.js +5 -0
- package/lib/mastodon/routes/accounts.js +3 -2
- package/lib/mastodon/routes/instance.js +8 -6
- package/lib/mastodon/routes/oauth.js +6 -3
- package/lib/mastodon/routes/statuses.js +12 -7
- package/lib/settings.js +72 -0
- package/locales/en.json +3 -0
- package/package.json +1 -1
- package/views/activitypub-settings.njk +187 -0
package/index.js
CHANGED
|
@@ -131,6 +131,10 @@ import {
|
|
|
131
131
|
broadcastActorUpdateController,
|
|
132
132
|
lookupObjectController,
|
|
133
133
|
} from "./lib/controllers/federation-mgmt.js";
|
|
134
|
+
import {
|
|
135
|
+
settingsGetController,
|
|
136
|
+
settingsPostController,
|
|
137
|
+
} from "./lib/controllers/settings.js";
|
|
134
138
|
|
|
135
139
|
const defaults = {
|
|
136
140
|
mountPath: "/activitypub",
|
|
@@ -206,6 +210,11 @@ export default class ActivityPubEndpoint {
|
|
|
206
210
|
text: "activitypub.federationMgmt.title",
|
|
207
211
|
requiresDatabase: true,
|
|
208
212
|
},
|
|
213
|
+
{
|
|
214
|
+
href: `${this.options.mountPath}/admin/settings`,
|
|
215
|
+
text: "activitypub.settings.title",
|
|
216
|
+
requiresDatabase: true,
|
|
217
|
+
},
|
|
209
218
|
];
|
|
210
219
|
}
|
|
211
220
|
|
|
@@ -378,6 +387,10 @@ export default class ActivityPubEndpoint {
|
|
|
378
387
|
router.post("/admin/federation/broadcast-actor", broadcastActorUpdateController(mp, this));
|
|
379
388
|
router.get("/admin/federation/lookup", lookupObjectController(mp, this));
|
|
380
389
|
|
|
390
|
+
// Settings
|
|
391
|
+
router.get("/admin/settings", settingsGetController(mp));
|
|
392
|
+
router.post("/admin/settings", settingsPostController(mp));
|
|
393
|
+
|
|
381
394
|
return router;
|
|
382
395
|
}
|
|
383
396
|
|
|
@@ -969,6 +982,9 @@ export default class ActivityPubEndpoint {
|
|
|
969
982
|
Indiekit.addCollection("ap_filters");
|
|
970
983
|
Indiekit.addCollection("ap_filter_keywords");
|
|
971
984
|
|
|
985
|
+
// Plugin settings (single document, admin UI at /admin/settings)
|
|
986
|
+
Indiekit.addCollection("ap_settings");
|
|
987
|
+
|
|
972
988
|
// Store collection references (posts resolved lazily)
|
|
973
989
|
const indiekitCollections = Indiekit.collections;
|
|
974
990
|
this._collections = {
|
package/lib/batch-broadcast.js
CHANGED
|
@@ -4,9 +4,7 @@
|
|
|
4
4
|
* @module batch-broadcast
|
|
5
5
|
*/
|
|
6
6
|
import { logActivity } from "./activity-log.js";
|
|
7
|
-
|
|
8
|
-
const BATCH_SIZE = 25;
|
|
9
|
-
const BATCH_DELAY_MS = 5000;
|
|
7
|
+
import { getSettings } from "./settings.js";
|
|
10
8
|
|
|
11
9
|
/**
|
|
12
10
|
* Broadcast an activity to all followers via batch delivery.
|
|
@@ -29,6 +27,10 @@ export async function batchBroadcast({
|
|
|
29
27
|
label,
|
|
30
28
|
objectUrl,
|
|
31
29
|
}) {
|
|
30
|
+
const settings = await getSettings(collections);
|
|
31
|
+
const batchSize = settings.broadcastBatchSize;
|
|
32
|
+
const batchDelay = settings.broadcastBatchDelay;
|
|
33
|
+
|
|
32
34
|
const ctx = federation.createContext(new URL(publicationUrl), {
|
|
33
35
|
handle,
|
|
34
36
|
publicationUrl,
|
|
@@ -54,11 +56,11 @@ export async function batchBroadcast({
|
|
|
54
56
|
|
|
55
57
|
console.info(
|
|
56
58
|
`[ActivityPub] Broadcasting ${label} to ${uniqueRecipients.length} ` +
|
|
57
|
-
`unique inboxes (${followers.length} followers) in batches of ${
|
|
59
|
+
`unique inboxes (${followers.length} followers) in batches of ${batchSize}`,
|
|
58
60
|
);
|
|
59
61
|
|
|
60
|
-
for (let i = 0; i < uniqueRecipients.length; i +=
|
|
61
|
-
const batch = uniqueRecipients.slice(i, i +
|
|
62
|
+
for (let i = 0; i < uniqueRecipients.length; i += batchSize) {
|
|
63
|
+
const batch = uniqueRecipients.slice(i, i + batchSize);
|
|
62
64
|
const recipients = batch.map((f) => ({
|
|
63
65
|
id: new URL(f.actorUrl),
|
|
64
66
|
inboxId: new URL(f.inbox || f.sharedInbox),
|
|
@@ -75,12 +77,12 @@ export async function batchBroadcast({
|
|
|
75
77
|
} catch (error) {
|
|
76
78
|
failed += batch.length;
|
|
77
79
|
console.warn(
|
|
78
|
-
`[ActivityPub] ${label} batch ${Math.floor(i /
|
|
80
|
+
`[ActivityPub] ${label} batch ${Math.floor(i / batchSize) + 1} failed: ${error.message}`,
|
|
79
81
|
);
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
if (i +
|
|
83
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
84
|
+
if (i + batchSize < uniqueRecipients.length) {
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, batchDelay));
|
|
84
86
|
}
|
|
85
87
|
}
|
|
86
88
|
|
package/lib/batch-refollow.js
CHANGED
|
@@ -16,10 +16,8 @@ import { lookupWithSecurity } from "./lookup-helpers.js";
|
|
|
16
16
|
import { Follow } from "@fedify/fedify/vocab";
|
|
17
17
|
import { logActivity } from "./activity-log.js";
|
|
18
18
|
import { cacheGet, cacheSet } from "./redis-cache.js";
|
|
19
|
+
import { getSettings } from "./settings.js";
|
|
19
20
|
|
|
20
|
-
const BATCH_SIZE = 10;
|
|
21
|
-
const DELAY_PER_FOLLOW = 3_000;
|
|
22
|
-
const DELAY_BETWEEN_BATCHES = 30_000;
|
|
23
21
|
const STARTUP_DELAY = 30_000;
|
|
24
22
|
const RETRY_COOLDOWN = 60 * 60 * 1_000; // 1 hour
|
|
25
23
|
const MAX_RETRIES = 3;
|
|
@@ -104,7 +102,9 @@ export async function resumeBatchRefollow(options) {
|
|
|
104
102
|
}
|
|
105
103
|
|
|
106
104
|
await setJobState("running");
|
|
107
|
-
|
|
105
|
+
const { collections: resumeCollections } = options;
|
|
106
|
+
const resumeSettings = await getSettings(resumeCollections);
|
|
107
|
+
_timer = setTimeout(() => processNextBatch(options), resumeSettings.refollowBatchDelay);
|
|
108
108
|
console.info("[ActivityPub] Batch refollow: resumed");
|
|
109
109
|
}
|
|
110
110
|
|
|
@@ -158,9 +158,14 @@ async function processNextBatch(options) {
|
|
|
158
158
|
const state = await cacheGet(KV_KEY);
|
|
159
159
|
if (state?.status !== "running") return;
|
|
160
160
|
|
|
161
|
+
const settings = await getSettings(collections);
|
|
162
|
+
const batchSize = settings.refollowBatchSize;
|
|
163
|
+
const delayPerFollow = settings.refollowDelay;
|
|
164
|
+
const delayBetweenBatches = settings.refollowBatchDelay;
|
|
165
|
+
|
|
161
166
|
// Claim a batch atomically: set source to "refollow:pending"
|
|
162
167
|
const entries = [];
|
|
163
|
-
for (let i = 0; i <
|
|
168
|
+
for (let i = 0; i < batchSize; i++) {
|
|
164
169
|
const doc = await collections.ap_following.findOneAndUpdate(
|
|
165
170
|
{ source: "import" },
|
|
166
171
|
{ $set: { source: "refollow:pending" } },
|
|
@@ -172,7 +177,7 @@ async function processNextBatch(options) {
|
|
|
172
177
|
|
|
173
178
|
// Also pick up retryable entries (failed but not permanently)
|
|
174
179
|
const retryCutoff = new Date(Date.now() - RETRY_COOLDOWN).toISOString();
|
|
175
|
-
const retrySlots =
|
|
180
|
+
const retrySlots = batchSize - entries.length;
|
|
176
181
|
for (let i = 0; i < retrySlots; i++) {
|
|
177
182
|
const doc = await collections.ap_following.findOneAndUpdate(
|
|
178
183
|
{
|
|
@@ -211,14 +216,14 @@ async function processNextBatch(options) {
|
|
|
211
216
|
for (const entry of entries) {
|
|
212
217
|
await processOneFollow(options, entry);
|
|
213
218
|
// Delay between individual follows
|
|
214
|
-
await sleep(
|
|
219
|
+
await sleep(delayPerFollow);
|
|
215
220
|
}
|
|
216
221
|
|
|
217
222
|
// Update job state timestamp
|
|
218
223
|
await setJobState("running");
|
|
219
224
|
|
|
220
225
|
// Schedule next batch
|
|
221
|
-
_timer = setTimeout(() => processNextBatch(options),
|
|
226
|
+
_timer = setTimeout(() => processNextBatch(options), delayBetweenBatches);
|
|
222
227
|
}
|
|
223
228
|
|
|
224
229
|
/**
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings controller — admin page for ActivityPub plugin configuration.
|
|
3
|
+
*
|
|
4
|
+
* GET: loads settings from ap_settings, renders form with defaults
|
|
5
|
+
* POST: validates, saves settings, redirects with success message
|
|
6
|
+
*/
|
|
7
|
+
import { getSettings, saveSettings, DEFAULTS } from "../settings.js";
|
|
8
|
+
import { getToken, validateToken } from "../csrf.js";
|
|
9
|
+
|
|
10
|
+
export function settingsGetController(mountPath) {
|
|
11
|
+
return async (request, response, next) => {
|
|
12
|
+
try {
|
|
13
|
+
const { application } = request.app.locals;
|
|
14
|
+
const settings = await getSettings(application.collections);
|
|
15
|
+
|
|
16
|
+
response.render("activitypub-settings", {
|
|
17
|
+
title: response.locals.__("activitypub.settings.title"),
|
|
18
|
+
settings,
|
|
19
|
+
defaults: DEFAULTS,
|
|
20
|
+
mountPath,
|
|
21
|
+
saved: request.query.saved === "true",
|
|
22
|
+
csrfToken: getToken(request.session),
|
|
23
|
+
});
|
|
24
|
+
} catch (error) {
|
|
25
|
+
next(error);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function settingsPostController(mountPath) {
|
|
31
|
+
return async (request, response, next) => {
|
|
32
|
+
try {
|
|
33
|
+
if (!validateToken(request)) {
|
|
34
|
+
return response.status(403).render("error", {
|
|
35
|
+
title: "Error",
|
|
36
|
+
content: "Invalid CSRF token",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { application } = request.app.locals;
|
|
41
|
+
const body = request.body;
|
|
42
|
+
|
|
43
|
+
const settings = {
|
|
44
|
+
// Instance & Client API
|
|
45
|
+
instanceLanguages: (body.instanceLanguages || "en")
|
|
46
|
+
.split(",")
|
|
47
|
+
.map((s) => s.trim())
|
|
48
|
+
.filter(Boolean),
|
|
49
|
+
maxCharacters:
|
|
50
|
+
parseInt(body.maxCharacters, 10) || DEFAULTS.maxCharacters,
|
|
51
|
+
maxMediaAttachments:
|
|
52
|
+
parseInt(body.maxMediaAttachments, 10) || DEFAULTS.maxMediaAttachments,
|
|
53
|
+
defaultVisibility: body.defaultVisibility || DEFAULTS.defaultVisibility,
|
|
54
|
+
defaultLanguage: (body.defaultLanguage || DEFAULTS.defaultLanguage).trim(),
|
|
55
|
+
|
|
56
|
+
// Federation & Delivery
|
|
57
|
+
timelineRetention: parseInt(body.timelineRetention, 10) || 0,
|
|
58
|
+
notificationRetentionDays:
|
|
59
|
+
parseInt(body.notificationRetentionDays, 10) || 0,
|
|
60
|
+
activityRetentionDays:
|
|
61
|
+
parseInt(body.activityRetentionDays, 10) || 0,
|
|
62
|
+
replyChainDepth:
|
|
63
|
+
parseInt(body.replyChainDepth, 10) || DEFAULTS.replyChainDepth,
|
|
64
|
+
broadcastBatchSize:
|
|
65
|
+
parseInt(body.broadcastBatchSize, 10) || DEFAULTS.broadcastBatchSize,
|
|
66
|
+
broadcastBatchDelay:
|
|
67
|
+
parseInt(body.broadcastBatchDelay, 10) || DEFAULTS.broadcastBatchDelay,
|
|
68
|
+
parallelWorkers:
|
|
69
|
+
parseInt(body.parallelWorkers, 10) || DEFAULTS.parallelWorkers,
|
|
70
|
+
logLevel: body.logLevel || DEFAULTS.logLevel,
|
|
71
|
+
|
|
72
|
+
// Migration
|
|
73
|
+
refollowBatchSize:
|
|
74
|
+
parseInt(body.refollowBatchSize, 10) || DEFAULTS.refollowBatchSize,
|
|
75
|
+
refollowDelay:
|
|
76
|
+
parseInt(body.refollowDelay, 10) || DEFAULTS.refollowDelay,
|
|
77
|
+
refollowBatchDelay:
|
|
78
|
+
parseInt(body.refollowBatchDelay, 10) || DEFAULTS.refollowBatchDelay,
|
|
79
|
+
|
|
80
|
+
// Security
|
|
81
|
+
refreshTokenTtlDays:
|
|
82
|
+
parseInt(body.refreshTokenTtlDays, 10) || DEFAULTS.refreshTokenTtlDays,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
await saveSettings(application.collections, settings);
|
|
86
|
+
|
|
87
|
+
response.redirect(`${mountPath}/admin/settings?saved=true`);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
next(error);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
package/lib/inbox-handlers.js
CHANGED
|
@@ -38,6 +38,7 @@ import { addNotification } from "./storage/notifications.js";
|
|
|
38
38
|
import { addMessage } from "./storage/messages.js";
|
|
39
39
|
import { fetchAndStorePreviews, fetchAndStoreQuote } from "./og-unfurl.js";
|
|
40
40
|
import { getFollowedTags } from "./storage/followed-tags.js";
|
|
41
|
+
import { getSettings } from "./settings.js";
|
|
41
42
|
|
|
42
43
|
/** @type {string} ActivityStreams Public Collection constant */
|
|
43
44
|
const PUBLIC = "https://www.w3.org/ns/activitystreams#Public";
|
|
@@ -760,7 +761,8 @@ export async function handleCreate(item, collections, ctx, handle) {
|
|
|
760
761
|
// Each ancestor is stored with isContext: true to distinguish from organic timeline items.
|
|
761
762
|
if (inReplyTo) {
|
|
762
763
|
try {
|
|
763
|
-
await
|
|
764
|
+
const settings = await getSettings(collections);
|
|
765
|
+
await fetchReplyChain(object, collections, authLoader, settings.replyChainDepth);
|
|
764
766
|
} catch (error) {
|
|
765
767
|
// Non-critical — incomplete context is acceptable
|
|
766
768
|
console.warn("[inbox-handlers] Reply chain fetch failed:", error.message);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings cache middleware for Mastodon API hot paths.
|
|
3
|
+
*
|
|
4
|
+
* Loads settings once per minute (not per request) and attaches
|
|
5
|
+
* to req.app.locals.apSettings for all downstream handlers.
|
|
6
|
+
*/
|
|
7
|
+
import { getSettings } from "../../settings.js";
|
|
8
|
+
|
|
9
|
+
let cachedSettings = null;
|
|
10
|
+
let cacheExpiry = 0;
|
|
11
|
+
const CACHE_TTL = 60_000; // 1 minute
|
|
12
|
+
|
|
13
|
+
export async function loadSettingsMiddleware(req, res, next) {
|
|
14
|
+
try {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
if (cachedSettings && now < cacheExpiry) {
|
|
17
|
+
req.app.locals.apSettings = cachedSettings;
|
|
18
|
+
return next();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const collections = req.app.locals.application?.collections;
|
|
22
|
+
cachedSettings = await getSettings(collections);
|
|
23
|
+
cacheExpiry = now + CACHE_TTL;
|
|
24
|
+
req.app.locals.apSettings = cachedSettings;
|
|
25
|
+
next();
|
|
26
|
+
} catch {
|
|
27
|
+
// On error, use defaults
|
|
28
|
+
if (!cachedSettings) {
|
|
29
|
+
const { DEFAULTS } = await import("../../settings.js");
|
|
30
|
+
cachedSettings = { ...DEFAULTS };
|
|
31
|
+
}
|
|
32
|
+
req.app.locals.apSettings = cachedSettings;
|
|
33
|
+
next();
|
|
34
|
+
}
|
|
35
|
+
}
|
package/lib/mastodon/router.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import express from "express";
|
|
9
9
|
import rateLimit from "express-rate-limit";
|
|
10
10
|
import { corsMiddleware } from "./middleware/cors.js";
|
|
11
|
+
import { loadSettingsMiddleware } from "./middleware/load-settings.js";
|
|
11
12
|
import { tokenRequired, optionalToken } from "./middleware/token-required.js";
|
|
12
13
|
import { errorHandler, notImplementedHandler } from "./middleware/error-handler.js";
|
|
13
14
|
|
|
@@ -77,6 +78,10 @@ export function createMastodonRouter({ collections, pluginOptions = {} }) {
|
|
|
77
78
|
// ─── CORS ───────────────────────────────────────────────────────────────
|
|
78
79
|
router.use("/api", corsMiddleware);
|
|
79
80
|
router.use("/oauth/token", corsMiddleware);
|
|
81
|
+
|
|
82
|
+
// ─── Settings cache ────────────────────────────────────────────────────
|
|
83
|
+
// Loads plugin settings once per minute, available as req.app.locals.apSettings
|
|
84
|
+
router.use("/api", loadSettingsMiddleware);
|
|
80
85
|
router.use("/oauth/revoke", corsMiddleware);
|
|
81
86
|
router.use("/.well-known/oauth-authorization-server", corsMiddleware);
|
|
82
87
|
|
|
@@ -60,10 +60,11 @@ router.get("/api/v1/accounts/verify_credentials", tokenRequired, scopeRequired("
|
|
|
60
60
|
// ─── GET /api/v1/preferences ─────────────────────────────────────────────────
|
|
61
61
|
|
|
62
62
|
router.get("/api/v1/preferences", tokenRequired, scopeRequired("read", "read:accounts"), (req, res) => {
|
|
63
|
+
const apSettings = req.app.locals.apSettings;
|
|
63
64
|
res.json({
|
|
64
|
-
"posting:default:visibility": "public",
|
|
65
|
+
"posting:default:visibility": apSettings?.defaultVisibility || "public",
|
|
65
66
|
"posting:default:sensitive": false,
|
|
66
|
-
"posting:default:language": "en",
|
|
67
|
+
"posting:default:language": apSettings?.defaultLanguage || "en",
|
|
67
68
|
"reading:expand:media": "default",
|
|
68
69
|
"reading:expand:spoilers": false,
|
|
69
70
|
});
|
|
@@ -17,6 +17,7 @@ router.get("/api/v2/instance", async (req, res, next) => {
|
|
|
17
17
|
const domain = req.get("host");
|
|
18
18
|
const collections = req.app.locals.mastodonCollections;
|
|
19
19
|
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
|
|
20
|
+
const apSettings = req.app.locals.apSettings;
|
|
20
21
|
|
|
21
22
|
const profile = await collections.ap_profile.findOne({});
|
|
22
23
|
const contactAccount = profile
|
|
@@ -44,7 +45,7 @@ router.get("/api/v2/instance", async (req, res, next) => {
|
|
|
44
45
|
versions: {},
|
|
45
46
|
},
|
|
46
47
|
icon: [],
|
|
47
|
-
languages: ["en"],
|
|
48
|
+
languages: apSettings?.instanceLanguages || ["en"],
|
|
48
49
|
configuration: {
|
|
49
50
|
urls: {
|
|
50
51
|
streaming: "",
|
|
@@ -54,8 +55,8 @@ router.get("/api/v2/instance", async (req, res, next) => {
|
|
|
54
55
|
max_pinned_statuses: 10,
|
|
55
56
|
},
|
|
56
57
|
statuses: {
|
|
57
|
-
max_characters: 5000,
|
|
58
|
-
max_media_attachments: 4,
|
|
58
|
+
max_characters: apSettings?.maxCharacters || 5000,
|
|
59
|
+
max_media_attachments: apSettings?.maxMediaAttachments || 4,
|
|
59
60
|
characters_reserved_per_url: 23,
|
|
60
61
|
},
|
|
61
62
|
media_attachments: {
|
|
@@ -116,6 +117,7 @@ router.get("/api/v1/instance", async (req, res, next) => {
|
|
|
116
117
|
const domain = req.get("host");
|
|
117
118
|
const collections = req.app.locals.mastodonCollections;
|
|
118
119
|
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
|
|
120
|
+
const apSettings = req.app.locals.apSettings;
|
|
119
121
|
|
|
120
122
|
const profile = await collections.ap_profile.findOne({});
|
|
121
123
|
|
|
@@ -160,14 +162,14 @@ router.get("/api/v1/instance", async (req, res, next) => {
|
|
|
160
162
|
domain_count: domainCount,
|
|
161
163
|
},
|
|
162
164
|
thumbnail: profile?.icon || null,
|
|
163
|
-
languages: ["en"],
|
|
165
|
+
languages: apSettings?.instanceLanguages || ["en"],
|
|
164
166
|
registrations: false,
|
|
165
167
|
approval_required: true,
|
|
166
168
|
invites_enabled: false,
|
|
167
169
|
configuration: {
|
|
168
170
|
statuses: {
|
|
169
|
-
max_characters: 5000,
|
|
170
|
-
max_media_attachments: 4,
|
|
171
|
+
max_characters: apSettings?.maxCharacters || 5000,
|
|
172
|
+
max_media_attachments: apSettings?.maxMediaAttachments || 4,
|
|
171
173
|
characters_reserved_per_url: 23,
|
|
172
174
|
},
|
|
173
175
|
media_attachments: {
|
|
@@ -502,13 +502,15 @@ router.post("/oauth/token", async (req, res, next) => {
|
|
|
502
502
|
// Rotate: new access token + new refresh token
|
|
503
503
|
const newAccessToken = randomHex(64);
|
|
504
504
|
const newRefreshToken = randomHex(64);
|
|
505
|
+
const refreshTtlDaysRotate = req.app.locals.apSettings?.refreshTokenTtlDays || 90;
|
|
506
|
+
const refreshTtlMsRotate = refreshTtlDaysRotate * 24 * 3600 * 1000;
|
|
505
507
|
await collections.ap_oauth_tokens.updateOne(
|
|
506
508
|
{ _id: existing._id },
|
|
507
509
|
{
|
|
508
510
|
$set: {
|
|
509
511
|
accessToken: newAccessToken,
|
|
510
512
|
refreshToken: newRefreshToken,
|
|
511
|
-
refreshExpiresAt: new Date(Date.now() +
|
|
513
|
+
refreshExpiresAt: new Date(Date.now() + refreshTtlMsRotate),
|
|
512
514
|
},
|
|
513
515
|
$unset: { expiresAt: "" },
|
|
514
516
|
},
|
|
@@ -589,8 +591,9 @@ router.post("/oauth/token", async (req, res, next) => {
|
|
|
589
591
|
|
|
590
592
|
// Generate access token and refresh token.
|
|
591
593
|
// Access tokens do not expire (matching Mastodon behavior — valid until revoked).
|
|
592
|
-
// Refresh tokens expire after
|
|
593
|
-
const
|
|
594
|
+
// Refresh tokens expire after a configurable number of days (default 90).
|
|
595
|
+
const refreshTtlDays = req.app.locals.apSettings?.refreshTokenTtlDays || 90;
|
|
596
|
+
const REFRESH_TOKEN_TTL = refreshTtlDays * 24 * 3600 * 1000;
|
|
594
597
|
const accessToken = randomHex(64);
|
|
595
598
|
const refreshToken = randomHex(64);
|
|
596
599
|
await collections.ap_oauth_tokens.updateOne(
|
|
@@ -453,7 +453,9 @@ router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:
|
|
|
453
453
|
: new URL(application.micropubEndpoint, application.url).href;
|
|
454
454
|
|
|
455
455
|
const token =
|
|
456
|
-
req.session?.access_token ||
|
|
456
|
+
req.session?.access_token ||
|
|
457
|
+
req.mastodonToken?.indieauthToken ||
|
|
458
|
+
req.mastodonToken?.accessToken;
|
|
457
459
|
if (token) {
|
|
458
460
|
const updatePayload = {
|
|
459
461
|
action: "update",
|
|
@@ -513,13 +515,16 @@ router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:
|
|
|
513
515
|
const updated = await collections.ap_timeline.findOne({
|
|
514
516
|
_id: item._id,
|
|
515
517
|
});
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
);
|
|
519
|
-
const handle = pluginOptions.actor?.handle || "";
|
|
520
|
-
setLocalIdentity(localPublicationUrl, handle);
|
|
518
|
+
const interactionState = await loadItemInteractions(collections, updated);
|
|
519
|
+
const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, [updated]);
|
|
521
520
|
|
|
522
|
-
const serialized = serializeStatus(updated, {
|
|
521
|
+
const serialized = serializeStatus(updated, {
|
|
522
|
+
baseUrl,
|
|
523
|
+
...interactionState,
|
|
524
|
+
pinnedIds: new Set(),
|
|
525
|
+
replyIdMap,
|
|
526
|
+
replyAccountIdMap,
|
|
527
|
+
});
|
|
523
528
|
res.json(serialized);
|
|
524
529
|
} catch (error) {
|
|
525
530
|
next(error);
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin settings — stored in ap_settings MongoDB collection.
|
|
3
|
+
*
|
|
4
|
+
* getSettings() merges DB values over hardcoded defaults.
|
|
5
|
+
* Consumers call this once per operation (or use cached middleware for hot paths).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const DEFAULTS = {
|
|
9
|
+
// Instance & Client API
|
|
10
|
+
instanceLanguages: ["en"],
|
|
11
|
+
maxCharacters: 5000,
|
|
12
|
+
maxMediaAttachments: 4,
|
|
13
|
+
defaultVisibility: "public",
|
|
14
|
+
defaultLanguage: "en",
|
|
15
|
+
|
|
16
|
+
// Federation & Delivery
|
|
17
|
+
timelineRetention: 1000,
|
|
18
|
+
notificationRetentionDays: 30,
|
|
19
|
+
activityRetentionDays: 90,
|
|
20
|
+
replyChainDepth: 5,
|
|
21
|
+
broadcastBatchSize: 25,
|
|
22
|
+
broadcastBatchDelay: 5000,
|
|
23
|
+
parallelWorkers: 5,
|
|
24
|
+
logLevel: "warning",
|
|
25
|
+
|
|
26
|
+
// Migration
|
|
27
|
+
refollowBatchSize: 10,
|
|
28
|
+
refollowDelay: 3000,
|
|
29
|
+
refollowBatchDelay: 30000,
|
|
30
|
+
|
|
31
|
+
// Security
|
|
32
|
+
refreshTokenTtlDays: 90,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Load settings from MongoDB, merged over defaults.
|
|
37
|
+
*
|
|
38
|
+
* @param {Map|object} collections - Indiekit collections map or plain object with ap_settings
|
|
39
|
+
* @returns {Promise<object>} Settings object with all keys guaranteed present
|
|
40
|
+
*/
|
|
41
|
+
export async function getSettings(collections) {
|
|
42
|
+
const col = collections?.get
|
|
43
|
+
? collections.get("ap_settings")
|
|
44
|
+
: collections?.ap_settings;
|
|
45
|
+
if (!col) return { ...DEFAULTS };
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const doc = await col.findOne({});
|
|
49
|
+
return { ...DEFAULTS, ...(doc?.settings || {}) };
|
|
50
|
+
} catch {
|
|
51
|
+
return { ...DEFAULTS };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Save settings to MongoDB.
|
|
57
|
+
*
|
|
58
|
+
* @param {Map|object} collections - Indiekit collections map or plain object
|
|
59
|
+
* @param {object} settings - Settings object (all keys from DEFAULTS)
|
|
60
|
+
*/
|
|
61
|
+
export async function saveSettings(collections, settings) {
|
|
62
|
+
const col = collections?.get
|
|
63
|
+
? collections.get("ap_settings")
|
|
64
|
+
: collections?.ap_settings;
|
|
65
|
+
if (!col) return;
|
|
66
|
+
|
|
67
|
+
await col.updateOne(
|
|
68
|
+
{},
|
|
69
|
+
{ $set: { settings, updatedAt: new Date().toISOString() } },
|
|
70
|
+
{ upsert: true },
|
|
71
|
+
);
|
|
72
|
+
}
|
package/locales/en.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.13.1",
|
|
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",
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
{% extends "document.njk" %}
|
|
2
|
+
|
|
3
|
+
{% from "heading/macro.njk" import heading with context %}
|
|
4
|
+
{% from "input/macro.njk" import input with context %}
|
|
5
|
+
{% from "radios/macro.njk" import radios with context %}
|
|
6
|
+
{% from "button/macro.njk" import button with context %}
|
|
7
|
+
{% from "notification-banner/macro.njk" import notificationBanner with context %}
|
|
8
|
+
|
|
9
|
+
{% block content %}
|
|
10
|
+
{% if saved %}
|
|
11
|
+
{{ notificationBanner({ type: "success", text: "Settings saved." }) }}
|
|
12
|
+
{% endif %}
|
|
13
|
+
|
|
14
|
+
{{ heading({ text: title }) }}
|
|
15
|
+
|
|
16
|
+
<form method="POST" action="{{ mountPath }}/admin/settings">
|
|
17
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
18
|
+
|
|
19
|
+
{# ─── Instance & Client API ──────────────────────────────────── #}
|
|
20
|
+
<fieldset class="fieldset">
|
|
21
|
+
<legend class="fieldset__legend">Instance & Client API</legend>
|
|
22
|
+
<p class="hint">Settings reported to Mastodon-compatible clients (Phanpy, Elk, Moshidon).</p>
|
|
23
|
+
|
|
24
|
+
{{ input({
|
|
25
|
+
name: "instanceLanguages",
|
|
26
|
+
label: "Instance languages",
|
|
27
|
+
hint: "Comma-separated ISO 639-1 codes (e.g. en,fr,de). Default: " + defaults.instanceLanguages | join(","),
|
|
28
|
+
value: settings.instanceLanguages | join(","),
|
|
29
|
+
type: "text"
|
|
30
|
+
}) }}
|
|
31
|
+
|
|
32
|
+
{{ input({
|
|
33
|
+
name: "maxCharacters",
|
|
34
|
+
label: "Max characters per status",
|
|
35
|
+
hint: "Character limit shown to clients. Default: " + defaults.maxCharacters,
|
|
36
|
+
value: settings.maxCharacters,
|
|
37
|
+
type: "number"
|
|
38
|
+
}) }}
|
|
39
|
+
|
|
40
|
+
{{ input({
|
|
41
|
+
name: "maxMediaAttachments",
|
|
42
|
+
label: "Max media attachments",
|
|
43
|
+
hint: "Per-status media file limit. Default: " + defaults.maxMediaAttachments,
|
|
44
|
+
value: settings.maxMediaAttachments,
|
|
45
|
+
type: "number"
|
|
46
|
+
}) }}
|
|
47
|
+
|
|
48
|
+
{{ radios({
|
|
49
|
+
name: "defaultVisibility",
|
|
50
|
+
label: "Default post visibility",
|
|
51
|
+
hint: "Default visibility for new posts. Default: " + defaults.defaultVisibility,
|
|
52
|
+
items: [
|
|
53
|
+
{ value: "public", text: "Public", checked: settings.defaultVisibility == "public" },
|
|
54
|
+
{ value: "unlisted", text: "Unlisted", checked: settings.defaultVisibility == "unlisted" },
|
|
55
|
+
{ value: "private", text: "Followers only", checked: settings.defaultVisibility == "private" }
|
|
56
|
+
]
|
|
57
|
+
}) }}
|
|
58
|
+
|
|
59
|
+
{{ input({
|
|
60
|
+
name: "defaultLanguage",
|
|
61
|
+
label: "Default post language",
|
|
62
|
+
hint: "ISO 639-1 code (e.g. en, fr). Default: " + defaults.defaultLanguage,
|
|
63
|
+
value: settings.defaultLanguage,
|
|
64
|
+
type: "text"
|
|
65
|
+
}) }}
|
|
66
|
+
</fieldset>
|
|
67
|
+
|
|
68
|
+
{# ─── Federation & Delivery ──────────────────────────────────── #}
|
|
69
|
+
<fieldset class="fieldset">
|
|
70
|
+
<legend class="fieldset__legend">Federation & Delivery</legend>
|
|
71
|
+
<p class="hint">Controls how content is stored, retained, and delivered to followers.</p>
|
|
72
|
+
|
|
73
|
+
{{ input({
|
|
74
|
+
name: "timelineRetention",
|
|
75
|
+
label: "Timeline retention (items)",
|
|
76
|
+
hint: "Max items in the AP timeline. 0 = unlimited. Default: " + defaults.timelineRetention,
|
|
77
|
+
value: settings.timelineRetention,
|
|
78
|
+
type: "number"
|
|
79
|
+
}) }}
|
|
80
|
+
|
|
81
|
+
{{ input({
|
|
82
|
+
name: "notificationRetentionDays",
|
|
83
|
+
label: "Notification retention (days)",
|
|
84
|
+
hint: "Days to keep notifications. 0 = forever. Default: " + defaults.notificationRetentionDays,
|
|
85
|
+
value: settings.notificationRetentionDays,
|
|
86
|
+
type: "number"
|
|
87
|
+
}) }}
|
|
88
|
+
|
|
89
|
+
{{ input({
|
|
90
|
+
name: "activityRetentionDays",
|
|
91
|
+
label: "Activity log retention (days)",
|
|
92
|
+
hint: "Days to keep activity log entries. 0 = forever. Default: " + defaults.activityRetentionDays,
|
|
93
|
+
value: settings.activityRetentionDays,
|
|
94
|
+
type: "number"
|
|
95
|
+
}) }}
|
|
96
|
+
|
|
97
|
+
{{ input({
|
|
98
|
+
name: "replyChainDepth",
|
|
99
|
+
label: "Reply chain depth",
|
|
100
|
+
hint: "Max parent posts fetched for thread context. Default: " + defaults.replyChainDepth,
|
|
101
|
+
value: settings.replyChainDepth,
|
|
102
|
+
type: "number"
|
|
103
|
+
}) }}
|
|
104
|
+
|
|
105
|
+
{{ input({
|
|
106
|
+
name: "broadcastBatchSize",
|
|
107
|
+
label: "Broadcast batch size",
|
|
108
|
+
hint: "Followers per delivery batch. Default: " + defaults.broadcastBatchSize,
|
|
109
|
+
value: settings.broadcastBatchSize,
|
|
110
|
+
type: "number"
|
|
111
|
+
}) }}
|
|
112
|
+
|
|
113
|
+
{{ input({
|
|
114
|
+
name: "broadcastBatchDelay",
|
|
115
|
+
label: "Broadcast batch delay (ms)",
|
|
116
|
+
hint: "Delay between delivery batches in milliseconds. Default: " + defaults.broadcastBatchDelay,
|
|
117
|
+
value: settings.broadcastBatchDelay,
|
|
118
|
+
type: "number"
|
|
119
|
+
}) }}
|
|
120
|
+
|
|
121
|
+
{{ input({
|
|
122
|
+
name: "parallelWorkers",
|
|
123
|
+
label: "Parallel delivery workers",
|
|
124
|
+
hint: "Redis queue workers. 0 = in-process queue. Default: " + defaults.parallelWorkers,
|
|
125
|
+
value: settings.parallelWorkers,
|
|
126
|
+
type: "number"
|
|
127
|
+
}) }}
|
|
128
|
+
|
|
129
|
+
{{ radios({
|
|
130
|
+
name: "logLevel",
|
|
131
|
+
label: "Federation log level",
|
|
132
|
+
hint: "Fedify log verbosity. Default: " + defaults.logLevel,
|
|
133
|
+
items: [
|
|
134
|
+
{ value: "debug", text: "Debug", checked: settings.logLevel == "debug" },
|
|
135
|
+
{ value: "info", text: "Info", checked: settings.logLevel == "info" },
|
|
136
|
+
{ value: "warning", text: "Warning", checked: settings.logLevel == "warning" },
|
|
137
|
+
{ value: "error", text: "Error", checked: settings.logLevel == "error" }
|
|
138
|
+
]
|
|
139
|
+
}) }}
|
|
140
|
+
</fieldset>
|
|
141
|
+
|
|
142
|
+
{# ─── Migration ──────────────────────────────────────────────── #}
|
|
143
|
+
<fieldset class="fieldset">
|
|
144
|
+
<legend class="fieldset__legend">Migration</legend>
|
|
145
|
+
<p class="hint">Controls the speed of Mastodon account re-follow processing.</p>
|
|
146
|
+
|
|
147
|
+
{{ input({
|
|
148
|
+
name: "refollowBatchSize",
|
|
149
|
+
label: "Refollow batch size",
|
|
150
|
+
hint: "Accounts per refollow batch. Default: " + defaults.refollowBatchSize,
|
|
151
|
+
value: settings.refollowBatchSize,
|
|
152
|
+
type: "number"
|
|
153
|
+
}) }}
|
|
154
|
+
|
|
155
|
+
{{ input({
|
|
156
|
+
name: "refollowDelay",
|
|
157
|
+
label: "Refollow delay per follow (ms)",
|
|
158
|
+
hint: "Delay between individual follow requests. Default: " + defaults.refollowDelay,
|
|
159
|
+
value: settings.refollowDelay,
|
|
160
|
+
type: "number"
|
|
161
|
+
}) }}
|
|
162
|
+
|
|
163
|
+
{{ input({
|
|
164
|
+
name: "refollowBatchDelay",
|
|
165
|
+
label: "Refollow batch delay (ms)",
|
|
166
|
+
hint: "Delay between refollow batches. Default: " + defaults.refollowBatchDelay,
|
|
167
|
+
value: settings.refollowBatchDelay,
|
|
168
|
+
type: "number"
|
|
169
|
+
}) }}
|
|
170
|
+
</fieldset>
|
|
171
|
+
|
|
172
|
+
{# ─── Security ───────────────────────────────────────────────── #}
|
|
173
|
+
<fieldset class="fieldset">
|
|
174
|
+
<legend class="fieldset__legend">Security</legend>
|
|
175
|
+
|
|
176
|
+
{{ input({
|
|
177
|
+
name: "refreshTokenTtlDays",
|
|
178
|
+
label: "Refresh token TTL (days)",
|
|
179
|
+
hint: "Days before OAuth refresh tokens expire. Access tokens never expire. Default: " + defaults.refreshTokenTtlDays,
|
|
180
|
+
value: settings.refreshTokenTtlDays,
|
|
181
|
+
type: "number"
|
|
182
|
+
}) }}
|
|
183
|
+
</fieldset>
|
|
184
|
+
|
|
185
|
+
{{ button({ text: "Save settings" }) }}
|
|
186
|
+
</form>
|
|
187
|
+
{% endblock %}
|