@rmdes/indiekit-endpoint-activitypub 3.7.5 → 3.8.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/README.md +19 -1
- package/index.js +8 -1
- package/lib/controllers/follow-tag.js +85 -1
- package/lib/controllers/tag-timeline.js +7 -2
- package/lib/inbox-handlers.js +3 -20
- package/lib/inbox-listeners.js +3 -43
- package/lib/inbox-queue.js +0 -21
- package/lib/mastodon/routes/notifications.js +5 -7
- package/lib/mastodon/routes/stubs.js +24 -2
- package/lib/resolve-author.js +2 -4
- package/lib/storage/followed-tags.js +87 -3
- package/locales/en.json +5 -1
- package/package.json +1 -1
- package/views/activitypub-tag-timeline.njk +22 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @rmdes/indiekit-endpoint-activitypub
|
|
2
2
|
|
|
3
|
-
ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built on [Fedify](https://fedify.dev) 2.0. Makes your IndieWeb site a full fediverse actor — discoverable, followable, and interactive from Mastodon, Misskey, Pixelfed, and any ActivityPub-compatible platform.
|
|
3
|
+
ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built on [Fedify](https://fedify.dev) 2.0. Makes your IndieWeb site a full fediverse actor — discoverable, followable, and interactive from Mastodon, Misskey, Pixelfed, and any ActivityPub-compatible platform. Includes a Mastodon-compatible Client API so you can use Phanpy, Elk, Moshidon, Fedilab, and other Mastodon clients with your own AP instance.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -79,6 +79,23 @@ ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built o
|
|
|
79
79
|
- OpenTelemetry tracing for federation activity
|
|
80
80
|
- Real-time activity inspection
|
|
81
81
|
|
|
82
|
+
**Mastodon Client API** *(v3.0.0+)*
|
|
83
|
+
- Full Mastodon REST API compatibility — use Phanpy, Elk, Moshidon, Fedilab, or any Mastodon-compatible client
|
|
84
|
+
- OAuth2 with PKCE (S256) — app registration, authorization, token exchange
|
|
85
|
+
- HTML+JS redirect for native Android apps (Chrome Custom Tabs block 302 to custom URI schemes)
|
|
86
|
+
- Home, public, and hashtag timelines with chronological published-date pagination
|
|
87
|
+
- Status creation via Micropub pipeline — posts flow through Indiekit → content file → AP syndication
|
|
88
|
+
- URL auto-linkification and @mention extraction in posted content
|
|
89
|
+
- Thread context (ancestors + descendants)
|
|
90
|
+
- Remote profile resolution via Fedify WebFinger with follower/following/post counts from AP collections
|
|
91
|
+
- Account stats enrichment — embedded account data in timeline responses includes real counts
|
|
92
|
+
- Favourite, boost, bookmark interactions federated via Fedify AP activities
|
|
93
|
+
- Notifications with type filtering
|
|
94
|
+
- Search across accounts, statuses, and hashtags with remote resolution
|
|
95
|
+
- Domain blocks API
|
|
96
|
+
- Timeline backfill from posts collection on startup (bookmarks, likes, reposts get synthesized content)
|
|
97
|
+
- In-memory account stats cache (500 entries, 1h TTL) for performance
|
|
98
|
+
|
|
82
99
|
**Admin UI**
|
|
83
100
|
- Dashboard with follower/following counts and recent activity
|
|
84
101
|
- Profile editor (name, bio, avatar, header, profile links with rel="me" verification)
|
|
@@ -86,6 +103,7 @@ ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built o
|
|
|
86
103
|
- Featured tags (hashtag collection)
|
|
87
104
|
- Activity log (inbound/outbound)
|
|
88
105
|
- Follower and following lists with source tracking
|
|
106
|
+
- Federation management page with moderation overview (blocked servers, blocked accounts, muted)
|
|
89
107
|
|
|
90
108
|
## Requirements
|
|
91
109
|
|
package/index.js
CHANGED
|
@@ -79,7 +79,12 @@ import {
|
|
|
79
79
|
instanceCheckApiController,
|
|
80
80
|
popularAccountsApiController,
|
|
81
81
|
} from "./lib/controllers/explore.js";
|
|
82
|
-
import {
|
|
82
|
+
import {
|
|
83
|
+
followTagController,
|
|
84
|
+
unfollowTagController,
|
|
85
|
+
followTagGloballyController,
|
|
86
|
+
unfollowTagGloballyController,
|
|
87
|
+
} from "./lib/controllers/follow-tag.js";
|
|
83
88
|
import {
|
|
84
89
|
listTabsController,
|
|
85
90
|
addTabController,
|
|
@@ -296,6 +301,8 @@ export default class ActivityPubEndpoint {
|
|
|
296
301
|
router.patch("/admin/reader/api/tabs/reorder", reorderTabsController(mp));
|
|
297
302
|
router.post("/admin/reader/follow-tag", followTagController(mp));
|
|
298
303
|
router.post("/admin/reader/unfollow-tag", unfollowTagController(mp));
|
|
304
|
+
router.post("/admin/reader/follow-tag-global", followTagGloballyController(mp, this));
|
|
305
|
+
router.post("/admin/reader/unfollow-tag-global", unfollowTagGloballyController(mp, this));
|
|
299
306
|
router.get("/admin/reader/notifications", notificationsController(mp));
|
|
300
307
|
router.post("/admin/reader/notifications/mark-read", markAllNotificationsReadController(mp));
|
|
301
308
|
router.post("/admin/reader/notifications/clear", clearAllNotificationsController(mp));
|
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { validateToken } from "../csrf.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
followTag,
|
|
8
|
+
unfollowTag,
|
|
9
|
+
setGlobalFollow,
|
|
10
|
+
removeGlobalFollow,
|
|
11
|
+
getTagsPubActorUrl,
|
|
12
|
+
} from "../storage/followed-tags.js";
|
|
7
13
|
|
|
8
14
|
export function followTagController(mountPath) {
|
|
9
15
|
return async (request, response, next) => {
|
|
@@ -60,3 +66,81 @@ export function unfollowTagController(mountPath) {
|
|
|
60
66
|
}
|
|
61
67
|
};
|
|
62
68
|
}
|
|
69
|
+
|
|
70
|
+
export function followTagGloballyController(mountPath, plugin) {
|
|
71
|
+
return async (request, response, next) => {
|
|
72
|
+
try {
|
|
73
|
+
const { application } = request.app.locals;
|
|
74
|
+
|
|
75
|
+
// CSRF validation
|
|
76
|
+
if (!validateToken(request)) {
|
|
77
|
+
return response.status(403).json({ error: "Invalid CSRF token" });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const tag = typeof request.body.tag === "string" ? request.body.tag.trim() : "";
|
|
81
|
+
if (!tag) {
|
|
82
|
+
return response.redirect(`${mountPath}/admin/reader`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const actorUrl = getTagsPubActorUrl(tag);
|
|
86
|
+
|
|
87
|
+
// Send AP Follow activity via Fedify
|
|
88
|
+
const result = await plugin.followActor(actorUrl);
|
|
89
|
+
if (!result.ok) {
|
|
90
|
+
const errorMsg = encodeURIComponent(result.error || "Follow failed");
|
|
91
|
+
return response.redirect(
|
|
92
|
+
`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}&error=${errorMsg}`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Store global follow state
|
|
97
|
+
const collections = {
|
|
98
|
+
ap_followed_tags: application?.collections?.get("ap_followed_tags"),
|
|
99
|
+
};
|
|
100
|
+
await setGlobalFollow(collections, tag, actorUrl);
|
|
101
|
+
|
|
102
|
+
return response.redirect(`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}`);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
next(error);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function unfollowTagGloballyController(mountPath, plugin) {
|
|
110
|
+
return async (request, response, next) => {
|
|
111
|
+
try {
|
|
112
|
+
const { application } = request.app.locals;
|
|
113
|
+
|
|
114
|
+
// CSRF validation
|
|
115
|
+
if (!validateToken(request)) {
|
|
116
|
+
return response.status(403).json({ error: "Invalid CSRF token" });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const tag = typeof request.body.tag === "string" ? request.body.tag.trim() : "";
|
|
120
|
+
if (!tag) {
|
|
121
|
+
return response.redirect(`${mountPath}/admin/reader`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const actorUrl = getTagsPubActorUrl(tag);
|
|
125
|
+
|
|
126
|
+
// Send AP Undo(Follow) activity via Fedify
|
|
127
|
+
const result = await plugin.unfollowActor(actorUrl);
|
|
128
|
+
if (!result.ok) {
|
|
129
|
+
const errorMsg = encodeURIComponent(result.error || "Unfollow failed");
|
|
130
|
+
return response.redirect(
|
|
131
|
+
`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}&error=${errorMsg}`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Remove global follow state
|
|
136
|
+
const collections = {
|
|
137
|
+
ap_followed_tags: application?.collections?.get("ap_followed_tags"),
|
|
138
|
+
};
|
|
139
|
+
await removeGlobalFollow(collections, tag);
|
|
140
|
+
|
|
141
|
+
return response.redirect(`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}`);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
next(error);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -45,17 +45,20 @@ export function tagTimelineController(mountPath) {
|
|
|
45
45
|
interactionsCol: application?.collections?.get("ap_interactions"),
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
// Check if this hashtag is followed
|
|
48
|
+
// Check if this hashtag is followed (local and/or global)
|
|
49
49
|
const followedTagsCol = application?.collections?.get("ap_followed_tags");
|
|
50
50
|
let isFollowed = false;
|
|
51
|
+
let isGloballyFollowed = false;
|
|
51
52
|
if (followedTagsCol) {
|
|
52
53
|
const followed = await followedTagsCol.findOne({
|
|
53
54
|
tag: { $regex: new RegExp(`^${tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") }
|
|
54
55
|
});
|
|
55
|
-
isFollowed = !!followed;
|
|
56
|
+
isFollowed = !!(followed?.followedAt);
|
|
57
|
+
isGloballyFollowed = !!(followed?.globalFollow);
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
const csrfToken = getToken(request.session);
|
|
61
|
+
const error = typeof request.query.error === "string" ? request.query.error : null;
|
|
59
62
|
|
|
60
63
|
response.render("activitypub-tag-timeline", {
|
|
61
64
|
title: `#${tag}`,
|
|
@@ -68,6 +71,8 @@ export function tagTimelineController(mountPath) {
|
|
|
68
71
|
csrfToken,
|
|
69
72
|
mountPath,
|
|
70
73
|
isFollowed,
|
|
74
|
+
isGloballyFollowed,
|
|
75
|
+
error,
|
|
71
76
|
});
|
|
72
77
|
} catch (error) {
|
|
73
78
|
next(error);
|
package/lib/inbox-handlers.js
CHANGED
|
@@ -984,24 +984,6 @@ export async function handleUpdate(item, collections, ctx, handle) {
|
|
|
984
984
|
|
|
985
985
|
const existing = await collections.ap_followers.findOne({ actorUrl });
|
|
986
986
|
if (existing) {
|
|
987
|
-
let updatedAvatar = "";
|
|
988
|
-
try {
|
|
989
|
-
updatedAvatar = actorObj.icon
|
|
990
|
-
? (await actorObj.icon)?.url?.href || ""
|
|
991
|
-
: "";
|
|
992
|
-
} catch {
|
|
993
|
-
// Icon fetch failed
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
let updatedBanner = "";
|
|
997
|
-
try {
|
|
998
|
-
updatedBanner = actorObj.image
|
|
999
|
-
? (await actorObj.image)?.url?.href || ""
|
|
1000
|
-
: "";
|
|
1001
|
-
} catch {
|
|
1002
|
-
// Banner fetch failed
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
987
|
await collections.ap_followers.updateOne(
|
|
1006
988
|
{ actorUrl },
|
|
1007
989
|
{
|
|
@@ -1011,8 +993,9 @@ export async function handleUpdate(item, collections, ctx, handle) {
|
|
|
1011
993
|
actorObj.preferredUsername?.toString() ||
|
|
1012
994
|
actorUrl,
|
|
1013
995
|
handle: actorObj.preferredUsername?.toString() || "",
|
|
1014
|
-
avatar:
|
|
1015
|
-
|
|
996
|
+
avatar: actorObj.icon
|
|
997
|
+
? (await actorObj.icon)?.url?.href || ""
|
|
998
|
+
: "",
|
|
1016
999
|
updatedAt: new Date().toISOString(),
|
|
1017
1000
|
},
|
|
1018
1001
|
},
|
package/lib/inbox-listeners.js
CHANGED
|
@@ -46,31 +46,12 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
46
46
|
|
|
47
47
|
const getAuthLoader = (ctx) => ctx.getDocumentLoader({ identifier: handle });
|
|
48
48
|
|
|
49
|
-
// Diagnostic: track listener invocations to detect federation stalls
|
|
50
|
-
const _diag = { count: 0, lastType: "", lastActor: "", lastAt: 0 };
|
|
51
|
-
const _diagInterval = setInterval(() => {
|
|
52
|
-
if (_diag.count > 0) {
|
|
53
|
-
console.info(`[inbox-diag] ${_diag.count} activities received in last 5min (last: ${_diag.lastType} from ${_diag.lastActor})`);
|
|
54
|
-
_diag.count = 0;
|
|
55
|
-
}
|
|
56
|
-
}, 5 * 60 * 1000);
|
|
57
|
-
// Prevent timer from keeping the process alive
|
|
58
|
-
_diagInterval.unref?.();
|
|
59
|
-
|
|
60
|
-
const _diagTrack = (type, actorUrl) => {
|
|
61
|
-
_diag.count++;
|
|
62
|
-
_diag.lastType = type;
|
|
63
|
-
_diag.lastActor = actorUrl?.split("/").pop() || "?";
|
|
64
|
-
_diag.lastAt = Date.now();
|
|
65
|
-
};
|
|
66
|
-
|
|
67
49
|
inboxChain
|
|
68
50
|
// ── Follow ──────────────────────────────────────────────────────
|
|
69
51
|
// Synchronous: Accept/Reject + follower storage (federation requirement)
|
|
70
52
|
// Async: notification + activity log
|
|
71
53
|
.on(Follow, async (ctx, follow) => {
|
|
72
54
|
const actorUrl = follow.actorId?.href || "";
|
|
73
|
-
_diagTrack("Follow", actorUrl);
|
|
74
55
|
if (await isServerBlocked(actorUrl, collections)) return;
|
|
75
56
|
await touchKeyFreshness(collections, actorUrl);
|
|
76
57
|
await resetDeliveryStrikes(collections, actorUrl);
|
|
@@ -85,30 +66,13 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
85
66
|
followerActor.preferredUsername?.toString() ||
|
|
86
67
|
followerUrl;
|
|
87
68
|
|
|
88
|
-
let followerAvatar = "";
|
|
89
|
-
try {
|
|
90
|
-
followerAvatar = followerActor.icon
|
|
91
|
-
? (await followerActor.icon)?.url?.href || ""
|
|
92
|
-
: "";
|
|
93
|
-
} catch {
|
|
94
|
-
// Icon fetch failed
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
let followerBanner = "";
|
|
98
|
-
try {
|
|
99
|
-
followerBanner = followerActor.image
|
|
100
|
-
? (await followerActor.image)?.url?.href || ""
|
|
101
|
-
: "";
|
|
102
|
-
} catch {
|
|
103
|
-
// Banner fetch failed
|
|
104
|
-
}
|
|
105
|
-
|
|
106
69
|
const followerData = {
|
|
107
70
|
actorUrl: followerUrl,
|
|
108
71
|
handle: followerActor.preferredUsername?.toString() || "",
|
|
109
72
|
name: followerName,
|
|
110
|
-
avatar:
|
|
111
|
-
|
|
73
|
+
avatar: followerActor.icon
|
|
74
|
+
? (await followerActor.icon)?.url?.href || ""
|
|
75
|
+
: "",
|
|
112
76
|
inbox: followerActor.inbox?.id?.href || "",
|
|
113
77
|
sharedInbox: followerActor.endpoints?.sharedInbox?.href || "",
|
|
114
78
|
};
|
|
@@ -245,7 +209,6 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
245
209
|
// ── Announce ────────────────────────────────────────────────────
|
|
246
210
|
.on(Announce, async (ctx, announce) => {
|
|
247
211
|
const actorUrl = announce.actorId?.href || "";
|
|
248
|
-
_diagTrack("Announce", actorUrl);
|
|
249
212
|
if (await isServerBlocked(actorUrl, collections)) return;
|
|
250
213
|
await touchKeyFreshness(collections, actorUrl);
|
|
251
214
|
await resetDeliveryStrikes(collections, actorUrl);
|
|
@@ -261,7 +224,6 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
261
224
|
// ── Create ──────────────────────────────────────────────────────
|
|
262
225
|
.on(Create, async (ctx, create) => {
|
|
263
226
|
const actorUrl = create.actorId?.href || "";
|
|
264
|
-
_diagTrack("Create", actorUrl);
|
|
265
227
|
if (await isServerBlocked(actorUrl, collections)) return;
|
|
266
228
|
await touchKeyFreshness(collections, actorUrl);
|
|
267
229
|
await resetDeliveryStrikes(collections, actorUrl);
|
|
@@ -312,7 +274,6 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
312
274
|
// ── Delete ──────────────────────────────────────────────────────
|
|
313
275
|
.on(Delete, async (ctx, del) => {
|
|
314
276
|
const actorUrl = del.actorId?.href || "";
|
|
315
|
-
_diagTrack("Delete", actorUrl);
|
|
316
277
|
if (await isServerBlocked(actorUrl, collections)) return;
|
|
317
278
|
await touchKeyFreshness(collections, actorUrl);
|
|
318
279
|
await resetDeliveryStrikes(collections, actorUrl);
|
|
@@ -342,7 +303,6 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
342
303
|
// ── Update ──────────────────────────────────────────────────────
|
|
343
304
|
.on(Update, async (ctx, update) => {
|
|
344
305
|
const actorUrl = update.actorId?.href || "";
|
|
345
|
-
_diagTrack("Update", actorUrl);
|
|
346
306
|
if (await isServerBlocked(actorUrl, collections)) return;
|
|
347
307
|
await touchKeyFreshness(collections, actorUrl);
|
|
348
308
|
await resetDeliveryStrikes(collections, actorUrl);
|
package/lib/inbox-queue.js
CHANGED
|
@@ -83,32 +83,11 @@ export async function enqueueActivity(collections, { activityType, actorUrl, obj
|
|
|
83
83
|
* @returns {NodeJS.Timeout} Interval ID (for cleanup)
|
|
84
84
|
*/
|
|
85
85
|
export function startInboxProcessor(collections, getCtx, handle) {
|
|
86
|
-
// Diagnostic: detect stuck processing items and log queue health
|
|
87
|
-
let _diagProcessed = 0;
|
|
88
|
-
const _diagInterval = setInterval(async () => {
|
|
89
|
-
try {
|
|
90
|
-
const stuck = await collections.ap_inbox_queue?.countDocuments({ status: "processing" }) || 0;
|
|
91
|
-
const pending = await collections.ap_inbox_queue?.countDocuments({ status: "pending" }) || 0;
|
|
92
|
-
if (stuck > 0 || _diagProcessed > 0 || pending > 10) {
|
|
93
|
-
console.info(`[inbox-queue-diag] processed=${_diagProcessed}/5min pending=${pending} stuck_processing=${stuck}`);
|
|
94
|
-
}
|
|
95
|
-
_diagProcessed = 0;
|
|
96
|
-
} catch { /* ignore */ }
|
|
97
|
-
}, 5 * 60 * 1000);
|
|
98
|
-
_diagInterval.unref?.();
|
|
99
|
-
|
|
100
86
|
const intervalId = setInterval(async () => {
|
|
101
87
|
try {
|
|
102
88
|
const ctx = getCtx();
|
|
103
89
|
if (ctx) {
|
|
104
|
-
const before = Date.now();
|
|
105
90
|
await processNextItem(collections, ctx, handle);
|
|
106
|
-
const elapsed = Date.now() - before;
|
|
107
|
-
if (elapsed > 0) _diagProcessed++;
|
|
108
|
-
// Warn if a single item takes too long (potential hang)
|
|
109
|
-
if (elapsed > 30_000) {
|
|
110
|
-
console.warn(`[inbox-queue-diag] slow item: ${elapsed}ms`);
|
|
111
|
-
}
|
|
112
91
|
}
|
|
113
92
|
} catch (error) {
|
|
114
93
|
console.error("[inbox-queue] Processor error:", error.message);
|
|
@@ -225,25 +225,23 @@ function resolveInternalTypes(mastodonTypes) {
|
|
|
225
225
|
async function batchFetchStatuses(collections, notifications) {
|
|
226
226
|
const statusMap = new Map();
|
|
227
227
|
|
|
228
|
-
|
|
229
|
-
// For mention/reply notifications, the url is the actual mention/reply post
|
|
230
|
-
const allUrls = [
|
|
228
|
+
const targetUrls = [
|
|
231
229
|
...new Set(
|
|
232
230
|
notifications
|
|
233
|
-
.
|
|
231
|
+
.map((n) => n.targetUrl)
|
|
234
232
|
.filter(Boolean),
|
|
235
233
|
),
|
|
236
234
|
];
|
|
237
235
|
|
|
238
|
-
if (
|
|
236
|
+
if (targetUrls.length === 0 || !collections.ap_timeline) {
|
|
239
237
|
return statusMap;
|
|
240
238
|
}
|
|
241
239
|
|
|
242
240
|
const items = await collections.ap_timeline
|
|
243
241
|
.find({
|
|
244
242
|
$or: [
|
|
245
|
-
{ uid: { $in:
|
|
246
|
-
{ url: { $in:
|
|
243
|
+
{ uid: { $in: targetUrls } },
|
|
244
|
+
{ url: { $in: targetUrls } },
|
|
247
245
|
],
|
|
248
246
|
})
|
|
249
247
|
.toArray();
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import express from "express";
|
|
22
22
|
import { serializeStatus } from "../entities/status.js";
|
|
23
23
|
import { parseLimit, buildPaginationQuery, setPaginationHeaders } from "../helpers/pagination.js";
|
|
24
|
+
import { getFollowedTagsWithState } from "../../storage/followed-tags.js";
|
|
24
25
|
|
|
25
26
|
const router = express.Router(); // eslint-disable-line new-cap
|
|
26
27
|
|
|
@@ -276,8 +277,29 @@ router.get("/api/v1/featured_tags", (req, res) => {
|
|
|
276
277
|
|
|
277
278
|
// ─── Followed tags ──────────────────────────────────────────────────────────
|
|
278
279
|
|
|
279
|
-
router.get("/api/v1/followed_tags", (req, res) => {
|
|
280
|
-
|
|
280
|
+
router.get("/api/v1/followed_tags", async (req, res, next) => {
|
|
281
|
+
try {
|
|
282
|
+
const collections = req.app.locals.mastodonCollections;
|
|
283
|
+
if (!collections?.ap_followed_tags) {
|
|
284
|
+
return res.json([]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
|
|
288
|
+
const publicationUrl = pluginOptions.publicationUrl || "";
|
|
289
|
+
const tags = await getFollowedTagsWithState({ ap_followed_tags: collections.ap_followed_tags });
|
|
290
|
+
|
|
291
|
+
const response = tags.map((doc) => ({
|
|
292
|
+
id: doc._id.toString(),
|
|
293
|
+
name: doc.tag,
|
|
294
|
+
url: `${publicationUrl.replace(/\/$/, "")}/tags/${doc.tag}`,
|
|
295
|
+
history: [],
|
|
296
|
+
following: true,
|
|
297
|
+
}));
|
|
298
|
+
|
|
299
|
+
res.json(response);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
next(error);
|
|
302
|
+
}
|
|
281
303
|
});
|
|
282
304
|
|
|
283
305
|
// ─── Suggestions ────────────────────────────────────────────────────────────
|
package/lib/resolve-author.js
CHANGED
|
@@ -91,11 +91,9 @@ export async function resolveAuthor(
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
// Strategy 2: Use author URL from timeline or notifications
|
|
94
|
-
// Support both Map (admin controllers) and plain object (Mastodon API)
|
|
95
94
|
if (collections) {
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
const ap_notifications = col("ap_notifications");
|
|
95
|
+
const ap_timeline = collections.get("ap_timeline");
|
|
96
|
+
const ap_notifications = collections.get("ap_notifications");
|
|
99
97
|
|
|
100
98
|
// Search timeline by both uid (canonical) and url (display)
|
|
101
99
|
let authorUrl = null;
|
|
@@ -15,6 +15,17 @@ export async function getFollowedTags(collections) {
|
|
|
15
15
|
return docs.map((d) => d.tag);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Get all followed hashtags with full state (local + global follow tracking)
|
|
20
|
+
* @param {object} collections - MongoDB collections
|
|
21
|
+
* @returns {Promise<Array<{tag: string, followedAt?: string, globalFollow?: boolean, globalActorUrl?: string}>>}
|
|
22
|
+
*/
|
|
23
|
+
export async function getFollowedTagsWithState(collections) {
|
|
24
|
+
const { ap_followed_tags } = collections;
|
|
25
|
+
if (!ap_followed_tags) return [];
|
|
26
|
+
return ap_followed_tags.find({}).sort({ followedAt: -1 }).toArray();
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
/**
|
|
19
30
|
* Follow a hashtag
|
|
20
31
|
* @param {object} collections - MongoDB collections
|
|
@@ -36,16 +47,31 @@ export async function followTag(collections, tag) {
|
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
/**
|
|
39
|
-
* Unfollow a hashtag
|
|
50
|
+
* Unfollow a hashtag locally.
|
|
51
|
+
* If a global follow (tags.pub) is active, preserves the document with global state intact.
|
|
52
|
+
* Only deletes the document entirely when no global follow is active.
|
|
40
53
|
* @param {object} collections - MongoDB collections
|
|
41
54
|
* @param {string} tag - Hashtag string (without # prefix)
|
|
42
|
-
* @returns {Promise<boolean>} true if removed, false if not found
|
|
55
|
+
* @returns {Promise<boolean>} true if removed/updated, false if not found
|
|
43
56
|
*/
|
|
44
57
|
export async function unfollowTag(collections, tag) {
|
|
45
58
|
const { ap_followed_tags } = collections;
|
|
46
59
|
const normalizedTag = tag.toLowerCase().trim().replace(/^#/, "");
|
|
47
60
|
if (!normalizedTag) return false;
|
|
48
61
|
|
|
62
|
+
// Check if a global follow is active before deleting
|
|
63
|
+
const existing = await ap_followed_tags.findOne({ tag: normalizedTag });
|
|
64
|
+
if (!existing) return false;
|
|
65
|
+
|
|
66
|
+
if (existing.globalFollow) {
|
|
67
|
+
// Preserve the document — only unset the local follow fields
|
|
68
|
+
await ap_followed_tags.updateOne(
|
|
69
|
+
{ tag: normalizedTag },
|
|
70
|
+
{ $unset: { followedAt: "" } }
|
|
71
|
+
);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
49
75
|
const result = await ap_followed_tags.deleteOne({ tag: normalizedTag });
|
|
50
76
|
return result.deletedCount > 0;
|
|
51
77
|
}
|
|
@@ -61,5 +87,63 @@ export async function isTagFollowed(collections, tag) {
|
|
|
61
87
|
if (!ap_followed_tags) return false;
|
|
62
88
|
const normalizedTag = tag.toLowerCase().trim().replace(/^#/, "");
|
|
63
89
|
const doc = await ap_followed_tags.findOne({ tag: normalizedTag });
|
|
64
|
-
return !!doc;
|
|
90
|
+
return !!(doc?.followedAt);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns the deterministic tags.pub actor URL for a hashtag.
|
|
95
|
+
* @param {string} tag - Hashtag string (without # prefix)
|
|
96
|
+
* @returns {string} Actor URL
|
|
97
|
+
*/
|
|
98
|
+
export function getTagsPubActorUrl(tag) {
|
|
99
|
+
return `https://tags.pub/user/${tag.toLowerCase().replace(/^#/, "")}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Set global follow state for a hashtag (upsert — works even with no local follow).
|
|
104
|
+
* @param {object} collections - MongoDB collections
|
|
105
|
+
* @param {string} tag - Hashtag string (without # prefix)
|
|
106
|
+
* @param {string} actorUrl - The tags.pub actor URL
|
|
107
|
+
* @returns {Promise<void>}
|
|
108
|
+
*/
|
|
109
|
+
export async function setGlobalFollow(collections, tag, actorUrl) {
|
|
110
|
+
const { ap_followed_tags } = collections;
|
|
111
|
+
const normalizedTag = tag.toLowerCase().trim().replace(/^#/, "");
|
|
112
|
+
if (!normalizedTag) return;
|
|
113
|
+
|
|
114
|
+
await ap_followed_tags.updateOne(
|
|
115
|
+
{ tag: normalizedTag },
|
|
116
|
+
{
|
|
117
|
+
$set: { globalFollow: true, globalActorUrl: actorUrl },
|
|
118
|
+
$setOnInsert: { tag: normalizedTag },
|
|
119
|
+
},
|
|
120
|
+
{ upsert: true }
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Remove global follow state for a hashtag.
|
|
126
|
+
* If no local follow exists (no followedAt), deletes the document entirely.
|
|
127
|
+
* @param {object} collections - MongoDB collections
|
|
128
|
+
* @param {string} tag - Hashtag string (without # prefix)
|
|
129
|
+
* @returns {Promise<void>}
|
|
130
|
+
*/
|
|
131
|
+
export async function removeGlobalFollow(collections, tag) {
|
|
132
|
+
const { ap_followed_tags } = collections;
|
|
133
|
+
const normalizedTag = tag.toLowerCase().trim().replace(/^#/, "");
|
|
134
|
+
if (!normalizedTag) return;
|
|
135
|
+
|
|
136
|
+
const existing = await ap_followed_tags.findOne({ tag: normalizedTag });
|
|
137
|
+
if (!existing) return;
|
|
138
|
+
|
|
139
|
+
if (existing.followedAt) {
|
|
140
|
+
// Local follow is still active — just unset the global fields
|
|
141
|
+
await ap_followed_tags.updateOne(
|
|
142
|
+
{ tag: normalizedTag },
|
|
143
|
+
{ $unset: { globalFollow: "", globalActorUrl: "" } }
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
// No local follow — delete the document entirely
|
|
147
|
+
await ap_followed_tags.deleteOne({ tag: normalizedTag });
|
|
148
|
+
}
|
|
65
149
|
}
|
package/locales/en.json
CHANGED
|
@@ -297,7 +297,11 @@
|
|
|
297
297
|
"noPosts": "No posts found with #%s in your timeline.",
|
|
298
298
|
"followTag": "Follow hashtag",
|
|
299
299
|
"unfollowTag": "Unfollow hashtag",
|
|
300
|
-
"following": "Following"
|
|
300
|
+
"following": "Following",
|
|
301
|
+
"followGlobally": "Follow globally via tags.pub",
|
|
302
|
+
"unfollowGlobally": "Unfollow global",
|
|
303
|
+
"globallyFollowing": "Following globally",
|
|
304
|
+
"globalFollowError": "Failed to follow globally: %s"
|
|
301
305
|
},
|
|
302
306
|
"pagination": {
|
|
303
307
|
"newer": "← Newer",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.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",
|
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
{# Tag header #}
|
|
5
5
|
<header class="ap-tag-header">
|
|
6
6
|
<div class="ap-tag-header__info">
|
|
7
|
-
<h2 class="ap-tag-header__title">#{{ hashtag }}</h2>
|
|
7
|
+
<h2 class="ap-tag-header__title">#{{ hashtag }}{% if isGloballyFollowed %} <span class="ap-tag-header__global-badge" title="{{ __('activitypub.reader.tagTimeline.globallyFollowing') }}">🌐</span>{% endif %}</h2>
|
|
8
8
|
<p class="ap-tag-header__count">
|
|
9
9
|
{{ __("activitypub.reader.tagTimeline.postsTagged", items.length) }}
|
|
10
10
|
</p>
|
|
11
11
|
</div>
|
|
12
12
|
<div class="ap-tag-header__actions">
|
|
13
|
+
{% if error %}
|
|
14
|
+
<p class="ap-tag-header__error">{{ __("activitypub.reader.tagTimeline.globalFollowError", error) }}</p>
|
|
15
|
+
{% endif %}
|
|
13
16
|
{% if isFollowed %}
|
|
14
17
|
<form action="{{ mountPath }}/admin/reader/unfollow-tag" method="post" class="ap-tag-header__follow-form">
|
|
15
18
|
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
@@ -27,6 +30,23 @@
|
|
|
27
30
|
</button>
|
|
28
31
|
</form>
|
|
29
32
|
{% endif %}
|
|
33
|
+
{% if isGloballyFollowed %}
|
|
34
|
+
<form action="{{ mountPath }}/admin/reader/unfollow-tag-global" method="post" class="ap-tag-header__follow-form">
|
|
35
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
36
|
+
<input type="hidden" name="tag" value="{{ hashtag }}">
|
|
37
|
+
<button type="submit" class="ap-tag-header__unfollow-btn ap-tag-header__unfollow-btn--global">
|
|
38
|
+
🌐 {{ __("activitypub.reader.tagTimeline.unfollowGlobally") }}
|
|
39
|
+
</button>
|
|
40
|
+
</form>
|
|
41
|
+
{% else %}
|
|
42
|
+
<form action="{{ mountPath }}/admin/reader/follow-tag-global" method="post" class="ap-tag-header__follow-form">
|
|
43
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
44
|
+
<input type="hidden" name="tag" value="{{ hashtag }}">
|
|
45
|
+
<button type="submit" class="ap-tag-header__follow-btn ap-tag-header__follow-btn--global">
|
|
46
|
+
🌐 {{ __("activitypub.reader.tagTimeline.followGlobally") }}
|
|
47
|
+
</button>
|
|
48
|
+
</form>
|
|
49
|
+
{% endif %}
|
|
30
50
|
<a href="{{ mountPath }}/admin/reader" class="ap-tag-header__back">
|
|
31
51
|
← {{ __("activitypub.reader.title") }}
|
|
32
52
|
</a>
|
|
@@ -61,7 +81,7 @@
|
|
|
61
81
|
</nav>
|
|
62
82
|
{% endif %}
|
|
63
83
|
|
|
64
|
-
{# Infinite scroll
|
|
84
|
+
{# Infinite scroll load-more trigger #}
|
|
65
85
|
{% if before %}
|
|
66
86
|
<div class="ap-load-more"
|
|
67
87
|
id="ap-load-more"
|