@rmdes/indiekit-endpoint-activitypub 1.0.28 → 1.1.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/assets/reader.css +884 -0
- package/index.js +172 -15
- package/lib/controllers/compose.js +323 -0
- package/lib/controllers/featured-tags.js +12 -2
- package/lib/controllers/featured.js +12 -2
- package/lib/controllers/interactions-boost.js +208 -0
- package/lib/controllers/interactions-like.js +231 -0
- package/lib/controllers/interactions.js +7 -0
- package/lib/controllers/moderation.js +294 -0
- package/lib/controllers/profile.js +27 -1
- package/lib/controllers/profile.remote.js +218 -0
- package/lib/controllers/reader.js +187 -0
- package/lib/csrf.js +49 -0
- package/lib/federation-setup.js +33 -2
- package/lib/inbox-listeners.js +217 -213
- package/lib/storage/moderation.js +180 -0
- package/lib/storage/notifications.js +132 -0
- package/lib/storage/timeline.js +210 -0
- package/lib/timeline-cleanup.js +88 -0
- package/lib/timeline-store.js +207 -0
- package/locales/en.json +92 -1
- package/package.json +3 -2
- package/views/activitypub-compose.njk +94 -0
- package/views/activitypub-moderation.njk +118 -0
- package/views/activitypub-notifications.njk +31 -0
- package/views/activitypub-profile.njk +98 -0
- package/views/activitypub-reader.njk +61 -0
- package/views/activitypub-remote-profile.njk +117 -0
- package/views/layouts/reader.njk +9 -0
- package/views/partials/ap-item-card.njk +157 -0
- package/views/partials/ap-item-media.njk +37 -0
- package/views/partials/ap-notification-card.njk +58 -0
package/index.js
CHANGED
|
@@ -9,6 +9,28 @@ import {
|
|
|
9
9
|
jf2ToAS2Activity,
|
|
10
10
|
} from "./lib/jf2-to-as2.js";
|
|
11
11
|
import { dashboardController } from "./lib/controllers/dashboard.js";
|
|
12
|
+
import {
|
|
13
|
+
readerController,
|
|
14
|
+
notificationsController,
|
|
15
|
+
composeController,
|
|
16
|
+
submitComposeController,
|
|
17
|
+
remoteProfileController,
|
|
18
|
+
followController,
|
|
19
|
+
unfollowController,
|
|
20
|
+
} from "./lib/controllers/reader.js";
|
|
21
|
+
import {
|
|
22
|
+
likeController,
|
|
23
|
+
unlikeController,
|
|
24
|
+
boostController,
|
|
25
|
+
unboostController,
|
|
26
|
+
} from "./lib/controllers/interactions.js";
|
|
27
|
+
import {
|
|
28
|
+
muteController,
|
|
29
|
+
unmuteController,
|
|
30
|
+
blockController,
|
|
31
|
+
unblockController,
|
|
32
|
+
moderationController,
|
|
33
|
+
} from "./lib/controllers/moderation.js";
|
|
12
34
|
import { followersController } from "./lib/controllers/followers.js";
|
|
13
35
|
import { followingController } from "./lib/controllers/following.js";
|
|
14
36
|
import { activitiesController } from "./lib/controllers/activities.js";
|
|
@@ -38,6 +60,7 @@ import {
|
|
|
38
60
|
} from "./lib/controllers/refollow.js";
|
|
39
61
|
import { startBatchRefollow } from "./lib/batch-refollow.js";
|
|
40
62
|
import { logActivity } from "./lib/activity-log.js";
|
|
63
|
+
import { scheduleCleanup } from "./lib/timeline-cleanup.js";
|
|
41
64
|
|
|
42
65
|
const defaults = {
|
|
43
66
|
mountPath: "/activitypub",
|
|
@@ -54,6 +77,7 @@ const defaults = {
|
|
|
54
77
|
redisUrl: "",
|
|
55
78
|
parallelWorkers: 5,
|
|
56
79
|
actorType: "Person",
|
|
80
|
+
timelineRetention: 1000,
|
|
57
81
|
};
|
|
58
82
|
|
|
59
83
|
export default class ActivityPubEndpoint {
|
|
@@ -72,8 +96,8 @@ export default class ActivityPubEndpoint {
|
|
|
72
96
|
|
|
73
97
|
get navigationItems() {
|
|
74
98
|
return {
|
|
75
|
-
href: this.options.mountPath
|
|
76
|
-
text: "activitypub.title",
|
|
99
|
+
href: `${this.options.mountPath}/admin/reader`,
|
|
100
|
+
text: "activitypub.reader.title",
|
|
77
101
|
requiresDatabase: true,
|
|
78
102
|
};
|
|
79
103
|
}
|
|
@@ -145,17 +169,33 @@ export default class ActivityPubEndpoint {
|
|
|
145
169
|
const mp = this.options.mountPath;
|
|
146
170
|
|
|
147
171
|
router.get("/", dashboardController(mp));
|
|
172
|
+
router.get("/admin/reader", readerController(mp));
|
|
173
|
+
router.get("/admin/reader/notifications", notificationsController(mp));
|
|
174
|
+
router.get("/admin/reader/compose", composeController(mp, this));
|
|
175
|
+
router.post("/admin/reader/compose", submitComposeController(mp, this));
|
|
176
|
+
router.post("/admin/reader/like", likeController(mp, this));
|
|
177
|
+
router.post("/admin/reader/unlike", unlikeController(mp, this));
|
|
178
|
+
router.post("/admin/reader/boost", boostController(mp, this));
|
|
179
|
+
router.post("/admin/reader/unboost", unboostController(mp, this));
|
|
180
|
+
router.get("/admin/reader/profile", remoteProfileController(mp, this));
|
|
181
|
+
router.post("/admin/reader/follow", followController(mp, this));
|
|
182
|
+
router.post("/admin/reader/unfollow", unfollowController(mp, this));
|
|
183
|
+
router.get("/admin/reader/moderation", moderationController(mp));
|
|
184
|
+
router.post("/admin/reader/mute", muteController(mp, this));
|
|
185
|
+
router.post("/admin/reader/unmute", unmuteController(mp, this));
|
|
186
|
+
router.post("/admin/reader/block", blockController(mp, this));
|
|
187
|
+
router.post("/admin/reader/unblock", unblockController(mp, this));
|
|
148
188
|
router.get("/admin/followers", followersController(mp));
|
|
149
189
|
router.get("/admin/following", followingController(mp));
|
|
150
190
|
router.get("/admin/activities", activitiesController(mp));
|
|
151
191
|
router.get("/admin/featured", featuredGetController(mp));
|
|
152
|
-
router.post("/admin/featured/pin", featuredPinController(mp));
|
|
153
|
-
router.post("/admin/featured/unpin", featuredUnpinController(mp));
|
|
192
|
+
router.post("/admin/featured/pin", featuredPinController(mp, this));
|
|
193
|
+
router.post("/admin/featured/unpin", featuredUnpinController(mp, this));
|
|
154
194
|
router.get("/admin/tags", featuredTagsGetController(mp));
|
|
155
|
-
router.post("/admin/tags/add", featuredTagsAddController(mp));
|
|
156
|
-
router.post("/admin/tags/remove", featuredTagsRemoveController(mp));
|
|
195
|
+
router.post("/admin/tags/add", featuredTagsAddController(mp, this));
|
|
196
|
+
router.post("/admin/tags/remove", featuredTagsRemoveController(mp, this));
|
|
157
197
|
router.get("/admin/profile", profileGetController(mp));
|
|
158
|
-
router.post("/admin/profile", profilePostController(mp));
|
|
198
|
+
router.post("/admin/profile", profilePostController(mp, this));
|
|
159
199
|
router.get("/admin/migrate", migrateGetController(mp, this.options));
|
|
160
200
|
router.post("/admin/migrate", migratePostController(mp, this.options));
|
|
161
201
|
router.post(
|
|
@@ -483,7 +523,7 @@ export default class ActivityPubEndpoint {
|
|
|
483
523
|
inbox,
|
|
484
524
|
sharedInbox,
|
|
485
525
|
followedAt: new Date().toISOString(),
|
|
486
|
-
source: "
|
|
526
|
+
source: "reader",
|
|
487
527
|
},
|
|
488
528
|
},
|
|
489
529
|
{ upsert: true },
|
|
@@ -593,6 +633,59 @@ export default class ActivityPubEndpoint {
|
|
|
593
633
|
}
|
|
594
634
|
}
|
|
595
635
|
|
|
636
|
+
/**
|
|
637
|
+
* Send an Update(Person) activity to all followers so remote servers
|
|
638
|
+
* re-fetch the actor object (picking up profile changes, new featured
|
|
639
|
+
* collections, attachments, etc.).
|
|
640
|
+
*/
|
|
641
|
+
async broadcastActorUpdate() {
|
|
642
|
+
if (!this._federation) return;
|
|
643
|
+
|
|
644
|
+
try {
|
|
645
|
+
const { Update } = await import("@fedify/fedify");
|
|
646
|
+
const handle = this.options.actor.handle;
|
|
647
|
+
const ctx = this._federation.createContext(
|
|
648
|
+
new URL(this._publicationUrl),
|
|
649
|
+
{ handle, publicationUrl: this._publicationUrl },
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
// Retrieve the full actor from the dispatcher (same object remote
|
|
653
|
+
// servers will get when they re-fetch the actor URL)
|
|
654
|
+
const actor = await ctx.getActor(handle);
|
|
655
|
+
if (!actor) {
|
|
656
|
+
console.warn("[ActivityPub] broadcastActorUpdate: could not build actor");
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const update = new Update({
|
|
661
|
+
actor: ctx.getActorUri(handle),
|
|
662
|
+
object: actor,
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
await ctx.sendActivity(
|
|
666
|
+
{ identifier: handle },
|
|
667
|
+
"followers",
|
|
668
|
+
update,
|
|
669
|
+
{ preferSharedInbox: true },
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
console.info("[ActivityPub] Sent Update(Person) to followers");
|
|
673
|
+
|
|
674
|
+
await logActivity(this._collections.ap_activities, {
|
|
675
|
+
direction: "outbound",
|
|
676
|
+
type: "Update",
|
|
677
|
+
actorUrl: this._publicationUrl,
|
|
678
|
+
objectUrl: this._getActorUrl(),
|
|
679
|
+
summary: "Sent Update(Person) to followers",
|
|
680
|
+
}).catch(() => {});
|
|
681
|
+
} catch (error) {
|
|
682
|
+
console.error(
|
|
683
|
+
"[ActivityPub] broadcastActorUpdate failed:",
|
|
684
|
+
error.message,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
596
689
|
/**
|
|
597
690
|
* Build the full actor URL from config.
|
|
598
691
|
* @returns {string}
|
|
@@ -619,6 +712,12 @@ export default class ActivityPubEndpoint {
|
|
|
619
712
|
Indiekit.addCollection("ap_profile");
|
|
620
713
|
Indiekit.addCollection("ap_featured");
|
|
621
714
|
Indiekit.addCollection("ap_featured_tags");
|
|
715
|
+
// Reader collections
|
|
716
|
+
Indiekit.addCollection("ap_timeline");
|
|
717
|
+
Indiekit.addCollection("ap_notifications");
|
|
718
|
+
Indiekit.addCollection("ap_muted");
|
|
719
|
+
Indiekit.addCollection("ap_blocked");
|
|
720
|
+
Indiekit.addCollection("ap_interactions");
|
|
622
721
|
|
|
623
722
|
// Store collection references (posts resolved lazily)
|
|
624
723
|
const indiekitCollections = Indiekit.collections;
|
|
@@ -631,16 +730,15 @@ export default class ActivityPubEndpoint {
|
|
|
631
730
|
ap_profile: indiekitCollections.get("ap_profile"),
|
|
632
731
|
ap_featured: indiekitCollections.get("ap_featured"),
|
|
633
732
|
ap_featured_tags: indiekitCollections.get("ap_featured_tags"),
|
|
733
|
+
// Reader collections
|
|
734
|
+
ap_timeline: indiekitCollections.get("ap_timeline"),
|
|
735
|
+
ap_notifications: indiekitCollections.get("ap_notifications"),
|
|
736
|
+
ap_muted: indiekitCollections.get("ap_muted"),
|
|
737
|
+
ap_blocked: indiekitCollections.get("ap_blocked"),
|
|
738
|
+
ap_interactions: indiekitCollections.get("ap_interactions"),
|
|
634
739
|
get posts() {
|
|
635
740
|
return indiekitCollections.get("posts");
|
|
636
741
|
},
|
|
637
|
-
// Lazy access to Microsub collections (may not exist if plugin not loaded)
|
|
638
|
-
get microsub_items() {
|
|
639
|
-
return indiekitCollections.get("microsub_items");
|
|
640
|
-
},
|
|
641
|
-
get microsub_channels() {
|
|
642
|
-
return indiekitCollections.get("microsub_channels");
|
|
643
|
-
},
|
|
644
742
|
_publicationUrl: this._publicationUrl,
|
|
645
743
|
};
|
|
646
744
|
|
|
@@ -675,6 +773,60 @@ export default class ActivityPubEndpoint {
|
|
|
675
773
|
{ background: true },
|
|
676
774
|
);
|
|
677
775
|
|
|
776
|
+
// Reader indexes (timeline, notifications, moderation, interactions)
|
|
777
|
+
this._collections.ap_timeline.createIndex(
|
|
778
|
+
{ uid: 1 },
|
|
779
|
+
{ unique: true, background: true },
|
|
780
|
+
);
|
|
781
|
+
this._collections.ap_timeline.createIndex(
|
|
782
|
+
{ published: -1 },
|
|
783
|
+
{ background: true },
|
|
784
|
+
);
|
|
785
|
+
this._collections.ap_timeline.createIndex(
|
|
786
|
+
{ "author.url": 1 },
|
|
787
|
+
{ background: true },
|
|
788
|
+
);
|
|
789
|
+
this._collections.ap_timeline.createIndex(
|
|
790
|
+
{ type: 1, published: -1 },
|
|
791
|
+
{ background: true },
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
this._collections.ap_notifications.createIndex(
|
|
795
|
+
{ uid: 1 },
|
|
796
|
+
{ unique: true, background: true },
|
|
797
|
+
);
|
|
798
|
+
this._collections.ap_notifications.createIndex(
|
|
799
|
+
{ published: -1 },
|
|
800
|
+
{ background: true },
|
|
801
|
+
);
|
|
802
|
+
this._collections.ap_notifications.createIndex(
|
|
803
|
+
{ read: 1 },
|
|
804
|
+
{ background: true },
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
this._collections.ap_muted.createIndex(
|
|
808
|
+
{ url: 1 },
|
|
809
|
+
{ unique: true, sparse: true, background: true },
|
|
810
|
+
);
|
|
811
|
+
this._collections.ap_muted.createIndex(
|
|
812
|
+
{ keyword: 1 },
|
|
813
|
+
{ unique: true, sparse: true, background: true },
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
this._collections.ap_blocked.createIndex(
|
|
817
|
+
{ url: 1 },
|
|
818
|
+
{ unique: true, background: true },
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
this._collections.ap_interactions.createIndex(
|
|
822
|
+
{ objectUrl: 1, type: 1 },
|
|
823
|
+
{ unique: true, background: true },
|
|
824
|
+
);
|
|
825
|
+
this._collections.ap_interactions.createIndex(
|
|
826
|
+
{ type: 1 },
|
|
827
|
+
{ background: true },
|
|
828
|
+
);
|
|
829
|
+
|
|
678
830
|
// Seed actor profile from config on first run
|
|
679
831
|
this._seedProfile().catch((error) => {
|
|
680
832
|
console.warn("[ActivityPub] Profile seed failed:", error.message);
|
|
@@ -720,6 +872,11 @@ export default class ActivityPubEndpoint {
|
|
|
720
872
|
console.error("[ActivityPub] Batch refollow start failed:", error.message);
|
|
721
873
|
});
|
|
722
874
|
}, 10_000);
|
|
875
|
+
|
|
876
|
+
// Schedule timeline retention cleanup (runs on startup + every 24h)
|
|
877
|
+
if (this.options.timelineRetention > 0) {
|
|
878
|
+
scheduleCleanup(this._collections, this.options.timelineRetention);
|
|
879
|
+
}
|
|
723
880
|
}
|
|
724
881
|
|
|
725
882
|
/**
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose controllers — reply form via Micropub or direct AP.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Temporal } from "@js-temporal/polyfill";
|
|
6
|
+
import { getTimelineItem } from "../storage/timeline.js";
|
|
7
|
+
import { getToken, validateToken } from "../csrf.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fetch syndication targets from the Micropub config endpoint.
|
|
11
|
+
* @param {object} application - Indiekit application locals
|
|
12
|
+
* @param {string} token - Session access token
|
|
13
|
+
* @returns {Promise<Array>}
|
|
14
|
+
*/
|
|
15
|
+
async function getSyndicationTargets(application, token) {
|
|
16
|
+
try {
|
|
17
|
+
const micropubEndpoint = application.micropubEndpoint;
|
|
18
|
+
|
|
19
|
+
if (!micropubEndpoint) return [];
|
|
20
|
+
|
|
21
|
+
const micropubUrl = micropubEndpoint.startsWith("http")
|
|
22
|
+
? micropubEndpoint
|
|
23
|
+
: new URL(micropubEndpoint, application.url).href;
|
|
24
|
+
|
|
25
|
+
const configUrl = `${micropubUrl}?q=config`;
|
|
26
|
+
const configResponse = await fetch(configUrl, {
|
|
27
|
+
headers: {
|
|
28
|
+
Authorization: `Bearer ${token}`,
|
|
29
|
+
Accept: "application/json",
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (configResponse.ok) {
|
|
34
|
+
const config = await configResponse.json();
|
|
35
|
+
return config["syndicate-to"] || [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return [];
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* GET /admin/reader/compose — Show compose form.
|
|
46
|
+
* @param {string} mountPath - Plugin mount path
|
|
47
|
+
* @param {object} plugin - ActivityPub plugin instance
|
|
48
|
+
*/
|
|
49
|
+
export function composeController(mountPath, plugin) {
|
|
50
|
+
return async (request, response, next) => {
|
|
51
|
+
try {
|
|
52
|
+
const { application } = request.app.locals;
|
|
53
|
+
const replyTo = request.query.replyTo || "";
|
|
54
|
+
|
|
55
|
+
// Fetch reply context (the post being replied to)
|
|
56
|
+
let replyContext = null;
|
|
57
|
+
|
|
58
|
+
if (replyTo) {
|
|
59
|
+
const collections = {
|
|
60
|
+
ap_timeline: application?.collections?.get("ap_timeline"),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Try to find the post in our timeline first
|
|
64
|
+
replyContext = await getTimelineItem(collections, replyTo);
|
|
65
|
+
|
|
66
|
+
// If not in timeline, try to look up remotely
|
|
67
|
+
if (!replyContext && plugin._federation) {
|
|
68
|
+
try {
|
|
69
|
+
const handle = plugin.options.actor.handle;
|
|
70
|
+
const ctx = plugin._federation.createContext(
|
|
71
|
+
new URL(plugin._publicationUrl),
|
|
72
|
+
{ handle, publicationUrl: plugin._publicationUrl },
|
|
73
|
+
);
|
|
74
|
+
const remoteObject = await ctx.lookupObject(new URL(replyTo));
|
|
75
|
+
|
|
76
|
+
if (remoteObject) {
|
|
77
|
+
let authorName = "";
|
|
78
|
+
let authorUrl = "";
|
|
79
|
+
|
|
80
|
+
if (typeof remoteObject.getAttributedTo === "function") {
|
|
81
|
+
const author = await remoteObject.getAttributedTo();
|
|
82
|
+
const actor = Array.isArray(author) ? author[0] : author;
|
|
83
|
+
|
|
84
|
+
if (actor) {
|
|
85
|
+
authorName =
|
|
86
|
+
actor.name?.toString() ||
|
|
87
|
+
actor.preferredUsername?.toString() ||
|
|
88
|
+
"";
|
|
89
|
+
authorUrl = actor.id?.href || "";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
replyContext = {
|
|
94
|
+
url: replyTo,
|
|
95
|
+
name: remoteObject.name?.toString() || "",
|
|
96
|
+
content: {
|
|
97
|
+
text:
|
|
98
|
+
remoteObject.content?.toString()?.slice(0, 300) || "",
|
|
99
|
+
},
|
|
100
|
+
author: { name: authorName, url: authorUrl },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// Could not resolve — form still works without context
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Fetch syndication targets for Micropub path
|
|
110
|
+
const token = request.session?.access_token;
|
|
111
|
+
const syndicationTargets = token
|
|
112
|
+
? await getSyndicationTargets(application, token)
|
|
113
|
+
: [];
|
|
114
|
+
|
|
115
|
+
const csrfToken = getToken(request.session);
|
|
116
|
+
|
|
117
|
+
response.render("activitypub-compose", {
|
|
118
|
+
title: response.locals.__("activitypub.compose.title"),
|
|
119
|
+
replyTo,
|
|
120
|
+
replyContext,
|
|
121
|
+
syndicationTargets,
|
|
122
|
+
csrfToken,
|
|
123
|
+
mountPath,
|
|
124
|
+
});
|
|
125
|
+
} catch (error) {
|
|
126
|
+
next(error);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* POST /admin/reader/compose — Submit reply via Micropub or direct AP.
|
|
133
|
+
* @param {string} mountPath - Plugin mount path
|
|
134
|
+
* @param {object} plugin - ActivityPub plugin instance
|
|
135
|
+
*/
|
|
136
|
+
export function submitComposeController(mountPath, plugin) {
|
|
137
|
+
return async (request, response, next) => {
|
|
138
|
+
try {
|
|
139
|
+
if (!validateToken(request)) {
|
|
140
|
+
return response.status(403).render("error", {
|
|
141
|
+
title: "Error",
|
|
142
|
+
content: "Invalid CSRF token",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const { application } = request.app.locals;
|
|
147
|
+
const { content, mode } = request.body;
|
|
148
|
+
const inReplyTo = request.body["in-reply-to"];
|
|
149
|
+
const syndicateTo = request.body["mp-syndicate-to"];
|
|
150
|
+
|
|
151
|
+
if (!content || !content.trim()) {
|
|
152
|
+
return response.status(400).render("error", {
|
|
153
|
+
title: "Error",
|
|
154
|
+
content: response.locals.__("activitypub.compose.errorEmpty"),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Quick reply — direct AP
|
|
159
|
+
if (mode === "quick") {
|
|
160
|
+
if (!plugin._federation) {
|
|
161
|
+
return response.status(503).render("error", {
|
|
162
|
+
title: "Error",
|
|
163
|
+
content: "Federation not initialized",
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const { Create, Note } = await import("@fedify/fedify");
|
|
168
|
+
const handle = plugin.options.actor.handle;
|
|
169
|
+
const ctx = plugin._federation.createContext(
|
|
170
|
+
new URL(plugin._publicationUrl),
|
|
171
|
+
{ handle, publicationUrl: plugin._publicationUrl },
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const noteId = `urn:uuid:${crypto.randomUUID()}`;
|
|
175
|
+
const actorUri = ctx.getActorUri(handle);
|
|
176
|
+
|
|
177
|
+
const note = new Note({
|
|
178
|
+
id: new URL(noteId),
|
|
179
|
+
attribution: actorUri,
|
|
180
|
+
content: content.trim(),
|
|
181
|
+
inReplyTo: inReplyTo ? new URL(inReplyTo) : undefined,
|
|
182
|
+
published: Temporal.Now.instant(),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const create = new Create({
|
|
186
|
+
id: new URL(`${noteId}#activity`),
|
|
187
|
+
actor: actorUri,
|
|
188
|
+
object: note,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Send to followers
|
|
192
|
+
await ctx.sendActivity({ identifier: handle }, "followers", create, {
|
|
193
|
+
preferSharedInbox: true,
|
|
194
|
+
syncCollection: true,
|
|
195
|
+
orderingKey: noteId,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// If replying, also send to the original author
|
|
199
|
+
if (inReplyTo) {
|
|
200
|
+
try {
|
|
201
|
+
const remoteObject = await ctx.lookupObject(new URL(inReplyTo));
|
|
202
|
+
|
|
203
|
+
if (
|
|
204
|
+
remoteObject &&
|
|
205
|
+
typeof remoteObject.getAttributedTo === "function"
|
|
206
|
+
) {
|
|
207
|
+
const author = await remoteObject.getAttributedTo();
|
|
208
|
+
const recipient = Array.isArray(author)
|
|
209
|
+
? author[0]
|
|
210
|
+
: author;
|
|
211
|
+
|
|
212
|
+
if (recipient) {
|
|
213
|
+
await ctx.sendActivity(
|
|
214
|
+
{ identifier: handle },
|
|
215
|
+
recipient,
|
|
216
|
+
create,
|
|
217
|
+
{ orderingKey: noteId },
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
// Non-critical — followers still got it
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
console.info(
|
|
227
|
+
`[ActivityPub] Sent quick reply${inReplyTo ? ` to ${inReplyTo}` : ""}`,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
return response.redirect(`${mountPath}/admin/reader`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Micropub path — post as blog reply
|
|
234
|
+
const micropubEndpoint = application.micropubEndpoint;
|
|
235
|
+
|
|
236
|
+
if (!micropubEndpoint) {
|
|
237
|
+
return response.status(500).render("error", {
|
|
238
|
+
title: "Error",
|
|
239
|
+
content: "Micropub endpoint not configured",
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const micropubUrl = micropubEndpoint.startsWith("http")
|
|
244
|
+
? micropubEndpoint
|
|
245
|
+
: new URL(micropubEndpoint, application.url).href;
|
|
246
|
+
|
|
247
|
+
const token = request.session?.access_token;
|
|
248
|
+
|
|
249
|
+
if (!token) {
|
|
250
|
+
return response.redirect(
|
|
251
|
+
"/session/login?redirect=" + request.originalUrl,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const micropubData = new URLSearchParams();
|
|
256
|
+
micropubData.append("h", "entry");
|
|
257
|
+
micropubData.append("content", content.trim());
|
|
258
|
+
|
|
259
|
+
if (inReplyTo) {
|
|
260
|
+
micropubData.append("in-reply-to", inReplyTo);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (syndicateTo) {
|
|
264
|
+
const targets = Array.isArray(syndicateTo)
|
|
265
|
+
? syndicateTo
|
|
266
|
+
: [syndicateTo];
|
|
267
|
+
|
|
268
|
+
for (const target of targets) {
|
|
269
|
+
micropubData.append("mp-syndicate-to", target);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const micropubResponse = await fetch(micropubUrl, {
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: {
|
|
276
|
+
Authorization: `Bearer ${token}`,
|
|
277
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
278
|
+
Accept: "application/json",
|
|
279
|
+
},
|
|
280
|
+
body: micropubData.toString(),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
if (
|
|
284
|
+
micropubResponse.ok ||
|
|
285
|
+
micropubResponse.status === 201 ||
|
|
286
|
+
micropubResponse.status === 202
|
|
287
|
+
) {
|
|
288
|
+
const location = micropubResponse.headers.get("Location");
|
|
289
|
+
console.info(
|
|
290
|
+
`[ActivityPub] Created blog reply via Micropub: ${location || "success"}`,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
return response.redirect(`${mountPath}/admin/reader`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const errorBody = await micropubResponse.text();
|
|
297
|
+
let errorMessage = `Micropub error: ${micropubResponse.statusText}`;
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const errorJson = JSON.parse(errorBody);
|
|
301
|
+
|
|
302
|
+
if (errorJson.error_description) {
|
|
303
|
+
errorMessage = String(errorJson.error_description);
|
|
304
|
+
} else if (errorJson.error) {
|
|
305
|
+
errorMessage = String(errorJson.error);
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
// Not JSON
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return response.status(micropubResponse.status).render("error", {
|
|
312
|
+
title: "Error",
|
|
313
|
+
content: errorMessage,
|
|
314
|
+
});
|
|
315
|
+
} catch (error) {
|
|
316
|
+
console.error("[ActivityPub] Compose submit failed:", error.message);
|
|
317
|
+
return response.status(500).render("error", {
|
|
318
|
+
title: "Error",
|
|
319
|
+
content: "Failed to create post. Please try again later.",
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
}
|
|
@@ -24,7 +24,7 @@ export function featuredTagsGetController(mountPath) {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export function featuredTagsAddController(mountPath) {
|
|
27
|
+
export function featuredTagsAddController(mountPath, plugin) {
|
|
28
28
|
return async (request, response, next) => {
|
|
29
29
|
try {
|
|
30
30
|
const { application } = request.app.locals;
|
|
@@ -44,6 +44,11 @@ export function featuredTagsAddController(mountPath) {
|
|
|
44
44
|
{ upsert: true },
|
|
45
45
|
);
|
|
46
46
|
|
|
47
|
+
// Notify followers so they re-fetch featured tags
|
|
48
|
+
if (plugin?.broadcastActorUpdate) {
|
|
49
|
+
plugin.broadcastActorUpdate().catch(() => {});
|
|
50
|
+
}
|
|
51
|
+
|
|
47
52
|
response.redirect(`${mountPath}/admin/tags`);
|
|
48
53
|
} catch (error) {
|
|
49
54
|
next(error);
|
|
@@ -51,7 +56,7 @@ export function featuredTagsAddController(mountPath) {
|
|
|
51
56
|
};
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
export function featuredTagsRemoveController(mountPath) {
|
|
59
|
+
export function featuredTagsRemoveController(mountPath, plugin) {
|
|
55
60
|
return async (request, response, next) => {
|
|
56
61
|
try {
|
|
57
62
|
const { application } = request.app.locals;
|
|
@@ -63,6 +68,11 @@ export function featuredTagsRemoveController(mountPath) {
|
|
|
63
68
|
|
|
64
69
|
await collection.deleteOne({ tag });
|
|
65
70
|
|
|
71
|
+
// Notify followers so they re-fetch featured tags
|
|
72
|
+
if (plugin?.broadcastActorUpdate) {
|
|
73
|
+
plugin.broadcastActorUpdate().catch(() => {});
|
|
74
|
+
}
|
|
75
|
+
|
|
66
76
|
response.redirect(`${mountPath}/admin/tags`);
|
|
67
77
|
} catch (error) {
|
|
68
78
|
next(error);
|
|
@@ -69,7 +69,7 @@ export function featuredGetController(mountPath) {
|
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
export function featuredPinController(mountPath) {
|
|
72
|
+
export function featuredPinController(mountPath, plugin) {
|
|
73
73
|
return async (request, response, next) => {
|
|
74
74
|
try {
|
|
75
75
|
const { application } = request.app.locals;
|
|
@@ -90,6 +90,11 @@ export function featuredPinController(mountPath) {
|
|
|
90
90
|
{ upsert: true },
|
|
91
91
|
);
|
|
92
92
|
|
|
93
|
+
// Notify followers so they re-fetch the featured collection
|
|
94
|
+
if (plugin?.broadcastActorUpdate) {
|
|
95
|
+
plugin.broadcastActorUpdate().catch(() => {});
|
|
96
|
+
}
|
|
97
|
+
|
|
93
98
|
response.redirect(`${mountPath}/admin/featured`);
|
|
94
99
|
} catch (error) {
|
|
95
100
|
next(error);
|
|
@@ -97,7 +102,7 @@ export function featuredPinController(mountPath) {
|
|
|
97
102
|
};
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
export function featuredUnpinController(mountPath) {
|
|
105
|
+
export function featuredUnpinController(mountPath, plugin) {
|
|
101
106
|
return async (request, response, next) => {
|
|
102
107
|
try {
|
|
103
108
|
const { application } = request.app.locals;
|
|
@@ -109,6 +114,11 @@ export function featuredUnpinController(mountPath) {
|
|
|
109
114
|
|
|
110
115
|
await collection.deleteOne({ postUrl });
|
|
111
116
|
|
|
117
|
+
// Notify followers so they re-fetch the featured collection
|
|
118
|
+
if (plugin?.broadcastActorUpdate) {
|
|
119
|
+
plugin.broadcastActorUpdate().catch(() => {});
|
|
120
|
+
}
|
|
121
|
+
|
|
112
122
|
response.redirect(`${mountPath}/admin/featured`);
|
|
113
123
|
} catch (error) {
|
|
114
124
|
next(error);
|