@rmdes/indiekit-endpoint-activitypub 1.0.21 → 1.0.23
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 +48 -8
- package/lib/batch-refollow.js +4 -2
- package/lib/controllers/featured-tags.js +71 -0
- package/lib/controllers/featured.js +117 -0
- package/lib/controllers/profile.js +10 -2
- package/lib/federation-setup.js +271 -7
- package/lib/inbox-listeners.js +1 -0
- package/locales/en.json +4 -0
- package/package.json +3 -3
- package/views/activitypub-featured-tags.njk +43 -0
- package/views/activitypub-featured.njk +52 -0
- package/views/activitypub-profile.njk +12 -0
package/index.js
CHANGED
|
@@ -21,6 +21,16 @@ import {
|
|
|
21
21
|
profileGetController,
|
|
22
22
|
profilePostController,
|
|
23
23
|
} from "./lib/controllers/profile.js";
|
|
24
|
+
import {
|
|
25
|
+
featuredGetController,
|
|
26
|
+
featuredPinController,
|
|
27
|
+
featuredUnpinController,
|
|
28
|
+
} from "./lib/controllers/featured.js";
|
|
29
|
+
import {
|
|
30
|
+
featuredTagsGetController,
|
|
31
|
+
featuredTagsAddController,
|
|
32
|
+
featuredTagsRemoveController,
|
|
33
|
+
} from "./lib/controllers/featured-tags.js";
|
|
24
34
|
import {
|
|
25
35
|
refollowPauseController,
|
|
26
36
|
refollowResumeController,
|
|
@@ -42,6 +52,8 @@ const defaults = {
|
|
|
42
52
|
activityRetentionDays: 90,
|
|
43
53
|
storeRawActivities: false,
|
|
44
54
|
redisUrl: "",
|
|
55
|
+
parallelWorkers: 5,
|
|
56
|
+
actorType: "Person",
|
|
45
57
|
};
|
|
46
58
|
|
|
47
59
|
export default class ActivityPubEndpoint {
|
|
@@ -136,6 +148,12 @@ export default class ActivityPubEndpoint {
|
|
|
136
148
|
router.get("/admin/followers", followersController(mp));
|
|
137
149
|
router.get("/admin/following", followingController(mp));
|
|
138
150
|
router.get("/admin/activities", activitiesController(mp));
|
|
151
|
+
router.get("/admin/featured", featuredGetController(mp));
|
|
152
|
+
router.post("/admin/featured/pin", featuredPinController());
|
|
153
|
+
router.post("/admin/featured/unpin", featuredUnpinController());
|
|
154
|
+
router.get("/admin/tags", featuredTagsGetController(mp));
|
|
155
|
+
router.post("/admin/tags/add", featuredTagsAddController());
|
|
156
|
+
router.post("/admin/tags/remove", featuredTagsRemoveController());
|
|
139
157
|
router.get("/admin/profile", profileGetController(mp));
|
|
140
158
|
router.post("/admin/profile", profilePostController(mp));
|
|
141
159
|
router.get("/admin/migrate", migrateGetController(mp, this.options));
|
|
@@ -167,8 +185,13 @@ export default class ActivityPubEndpoint {
|
|
|
167
185
|
router.use((req, res, next) => {
|
|
168
186
|
if (!self._fedifyMiddleware) return next();
|
|
169
187
|
if (req.method !== "GET" && req.method !== "HEAD") return next();
|
|
170
|
-
// Skip Fedify for admin routes — handled by authenticated router
|
|
171
|
-
|
|
188
|
+
// Skip Fedify for admin routes — handled by authenticated router.
|
|
189
|
+
// This router is mounted at "/" so paths include the full mountPath prefix
|
|
190
|
+
// (e.g. /activitypub/admin/...), not just /admin/...
|
|
191
|
+
const mp = self.options.mountPath;
|
|
192
|
+
if (req.path.startsWith("/admin") || req.path.startsWith(`${mp}/admin`)) return next();
|
|
193
|
+
// Skip Fedify for the bare dashboard path (e.g. /activitypub)
|
|
194
|
+
if (req.path === mp || req.path === `${mp}/`) return next();
|
|
172
195
|
return self._fedifyMiddleware(req, res, next);
|
|
173
196
|
});
|
|
174
197
|
|
|
@@ -266,7 +289,7 @@ export default class ActivityPubEndpoint {
|
|
|
266
289
|
|
|
267
290
|
const ctx = self._federation.createContext(
|
|
268
291
|
new URL(self._publicationUrl),
|
|
269
|
-
{},
|
|
292
|
+
{ handle, publicationUrl: self._publicationUrl },
|
|
270
293
|
);
|
|
271
294
|
|
|
272
295
|
// For replies, resolve the original post author for proper
|
|
@@ -327,11 +350,16 @@ export default class ActivityPubEndpoint {
|
|
|
327
350
|
`[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
|
|
328
351
|
);
|
|
329
352
|
|
|
330
|
-
// Send to followers
|
|
353
|
+
// Send to followers via shared inboxes with collection sync (FEP-8fcf)
|
|
331
354
|
await ctx.sendActivity(
|
|
332
355
|
{ identifier: handle },
|
|
333
356
|
"followers",
|
|
334
357
|
activity,
|
|
358
|
+
{
|
|
359
|
+
preferSharedInbox: true,
|
|
360
|
+
syncCollection: true,
|
|
361
|
+
orderingKey: properties.url,
|
|
362
|
+
},
|
|
335
363
|
);
|
|
336
364
|
|
|
337
365
|
// For replies, also deliver to the original post author's inbox
|
|
@@ -342,6 +370,7 @@ export default class ActivityPubEndpoint {
|
|
|
342
370
|
{ identifier: handle },
|
|
343
371
|
replyToActor.recipient,
|
|
344
372
|
activity,
|
|
373
|
+
{ orderingKey: properties.url },
|
|
345
374
|
);
|
|
346
375
|
console.info(
|
|
347
376
|
`[ActivityPub] Reply delivered to author: ${replyToActor.url}`,
|
|
@@ -408,7 +437,7 @@ export default class ActivityPubEndpoint {
|
|
|
408
437
|
const handle = this.options.actor.handle;
|
|
409
438
|
const ctx = this._federation.createContext(
|
|
410
439
|
new URL(this._publicationUrl),
|
|
411
|
-
{},
|
|
440
|
+
{ handle, publicationUrl: this._publicationUrl },
|
|
412
441
|
);
|
|
413
442
|
|
|
414
443
|
// Resolve the remote actor to get their inbox
|
|
@@ -423,7 +452,9 @@ export default class ActivityPubEndpoint {
|
|
|
423
452
|
object: new URL(actorUrl),
|
|
424
453
|
});
|
|
425
454
|
|
|
426
|
-
await ctx.sendActivity({ identifier: handle }, remoteActor, follow
|
|
455
|
+
await ctx.sendActivity({ identifier: handle }, remoteActor, follow, {
|
|
456
|
+
orderingKey: actorUrl,
|
|
457
|
+
});
|
|
427
458
|
|
|
428
459
|
// Store in ap_following
|
|
429
460
|
const name =
|
|
@@ -502,7 +533,7 @@ export default class ActivityPubEndpoint {
|
|
|
502
533
|
const handle = this.options.actor.handle;
|
|
503
534
|
const ctx = this._federation.createContext(
|
|
504
535
|
new URL(this._publicationUrl),
|
|
505
|
-
{},
|
|
536
|
+
{ handle, publicationUrl: this._publicationUrl },
|
|
506
537
|
);
|
|
507
538
|
|
|
508
539
|
const remoteActor = await ctx.lookupObject(actorUrl);
|
|
@@ -531,7 +562,9 @@ export default class ActivityPubEndpoint {
|
|
|
531
562
|
object: follow,
|
|
532
563
|
});
|
|
533
564
|
|
|
534
|
-
await ctx.sendActivity({ identifier: handle }, remoteActor, undo
|
|
565
|
+
await ctx.sendActivity({ identifier: handle }, remoteActor, undo, {
|
|
566
|
+
orderingKey: actorUrl,
|
|
567
|
+
});
|
|
535
568
|
await this._collections.ap_following.deleteOne({ actorUrl });
|
|
536
569
|
|
|
537
570
|
console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`);
|
|
@@ -586,6 +619,8 @@ export default class ActivityPubEndpoint {
|
|
|
586
619
|
Indiekit.addCollection("ap_keys");
|
|
587
620
|
Indiekit.addCollection("ap_kv");
|
|
588
621
|
Indiekit.addCollection("ap_profile");
|
|
622
|
+
Indiekit.addCollection("ap_featured");
|
|
623
|
+
Indiekit.addCollection("ap_featured_tags");
|
|
589
624
|
|
|
590
625
|
// Store collection references (posts resolved lazily)
|
|
591
626
|
const indiekitCollections = Indiekit.collections;
|
|
@@ -596,6 +631,8 @@ export default class ActivityPubEndpoint {
|
|
|
596
631
|
ap_keys: indiekitCollections.get("ap_keys"),
|
|
597
632
|
ap_kv: indiekitCollections.get("ap_kv"),
|
|
598
633
|
ap_profile: indiekitCollections.get("ap_profile"),
|
|
634
|
+
ap_featured: indiekitCollections.get("ap_featured"),
|
|
635
|
+
ap_featured_tags: indiekitCollections.get("ap_featured_tags"),
|
|
599
636
|
get posts() {
|
|
600
637
|
return indiekitCollections.get("posts");
|
|
601
638
|
},
|
|
@@ -652,6 +689,9 @@ export default class ActivityPubEndpoint {
|
|
|
652
689
|
handle: this.options.actor.handle,
|
|
653
690
|
storeRawActivities: this.options.storeRawActivities,
|
|
654
691
|
redisUrl: this.options.redisUrl,
|
|
692
|
+
publicationUrl: this._publicationUrl,
|
|
693
|
+
parallelWorkers: this.options.parallelWorkers,
|
|
694
|
+
actorType: this.options.actorType,
|
|
655
695
|
});
|
|
656
696
|
|
|
657
697
|
this._federation = federation;
|
package/lib/batch-refollow.js
CHANGED
|
@@ -225,7 +225,7 @@ async function processOneFollow(options, entry) {
|
|
|
225
225
|
const { federation, collections, handle, publicationUrl } = options;
|
|
226
226
|
|
|
227
227
|
try {
|
|
228
|
-
const ctx = federation.createContext(new URL(publicationUrl), {});
|
|
228
|
+
const ctx = federation.createContext(new URL(publicationUrl), { handle, publicationUrl });
|
|
229
229
|
|
|
230
230
|
// Resolve the remote actor
|
|
231
231
|
const remoteActor = await ctx.lookupObject(entry.actorUrl);
|
|
@@ -242,7 +242,9 @@ async function processOneFollow(options, entry) {
|
|
|
242
242
|
object: new URL(canonicalUrl),
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
-
await ctx.sendActivity({ identifier: handle }, remoteActor, follow
|
|
245
|
+
await ctx.sendActivity({ identifier: handle }, remoteActor, follow, {
|
|
246
|
+
orderingKey: canonicalUrl,
|
|
247
|
+
});
|
|
246
248
|
|
|
247
249
|
// Mark as sent — update actorUrl to canonical form so Accept handler
|
|
248
250
|
// can match when the remote server responds
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Featured tags controller — list, add, and remove featured hashtags.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function featuredTagsGetController(mountPath) {
|
|
6
|
+
return async (request, response, next) => {
|
|
7
|
+
try {
|
|
8
|
+
const { application } = request.app.locals;
|
|
9
|
+
const collection = application?.collections?.get("ap_featured_tags");
|
|
10
|
+
|
|
11
|
+
const tags = collection
|
|
12
|
+
? await collection.find().sort({ addedAt: -1 }).toArray()
|
|
13
|
+
: [];
|
|
14
|
+
|
|
15
|
+
response.render("activitypub-featured-tags", {
|
|
16
|
+
title:
|
|
17
|
+
response.locals.__("activitypub.featuredTags") || "Featured Tags",
|
|
18
|
+
tags,
|
|
19
|
+
mountPath,
|
|
20
|
+
});
|
|
21
|
+
} catch (error) {
|
|
22
|
+
next(error);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function featuredTagsAddController() {
|
|
28
|
+
return async (request, response, next) => {
|
|
29
|
+
try {
|
|
30
|
+
const { application } = request.app.locals;
|
|
31
|
+
const collection = application?.collections?.get("ap_featured_tags");
|
|
32
|
+
if (!collection) return response.status(500).send("No collection");
|
|
33
|
+
|
|
34
|
+
let { tag } = request.body;
|
|
35
|
+
if (!tag) return response.status(400).send("Missing tag");
|
|
36
|
+
|
|
37
|
+
// Normalize: strip leading # and lowercase
|
|
38
|
+
tag = tag.replace(/^#/, "").toLowerCase().trim();
|
|
39
|
+
if (!tag) return response.status(400).send("Invalid tag");
|
|
40
|
+
|
|
41
|
+
await collection.updateOne(
|
|
42
|
+
{ tag },
|
|
43
|
+
{ $set: { tag, addedAt: new Date().toISOString() } },
|
|
44
|
+
{ upsert: true },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
response.redirect("back");
|
|
48
|
+
} catch (error) {
|
|
49
|
+
next(error);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function featuredTagsRemoveController() {
|
|
55
|
+
return async (request, response, next) => {
|
|
56
|
+
try {
|
|
57
|
+
const { application } = request.app.locals;
|
|
58
|
+
const collection = application?.collections?.get("ap_featured_tags");
|
|
59
|
+
if (!collection) return response.status(500).send("No collection");
|
|
60
|
+
|
|
61
|
+
const { tag } = request.body;
|
|
62
|
+
if (!tag) return response.status(400).send("Missing tag");
|
|
63
|
+
|
|
64
|
+
await collection.deleteOne({ tag });
|
|
65
|
+
|
|
66
|
+
response.redirect("back");
|
|
67
|
+
} catch (error) {
|
|
68
|
+
next(error);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Featured (pinned) posts controller — list, pin, and unpin posts.
|
|
3
|
+
*/
|
|
4
|
+
const MAX_PINS = 5;
|
|
5
|
+
|
|
6
|
+
export function featuredGetController(mountPath) {
|
|
7
|
+
return async (request, response, next) => {
|
|
8
|
+
try {
|
|
9
|
+
const { application } = request.app.locals;
|
|
10
|
+
const featuredCollection = application?.collections?.get("ap_featured");
|
|
11
|
+
const postsCollection = application?.collections?.get("posts");
|
|
12
|
+
|
|
13
|
+
const pinnedDocs = featuredCollection
|
|
14
|
+
? await featuredCollection.find().sort({ pinnedAt: -1 }).toArray()
|
|
15
|
+
: [];
|
|
16
|
+
|
|
17
|
+
// Enrich pinned posts with title/type from posts collection
|
|
18
|
+
const pinned = [];
|
|
19
|
+
for (const doc of pinnedDocs) {
|
|
20
|
+
let title = doc.postUrl;
|
|
21
|
+
let postType = "note";
|
|
22
|
+
if (postsCollection) {
|
|
23
|
+
const post = await postsCollection.findOne({
|
|
24
|
+
"properties.url": doc.postUrl,
|
|
25
|
+
});
|
|
26
|
+
if (post?.properties) {
|
|
27
|
+
title =
|
|
28
|
+
post.properties.name ||
|
|
29
|
+
post.properties.content?.text?.slice(0, 80) ||
|
|
30
|
+
doc.postUrl;
|
|
31
|
+
postType = post.properties["post-type"] || "note";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
pinned.push({ ...doc, title, postType });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Get recent posts for the "pin" dropdown
|
|
38
|
+
const recentPosts = postsCollection
|
|
39
|
+
? await postsCollection
|
|
40
|
+
.find()
|
|
41
|
+
.sort({ "properties.published": -1 })
|
|
42
|
+
.limit(20)
|
|
43
|
+
.toArray()
|
|
44
|
+
: [];
|
|
45
|
+
|
|
46
|
+
const pinnedUrls = new Set(pinnedDocs.map((d) => d.postUrl));
|
|
47
|
+
const availablePosts = recentPosts
|
|
48
|
+
.filter((p) => p.properties?.url && !pinnedUrls.has(p.properties.url))
|
|
49
|
+
.map((p) => ({
|
|
50
|
+
url: p.properties.url,
|
|
51
|
+
title:
|
|
52
|
+
p.properties.name ||
|
|
53
|
+
p.properties.content?.text?.slice(0, 80) ||
|
|
54
|
+
p.properties.url,
|
|
55
|
+
postType: p.properties["post-type"] || "note",
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
response.render("activitypub-featured", {
|
|
59
|
+
title: response.locals.__("activitypub.featured") || "Pinned Posts",
|
|
60
|
+
pinned,
|
|
61
|
+
availablePosts,
|
|
62
|
+
maxPins: MAX_PINS,
|
|
63
|
+
canPin: pinned.length < MAX_PINS,
|
|
64
|
+
mountPath,
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
next(error);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function featuredPinController() {
|
|
73
|
+
return async (request, response, next) => {
|
|
74
|
+
try {
|
|
75
|
+
const { application } = request.app.locals;
|
|
76
|
+
const collection = application?.collections?.get("ap_featured");
|
|
77
|
+
if (!collection) return response.status(500).send("No collection");
|
|
78
|
+
|
|
79
|
+
const { postUrl } = request.body;
|
|
80
|
+
if (!postUrl) return response.status(400).send("Missing postUrl");
|
|
81
|
+
|
|
82
|
+
const count = await collection.countDocuments();
|
|
83
|
+
if (count >= MAX_PINS) {
|
|
84
|
+
return response.status(400).send("Maximum pins reached");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await collection.updateOne(
|
|
88
|
+
{ postUrl },
|
|
89
|
+
{ $set: { postUrl, pinnedAt: new Date().toISOString() } },
|
|
90
|
+
{ upsert: true },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
response.redirect("back");
|
|
94
|
+
} catch (error) {
|
|
95
|
+
next(error);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function featuredUnpinController() {
|
|
101
|
+
return async (request, response, next) => {
|
|
102
|
+
try {
|
|
103
|
+
const { application } = request.app.locals;
|
|
104
|
+
const collection = application?.collections?.get("ap_featured");
|
|
105
|
+
if (!collection) return response.status(500).send("No collection");
|
|
106
|
+
|
|
107
|
+
const { postUrl } = request.body;
|
|
108
|
+
if (!postUrl) return response.status(400).send("Missing postUrl");
|
|
109
|
+
|
|
110
|
+
await collection.deleteOne({ postUrl });
|
|
111
|
+
|
|
112
|
+
response.redirect("back");
|
|
113
|
+
} catch (error) {
|
|
114
|
+
next(error);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -36,8 +36,15 @@ export function profilePostController(mountPath) {
|
|
|
36
36
|
return next(new Error("ap_profile collection not available"));
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
const {
|
|
40
|
-
|
|
39
|
+
const {
|
|
40
|
+
name,
|
|
41
|
+
summary,
|
|
42
|
+
url,
|
|
43
|
+
icon,
|
|
44
|
+
image,
|
|
45
|
+
manuallyApprovesFollowers,
|
|
46
|
+
authorizedFetch,
|
|
47
|
+
} = request.body;
|
|
41
48
|
|
|
42
49
|
const update = {
|
|
43
50
|
$set: {
|
|
@@ -47,6 +54,7 @@ export function profilePostController(mountPath) {
|
|
|
47
54
|
icon: icon?.trim() || "",
|
|
48
55
|
image: image?.trim() || "",
|
|
49
56
|
manuallyApprovesFollowers: manuallyApprovesFollowers === "true",
|
|
57
|
+
authorizedFetch: authorizedFetch === "true",
|
|
50
58
|
updatedAt: new Date().toISOString(),
|
|
51
59
|
},
|
|
52
60
|
};
|
package/lib/federation-setup.js
CHANGED
|
@@ -7,13 +7,23 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
10
11
|
import { Temporal } from "@js-temporal/polyfill";
|
|
11
12
|
import {
|
|
13
|
+
Application,
|
|
14
|
+
Article,
|
|
15
|
+
Create,
|
|
12
16
|
Endpoints,
|
|
17
|
+
Group,
|
|
18
|
+
Hashtag,
|
|
13
19
|
Image,
|
|
14
20
|
InProcessMessageQueue,
|
|
21
|
+
Note,
|
|
22
|
+
Organization,
|
|
23
|
+
ParallelMessageQueue,
|
|
15
24
|
Person,
|
|
16
25
|
PropertyValue,
|
|
26
|
+
Service,
|
|
17
27
|
createFederation,
|
|
18
28
|
exportJwk,
|
|
19
29
|
generateCryptoKeyPair,
|
|
@@ -25,6 +35,7 @@ import { RedisMessageQueue } from "@fedify/redis";
|
|
|
25
35
|
import Redis from "ioredis";
|
|
26
36
|
import { MongoKvStore } from "./kv-store.js";
|
|
27
37
|
import { registerInboxListeners } from "./inbox-listeners.js";
|
|
38
|
+
import { jf2ToAS2Activity, resolvePostUrl } from "./jf2-to-as2.js";
|
|
28
39
|
|
|
29
40
|
/**
|
|
30
41
|
* Create and configure a Fedify Federation instance.
|
|
@@ -46,8 +57,15 @@ export function setupFederation(options) {
|
|
|
46
57
|
handle,
|
|
47
58
|
storeRawActivities = false,
|
|
48
59
|
redisUrl = "",
|
|
60
|
+
publicationUrl = "",
|
|
61
|
+
parallelWorkers = 5,
|
|
62
|
+
actorType = "Person",
|
|
49
63
|
} = options;
|
|
50
64
|
|
|
65
|
+
// Map config string to Fedify actor class
|
|
66
|
+
const actorTypeMap = { Person, Service, Application, Organization, Group };
|
|
67
|
+
const ActorClass = actorTypeMap[actorType] || Person;
|
|
68
|
+
|
|
51
69
|
// Configure LogTape for Fedify delivery logging (once per process)
|
|
52
70
|
if (!_logtapeConfigured) {
|
|
53
71
|
_logtapeConfigured = true;
|
|
@@ -71,8 +89,16 @@ export function setupFederation(options) {
|
|
|
71
89
|
|
|
72
90
|
let queue;
|
|
73
91
|
if (redisUrl) {
|
|
74
|
-
|
|
75
|
-
|
|
92
|
+
const redisQueue = new RedisMessageQueue(() => new Redis(redisUrl));
|
|
93
|
+
if (parallelWorkers > 1) {
|
|
94
|
+
queue = new ParallelMessageQueue(redisQueue, parallelWorkers);
|
|
95
|
+
console.info(
|
|
96
|
+
`[ActivityPub] Using Redis message queue with ${parallelWorkers} parallel workers`,
|
|
97
|
+
);
|
|
98
|
+
} else {
|
|
99
|
+
queue = redisQueue;
|
|
100
|
+
console.info("[ActivityPub] Using Redis message queue (single worker)");
|
|
101
|
+
}
|
|
76
102
|
} else {
|
|
77
103
|
queue = new InProcessMessageQueue();
|
|
78
104
|
console.warn(
|
|
@@ -90,6 +116,26 @@ export function setupFederation(options) {
|
|
|
90
116
|
.setActorDispatcher(
|
|
91
117
|
`${mountPath}/users/{identifier}`,
|
|
92
118
|
async (ctx, identifier) => {
|
|
119
|
+
// Instance actor: Application-type actor for the domain itself
|
|
120
|
+
// Required for authorized fetch to avoid infinite loops
|
|
121
|
+
const hostname = ctx.url?.hostname || "";
|
|
122
|
+
if (identifier === hostname) {
|
|
123
|
+
const keyPairs = await ctx.getActorKeyPairs(identifier);
|
|
124
|
+
const appOptions = {
|
|
125
|
+
id: ctx.getActorUri(identifier),
|
|
126
|
+
preferredUsername: hostname,
|
|
127
|
+
name: hostname,
|
|
128
|
+
inbox: ctx.getInboxUri(identifier),
|
|
129
|
+
outbox: ctx.getOutboxUri(identifier),
|
|
130
|
+
endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
|
|
131
|
+
};
|
|
132
|
+
if (keyPairs.length > 0) {
|
|
133
|
+
appOptions.publicKey = keyPairs[0].cryptographicKey;
|
|
134
|
+
appOptions.assertionMethods = keyPairs.map((k) => k.multikey);
|
|
135
|
+
}
|
|
136
|
+
return new Application(appOptions);
|
|
137
|
+
}
|
|
138
|
+
|
|
93
139
|
if (identifier !== handle) return null;
|
|
94
140
|
|
|
95
141
|
const profile = await getProfile(collections);
|
|
@@ -104,6 +150,9 @@ export function setupFederation(options) {
|
|
|
104
150
|
outbox: ctx.getOutboxUri(identifier),
|
|
105
151
|
followers: ctx.getFollowersUri(identifier),
|
|
106
152
|
following: ctx.getFollowingUri(identifier),
|
|
153
|
+
liked: ctx.getLikedUri(identifier),
|
|
154
|
+
featured: ctx.getFeaturedUri(identifier),
|
|
155
|
+
featuredTags: ctx.getFeaturedTagsUri(identifier),
|
|
107
156
|
endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
|
|
108
157
|
manuallyApprovesFollowers:
|
|
109
158
|
profile.manuallyApprovesFollowers || false,
|
|
@@ -148,12 +197,35 @@ export function setupFederation(options) {
|
|
|
148
197
|
personOptions.published = Temporal.Instant.from(profile.createdAt);
|
|
149
198
|
}
|
|
150
199
|
|
|
151
|
-
return new
|
|
200
|
+
return new ActorClass(personOptions);
|
|
152
201
|
},
|
|
153
202
|
)
|
|
154
|
-
.mapHandle((_ctx, username) =>
|
|
203
|
+
.mapHandle((_ctx, username) => {
|
|
204
|
+
if (username === handle) return handle;
|
|
205
|
+
// Accept hostname as valid identifier for instance actor
|
|
206
|
+
if (publicationUrl) {
|
|
207
|
+
try {
|
|
208
|
+
const hostname = new URL(publicationUrl).hostname;
|
|
209
|
+
if (username === hostname) return hostname;
|
|
210
|
+
} catch { /* ignore */ }
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
})
|
|
214
|
+
.mapAlias((_ctx, alias) => {
|
|
215
|
+
// Resolve profile URL and /@handle patterns via WebFinger
|
|
216
|
+
if (!publicationUrl) return null;
|
|
217
|
+
try {
|
|
218
|
+
const pub = new URL(publicationUrl);
|
|
219
|
+
if (alias.hostname !== pub.hostname) return null;
|
|
220
|
+
const path = alias.pathname.replace(/\/$/, "");
|
|
221
|
+
if (path === "" || path === `/@${handle}`) return handle;
|
|
222
|
+
} catch { /* ignore */ }
|
|
223
|
+
return null;
|
|
224
|
+
})
|
|
155
225
|
.setKeyPairsDispatcher(async (ctx, identifier) => {
|
|
156
|
-
|
|
226
|
+
// Allow key pairs for both the main actor and instance actor
|
|
227
|
+
const hostname = ctx.url?.hostname || "";
|
|
228
|
+
if (identifier !== handle && identifier !== hostname) return [];
|
|
157
229
|
|
|
158
230
|
const keyPairs = [];
|
|
159
231
|
|
|
@@ -224,6 +296,16 @@ export function setupFederation(options) {
|
|
|
224
296
|
}
|
|
225
297
|
|
|
226
298
|
return keyPairs;
|
|
299
|
+
})
|
|
300
|
+
.authorize(async (ctx, identifier, signedKey, _signedKeyOwner) => {
|
|
301
|
+
// Instance actor is always publicly accessible (prevents infinite loops)
|
|
302
|
+
const hostname = ctx.url?.hostname || "";
|
|
303
|
+
if (identifier === hostname) return true;
|
|
304
|
+
// Check if authorized fetch is enabled
|
|
305
|
+
const profile = await getProfile(collections);
|
|
306
|
+
if (!profile.authorizedFetch) return true;
|
|
307
|
+
// When enabled, require a valid HTTP Signature
|
|
308
|
+
return signedKey != null;
|
|
227
309
|
});
|
|
228
310
|
|
|
229
311
|
// --- Inbox listeners ---
|
|
@@ -248,8 +330,22 @@ export function setupFederation(options) {
|
|
|
248
330
|
setupFollowers(federation, mountPath, handle, collections);
|
|
249
331
|
setupFollowing(federation, mountPath, handle, collections);
|
|
250
332
|
setupOutbox(federation, mountPath, handle, collections);
|
|
333
|
+
setupLiked(federation, mountPath, handle, collections);
|
|
334
|
+
setupFeatured(federation, mountPath, handle, collections, publicationUrl);
|
|
335
|
+
setupFeaturedTags(federation, mountPath, handle, collections, publicationUrl);
|
|
336
|
+
|
|
337
|
+
// --- Object dispatchers (make posts dereferenceable) ---
|
|
338
|
+
setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl);
|
|
251
339
|
|
|
252
340
|
// --- NodeInfo ---
|
|
341
|
+
let softwareVersion = { major: 1, minor: 0, patch: 0 };
|
|
342
|
+
try {
|
|
343
|
+
const require = createRequire(import.meta.url);
|
|
344
|
+
const pkg = require("@indiekit/indiekit/package.json");
|
|
345
|
+
const [major, minor, patch] = pkg.version.split(/[.-]/).map(Number);
|
|
346
|
+
if (!Number.isNaN(major)) softwareVersion = { major, minor: minor || 0, patch: patch || 0 };
|
|
347
|
+
} catch { /* fallback to 1.0.0 */ }
|
|
348
|
+
|
|
253
349
|
federation.setNodeInfoDispatcher("/nodeinfo/2.1", async () => {
|
|
254
350
|
const postsCount = collections.posts
|
|
255
351
|
? await collections.posts.countDocuments()
|
|
@@ -258,7 +354,7 @@ export function setupFederation(options) {
|
|
|
258
354
|
return {
|
|
259
355
|
software: {
|
|
260
356
|
name: "indiekit",
|
|
261
|
-
version:
|
|
357
|
+
version: softwareVersion,
|
|
262
358
|
},
|
|
263
359
|
protocols: ["activitypub"],
|
|
264
360
|
usage: {
|
|
@@ -288,8 +384,29 @@ function setupFollowers(federation, mountPath, handle, collections) {
|
|
|
288
384
|
`${mountPath}/users/{identifier}/followers`,
|
|
289
385
|
async (ctx, identifier, cursor) => {
|
|
290
386
|
if (identifier !== handle) return null;
|
|
387
|
+
|
|
388
|
+
// One-shot collection: when cursor is null, return ALL followers
|
|
389
|
+
// as Recipient objects so sendActivity("followers") can deliver.
|
|
390
|
+
// See: https://fedify.dev/manual/collections#one-shot-followers-collection-for-gathering-recipients
|
|
391
|
+
if (cursor == null) {
|
|
392
|
+
const docs = await collections.ap_followers
|
|
393
|
+
.find()
|
|
394
|
+
.sort({ followedAt: -1 })
|
|
395
|
+
.toArray();
|
|
396
|
+
return {
|
|
397
|
+
items: docs.map((f) => ({
|
|
398
|
+
id: new URL(f.actorUrl),
|
|
399
|
+
inboxId: f.inbox ? new URL(f.inbox) : null,
|
|
400
|
+
endpoints: f.sharedInbox
|
|
401
|
+
? { sharedInbox: new URL(f.sharedInbox) }
|
|
402
|
+
: null,
|
|
403
|
+
})),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Paginated collection: for remote browsing of /followers endpoint
|
|
291
408
|
const pageSize = 20;
|
|
292
|
-
const skip =
|
|
409
|
+
const skip = Number.parseInt(cursor, 10);
|
|
293
410
|
const docs = await collections.ap_followers
|
|
294
411
|
.find()
|
|
295
412
|
.sort({ followedAt: -1 })
|
|
@@ -342,6 +459,118 @@ function setupFollowing(federation, mountPath, handle, collections) {
|
|
|
342
459
|
.setFirstCursor(async () => "0");
|
|
343
460
|
}
|
|
344
461
|
|
|
462
|
+
function setupLiked(federation, mountPath, handle, collections) {
|
|
463
|
+
federation
|
|
464
|
+
.setLikedDispatcher(
|
|
465
|
+
`${mountPath}/users/{identifier}/liked`,
|
|
466
|
+
async (ctx, identifier, cursor) => {
|
|
467
|
+
if (identifier !== handle) return null;
|
|
468
|
+
if (!collections.posts) return { items: [] };
|
|
469
|
+
|
|
470
|
+
const pageSize = 20;
|
|
471
|
+
const skip = cursor ? Number.parseInt(cursor, 10) : 0;
|
|
472
|
+
const query = { "properties.post-type": "like" };
|
|
473
|
+
const docs = await collections.posts
|
|
474
|
+
.find(query)
|
|
475
|
+
.sort({ "properties.published": -1 })
|
|
476
|
+
.skip(skip)
|
|
477
|
+
.limit(pageSize)
|
|
478
|
+
.toArray();
|
|
479
|
+
const total = await collections.posts.countDocuments(query);
|
|
480
|
+
|
|
481
|
+
const items = docs
|
|
482
|
+
.map((d) => {
|
|
483
|
+
const likeOf = d.properties?.["like-of"];
|
|
484
|
+
return likeOf ? new URL(likeOf) : null;
|
|
485
|
+
})
|
|
486
|
+
.filter(Boolean);
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
items,
|
|
490
|
+
nextCursor:
|
|
491
|
+
skip + pageSize < total ? String(skip + pageSize) : null,
|
|
492
|
+
};
|
|
493
|
+
},
|
|
494
|
+
)
|
|
495
|
+
.setCounter(async (ctx, identifier) => {
|
|
496
|
+
if (identifier !== handle) return 0;
|
|
497
|
+
if (!collections.posts) return 0;
|
|
498
|
+
return await collections.posts.countDocuments({
|
|
499
|
+
"properties.post-type": "like",
|
|
500
|
+
});
|
|
501
|
+
})
|
|
502
|
+
.setFirstCursor(async () => "0");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function setupFeatured(federation, mountPath, handle, collections, publicationUrl) {
|
|
506
|
+
federation.setFeaturedDispatcher(
|
|
507
|
+
`${mountPath}/users/{identifier}/featured`,
|
|
508
|
+
async (ctx, identifier) => {
|
|
509
|
+
if (identifier !== handle) return null;
|
|
510
|
+
if (!collections.ap_featured) return { items: [] };
|
|
511
|
+
|
|
512
|
+
const docs = await collections.ap_featured
|
|
513
|
+
.find()
|
|
514
|
+
.sort({ pinnedAt: -1 })
|
|
515
|
+
.toArray();
|
|
516
|
+
|
|
517
|
+
// Convert pinned post URLs to Fedify Note/Article objects
|
|
518
|
+
const items = [];
|
|
519
|
+
for (const doc of docs) {
|
|
520
|
+
if (!collections.posts) continue;
|
|
521
|
+
const post = await collections.posts.findOne({
|
|
522
|
+
"properties.url": doc.postUrl,
|
|
523
|
+
});
|
|
524
|
+
if (!post) continue;
|
|
525
|
+
const actorUrl = ctx.getActorUri(identifier).href;
|
|
526
|
+
const activity = jf2ToAS2Activity(
|
|
527
|
+
post.properties,
|
|
528
|
+
actorUrl,
|
|
529
|
+
publicationUrl,
|
|
530
|
+
);
|
|
531
|
+
if (activity instanceof Create) {
|
|
532
|
+
const obj = await activity.getObject();
|
|
533
|
+
if (obj) items.push(obj);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return { items };
|
|
538
|
+
},
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function setupFeaturedTags(federation, mountPath, handle, collections, publicationUrl) {
|
|
543
|
+
federation.setFeaturedTagsDispatcher(
|
|
544
|
+
`${mountPath}/users/{identifier}/tags`,
|
|
545
|
+
async (ctx, identifier) => {
|
|
546
|
+
if (identifier !== handle) return null;
|
|
547
|
+
if (!collections.ap_featured_tags) return { items: [] };
|
|
548
|
+
|
|
549
|
+
const docs = await collections.ap_featured_tags
|
|
550
|
+
.find()
|
|
551
|
+
.sort({ addedAt: -1 })
|
|
552
|
+
.toArray();
|
|
553
|
+
|
|
554
|
+
const baseUrl = publicationUrl
|
|
555
|
+
? publicationUrl.replace(/\/$/, "")
|
|
556
|
+
: ctx.url.origin;
|
|
557
|
+
|
|
558
|
+
const items = docs.map(
|
|
559
|
+
(doc) =>
|
|
560
|
+
new Hashtag({
|
|
561
|
+
name: `#${doc.tag}`,
|
|
562
|
+
href: new URL(
|
|
563
|
+
`/categories/${encodeURIComponent(doc.tag)}`,
|
|
564
|
+
baseUrl,
|
|
565
|
+
),
|
|
566
|
+
}),
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
return { items };
|
|
570
|
+
},
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
345
574
|
function setupOutbox(federation, mountPath, handle, collections) {
|
|
346
575
|
federation
|
|
347
576
|
.setOutboxDispatcher(
|
|
@@ -394,6 +623,41 @@ function setupOutbox(federation, mountPath, handle, collections) {
|
|
|
394
623
|
.setFirstCursor(async () => "0");
|
|
395
624
|
}
|
|
396
625
|
|
|
626
|
+
function setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl) {
|
|
627
|
+
// Shared lookup: find post by URL path, convert to Fedify Note/Article
|
|
628
|
+
async function resolvePost(ctx, id) {
|
|
629
|
+
if (!collections.posts || !publicationUrl) return null;
|
|
630
|
+
const postUrl = `${publicationUrl.replace(/\/$/, "")}/${id}`;
|
|
631
|
+
const post = await collections.posts.findOne({ "properties.url": postUrl });
|
|
632
|
+
if (!post) return null;
|
|
633
|
+
const actorUrl = ctx.getActorUri(handle).href;
|
|
634
|
+
const activity = jf2ToAS2Activity(post.properties, actorUrl, publicationUrl);
|
|
635
|
+
// Only Create activities wrap Note/Article objects
|
|
636
|
+
if (!(activity instanceof Create)) return null;
|
|
637
|
+
return await activity.getObject();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Note dispatcher — handles note, reply, bookmark, jam, rsvp, checkin
|
|
641
|
+
federation.setObjectDispatcher(
|
|
642
|
+
Note,
|
|
643
|
+
`${mountPath}/objects/note/{+id}`,
|
|
644
|
+
async (ctx, { id }) => {
|
|
645
|
+
const obj = await resolvePost(ctx, id);
|
|
646
|
+
return obj instanceof Note ? obj : null;
|
|
647
|
+
},
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
// Article dispatcher
|
|
651
|
+
federation.setObjectDispatcher(
|
|
652
|
+
Article,
|
|
653
|
+
`${mountPath}/objects/article/{+id}`,
|
|
654
|
+
async (ctx, { id }) => {
|
|
655
|
+
const obj = await resolvePost(ctx, id);
|
|
656
|
+
return obj instanceof Article ? obj : null;
|
|
657
|
+
},
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
397
661
|
// --- Helpers ---
|
|
398
662
|
|
|
399
663
|
async function getProfile(collections) {
|
package/lib/inbox-listeners.js
CHANGED
package/locales/en.json
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
"followers": "Followers",
|
|
5
5
|
"following": "Following",
|
|
6
6
|
"activities": "Activity log",
|
|
7
|
+
"featured": "Pinned Posts",
|
|
8
|
+
"featuredTags": "Featured Tags",
|
|
7
9
|
"recentActivity": "Recent activity",
|
|
8
10
|
"noActivity": "No activity yet. Once your actor is federated, interactions will appear here.",
|
|
9
11
|
"noFollowers": "No followers yet.",
|
|
@@ -36,6 +38,8 @@
|
|
|
36
38
|
"imageHint": "URL to a banner image shown at the top of your profile",
|
|
37
39
|
"manualApprovalLabel": "Manually approve followers",
|
|
38
40
|
"manualApprovalHint": "When enabled, follow requests require your approval before they take effect",
|
|
41
|
+
"authorizedFetchLabel": "Require authorized fetch (secure mode)",
|
|
42
|
+
"authorizedFetchHint": "When enabled, only servers with valid HTTP Signatures can fetch your actor and collections. This improves privacy but may reduce compatibility with some clients.",
|
|
39
43
|
"save": "Save profile",
|
|
40
44
|
"saved": "Profile saved. Changes are now visible to the fediverse."
|
|
41
45
|
},
|
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.23",
|
|
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",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"url": "https://github.com/rmdes/indiekit-endpoint-activitypub/issues"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@fedify/express": "^1.
|
|
41
|
-
"@fedify/fedify": "^1.10.
|
|
40
|
+
"@fedify/express": "^1.10.3",
|
|
41
|
+
"@fedify/fedify": "^1.10.3",
|
|
42
42
|
"@fedify/redis": "^1.10.3",
|
|
43
43
|
"@js-temporal/polyfill": "^0.5.0",
|
|
44
44
|
"express": "^5.0.0",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{% extends "document.njk" %}
|
|
2
|
+
|
|
3
|
+
{% from "heading/macro.njk" import heading with context %}
|
|
4
|
+
{% from "prose/macro.njk" import prose with context %}
|
|
5
|
+
|
|
6
|
+
{% block content %}
|
|
7
|
+
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
|
8
|
+
|
|
9
|
+
{% if tags.length > 0 %}
|
|
10
|
+
<table class="table">
|
|
11
|
+
<thead>
|
|
12
|
+
<tr>
|
|
13
|
+
<th>Tag</th>
|
|
14
|
+
<th>Added</th>
|
|
15
|
+
<th></th>
|
|
16
|
+
</tr>
|
|
17
|
+
</thead>
|
|
18
|
+
<tbody>
|
|
19
|
+
{% for item in tags %}
|
|
20
|
+
<tr>
|
|
21
|
+
<td>#{{ item.tag }}</td>
|
|
22
|
+
<td>{% if item.addedAt %}{{ item.addedAt | date("PPp") }}{% endif %}</td>
|
|
23
|
+
<td>
|
|
24
|
+
<form method="post" action="{{ mountPath }}/admin/tags/remove">
|
|
25
|
+
<input type="hidden" name="tag" value="{{ item.tag }}">
|
|
26
|
+
<button type="submit" class="button button--small">Remove</button>
|
|
27
|
+
</form>
|
|
28
|
+
</td>
|
|
29
|
+
</tr>
|
|
30
|
+
{% endfor %}
|
|
31
|
+
</tbody>
|
|
32
|
+
</table>
|
|
33
|
+
{% else %}
|
|
34
|
+
{{ prose({ text: "No featured tags yet. Add a hashtag to help others discover your content." }) }}
|
|
35
|
+
{% endif %}
|
|
36
|
+
|
|
37
|
+
<h2>Add a featured tag</h2>
|
|
38
|
+
<form method="post" action="{{ mountPath }}/admin/tags/add">
|
|
39
|
+
<label for="tag">Hashtag</label>
|
|
40
|
+
<input type="text" id="tag" name="tag" placeholder="indieweb" required>
|
|
41
|
+
<button type="submit" class="button">Add tag</button>
|
|
42
|
+
</form>
|
|
43
|
+
{% endblock %}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{% extends "document.njk" %}
|
|
2
|
+
|
|
3
|
+
{% from "heading/macro.njk" import heading with context %}
|
|
4
|
+
{% from "prose/macro.njk" import prose with context %}
|
|
5
|
+
|
|
6
|
+
{% block content %}
|
|
7
|
+
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
|
8
|
+
|
|
9
|
+
{% if pinned.length > 0 %}
|
|
10
|
+
<table class="table">
|
|
11
|
+
<thead>
|
|
12
|
+
<tr>
|
|
13
|
+
<th>Post</th>
|
|
14
|
+
<th>Type</th>
|
|
15
|
+
<th>Pinned</th>
|
|
16
|
+
<th></th>
|
|
17
|
+
</tr>
|
|
18
|
+
</thead>
|
|
19
|
+
<tbody>
|
|
20
|
+
{% for item in pinned %}
|
|
21
|
+
<tr>
|
|
22
|
+
<td><a href="{{ item.postUrl }}">{{ item.title }}</a></td>
|
|
23
|
+
<td>{{ item.postType }}</td>
|
|
24
|
+
<td>{% if item.pinnedAt %}{{ item.pinnedAt | date("PPp") }}{% endif %}</td>
|
|
25
|
+
<td>
|
|
26
|
+
<form method="post" action="{{ mountPath }}/admin/featured/unpin">
|
|
27
|
+
<input type="hidden" name="postUrl" value="{{ item.postUrl }}">
|
|
28
|
+
<button type="submit" class="button button--small">Unpin</button>
|
|
29
|
+
</form>
|
|
30
|
+
</td>
|
|
31
|
+
</tr>
|
|
32
|
+
{% endfor %}
|
|
33
|
+
</tbody>
|
|
34
|
+
</table>
|
|
35
|
+
{% else %}
|
|
36
|
+
{{ prose({ text: "No pinned posts yet." }) }}
|
|
37
|
+
{% endif %}
|
|
38
|
+
|
|
39
|
+
{% if canPin and availablePosts.length > 0 %}
|
|
40
|
+
<h2>Pin a post ({{ pinned.length }}/{{ maxPins }})</h2>
|
|
41
|
+
<form method="post" action="{{ mountPath }}/admin/featured/pin">
|
|
42
|
+
<select name="postUrl">
|
|
43
|
+
{% for post in availablePosts %}
|
|
44
|
+
<option value="{{ post.url }}">{{ post.title }} ({{ post.postType }})</option>
|
|
45
|
+
{% endfor %}
|
|
46
|
+
</select>
|
|
47
|
+
<button type="submit" class="button">Pin</button>
|
|
48
|
+
</form>
|
|
49
|
+
{% elif not canPin %}
|
|
50
|
+
{{ prose({ text: "Maximum of " + maxPins + " pinned posts reached. Unpin one to add another." }) }}
|
|
51
|
+
{% endif %}
|
|
52
|
+
{% endblock %}
|
|
@@ -69,6 +69,18 @@
|
|
|
69
69
|
values: ["true"] if profile.manuallyApprovesFollowers else []
|
|
70
70
|
}) }}
|
|
71
71
|
|
|
72
|
+
{{ checkboxes({
|
|
73
|
+
name: "authorizedFetch",
|
|
74
|
+
items: [
|
|
75
|
+
{
|
|
76
|
+
label: __("activitypub.profile.authorizedFetchLabel"),
|
|
77
|
+
value: "true",
|
|
78
|
+
hint: __("activitypub.profile.authorizedFetchHint")
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
values: ["true"] if profile.authorizedFetch else []
|
|
82
|
+
}) }}
|
|
83
|
+
|
|
72
84
|
{{ button({ text: __("activitypub.profile.save") }) }}
|
|
73
85
|
</form>
|
|
74
86
|
{% endblock %}
|