@rmdes/indiekit-endpoint-activitypub 2.0.26 → 2.0.28
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 +56 -1
- package/assets/reader-infinite-scroll.js +183 -0
- package/assets/reader.css +282 -0
- package/index.js +34 -0
- package/lib/controllers/api-timeline.js +170 -0
- package/lib/controllers/explore.js +293 -0
- package/lib/controllers/follow-tag.js +62 -0
- package/lib/controllers/reader.js +11 -0
- package/lib/controllers/tag-timeline.js +147 -0
- package/lib/inbox-listeners.js +40 -3
- package/lib/migrations/separate-mentions.js +88 -0
- package/lib/storage/followed-tags.js +65 -0
- package/lib/storage/timeline.js +15 -2
- package/lib/timeline-store.js +18 -5
- package/locales/en.json +27 -4
- package/package.json +1 -1
- package/views/activitypub-explore.njk +82 -0
- package/views/activitypub-reader.njk +42 -3
- package/views/activitypub-tag-timeline.njk +86 -0
- package/views/layouts/ap-reader.njk +4 -1
- package/views/partials/ap-item-card.njk +20 -5
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON API timeline endpoint — returns pre-rendered HTML cards for infinite scroll AJAX loads.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { getTimelineItems } from "../storage/timeline.js";
|
|
6
|
+
import { getToken } from "../csrf.js";
|
|
7
|
+
import {
|
|
8
|
+
getMutedUrls,
|
|
9
|
+
getMutedKeywords,
|
|
10
|
+
getBlockedUrls,
|
|
11
|
+
getFilterMode,
|
|
12
|
+
} from "../storage/moderation.js";
|
|
13
|
+
|
|
14
|
+
export function apiTimelineController(mountPath) {
|
|
15
|
+
return async (request, response, next) => {
|
|
16
|
+
try {
|
|
17
|
+
const { application } = request.app.locals;
|
|
18
|
+
const collections = {
|
|
19
|
+
ap_timeline: application?.collections?.get("ap_timeline"),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Query parameters
|
|
23
|
+
const tab = request.query.tab || "notes";
|
|
24
|
+
const tag = typeof request.query.tag === "string" ? request.query.tag.trim() : "";
|
|
25
|
+
const before = request.query.before;
|
|
26
|
+
const limit = 20;
|
|
27
|
+
|
|
28
|
+
// Build storage query options (same logic as readerController)
|
|
29
|
+
const options = { before, limit };
|
|
30
|
+
|
|
31
|
+
if (tag) {
|
|
32
|
+
options.tag = tag;
|
|
33
|
+
} else {
|
|
34
|
+
if (tab === "notes") {
|
|
35
|
+
options.type = "note";
|
|
36
|
+
options.excludeReplies = true;
|
|
37
|
+
} else if (tab === "articles") {
|
|
38
|
+
options.type = "article";
|
|
39
|
+
} else if (tab === "boosts") {
|
|
40
|
+
options.type = "boost";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const result = await getTimelineItems(collections, options);
|
|
45
|
+
|
|
46
|
+
// Client-side tab filtering for types not supported by storage
|
|
47
|
+
let items = result.items;
|
|
48
|
+
if (!tag) {
|
|
49
|
+
if (tab === "replies") {
|
|
50
|
+
items = items.filter((item) => item.inReplyTo);
|
|
51
|
+
} else if (tab === "media") {
|
|
52
|
+
items = items.filter(
|
|
53
|
+
(item) =>
|
|
54
|
+
(item.photo && item.photo.length > 0) ||
|
|
55
|
+
(item.video && item.video.length > 0) ||
|
|
56
|
+
(item.audio && item.audio.length > 0)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Apply moderation filters
|
|
62
|
+
const modCollections = {
|
|
63
|
+
ap_muted: application?.collections?.get("ap_muted"),
|
|
64
|
+
ap_blocked: application?.collections?.get("ap_blocked"),
|
|
65
|
+
ap_profile: application?.collections?.get("ap_profile"),
|
|
66
|
+
};
|
|
67
|
+
const [mutedUrls, mutedKeywords, blockedUrls, filterMode] =
|
|
68
|
+
await Promise.all([
|
|
69
|
+
getMutedUrls(modCollections),
|
|
70
|
+
getMutedKeywords(modCollections),
|
|
71
|
+
getBlockedUrls(modCollections),
|
|
72
|
+
getFilterMode(modCollections),
|
|
73
|
+
]);
|
|
74
|
+
const blockedSet = new Set(blockedUrls);
|
|
75
|
+
const mutedSet = new Set(mutedUrls);
|
|
76
|
+
|
|
77
|
+
if (blockedSet.size > 0 || mutedSet.size > 0 || mutedKeywords.length > 0) {
|
|
78
|
+
items = items.filter((item) => {
|
|
79
|
+
if (item.author?.url && blockedSet.has(item.author.url)) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isMutedActor = item.author?.url && mutedSet.has(item.author.url);
|
|
84
|
+
let matchedKeyword = null;
|
|
85
|
+
if (mutedKeywords.length > 0) {
|
|
86
|
+
const searchable = [item.content?.text, item.name, item.summary]
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.join(" ")
|
|
89
|
+
.toLowerCase();
|
|
90
|
+
if (searchable) {
|
|
91
|
+
matchedKeyword = mutedKeywords.find((kw) =>
|
|
92
|
+
searchable.includes(kw.toLowerCase())
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (isMutedActor || matchedKeyword) {
|
|
98
|
+
if (filterMode === "warn") {
|
|
99
|
+
item._moderated = true;
|
|
100
|
+
item._moderationReason = isMutedActor ? "muted_account" : "muted_keyword";
|
|
101
|
+
if (matchedKeyword) item._moderationKeyword = matchedKeyword;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return true;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Get interaction state
|
|
112
|
+
const interactionsCol = application?.collections?.get("ap_interactions");
|
|
113
|
+
const interactionMap = {};
|
|
114
|
+
|
|
115
|
+
if (interactionsCol) {
|
|
116
|
+
const lookupUrls = new Set();
|
|
117
|
+
const objectUrlToUid = new Map();
|
|
118
|
+
for (const item of items) {
|
|
119
|
+
const uid = item.uid;
|
|
120
|
+
const displayUrl = item.url || item.originalUrl;
|
|
121
|
+
if (uid) { lookupUrls.add(uid); objectUrlToUid.set(uid, uid); }
|
|
122
|
+
if (displayUrl) { lookupUrls.add(displayUrl); objectUrlToUid.set(displayUrl, uid || displayUrl); }
|
|
123
|
+
}
|
|
124
|
+
if (lookupUrls.size > 0) {
|
|
125
|
+
const interactions = await interactionsCol
|
|
126
|
+
.find({ objectUrl: { $in: [...lookupUrls] } })
|
|
127
|
+
.toArray();
|
|
128
|
+
for (const interaction of interactions) {
|
|
129
|
+
const key = objectUrlToUid.get(interaction.objectUrl) || interaction.objectUrl;
|
|
130
|
+
if (!interactionMap[key]) interactionMap[key] = {};
|
|
131
|
+
interactionMap[key][interaction.type] = true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const csrfToken = getToken(request.session);
|
|
137
|
+
|
|
138
|
+
// Render each card server-side using the same Nunjucks template
|
|
139
|
+
// Merge response.locals so that i18n (__), mountPath, etc. are available
|
|
140
|
+
const templateData = {
|
|
141
|
+
...response.locals,
|
|
142
|
+
mountPath,
|
|
143
|
+
csrfToken,
|
|
144
|
+
interactionMap,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const htmlParts = await Promise.all(
|
|
148
|
+
items.map((item) => {
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
request.app.render(
|
|
151
|
+
"partials/ap-item-card.njk",
|
|
152
|
+
{ ...templateData, item },
|
|
153
|
+
(err, html) => {
|
|
154
|
+
if (err) reject(err);
|
|
155
|
+
else resolve(html);
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
})
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
response.json({
|
|
163
|
+
html: htmlParts.join(""),
|
|
164
|
+
before: result.before,
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
next(error);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explore controller — browse public timelines from remote Mastodon-compatible instances.
|
|
3
|
+
*
|
|
4
|
+
* All remote API calls are server-side (no CORS issues).
|
|
5
|
+
* Remote HTML is always passed through sanitizeContent() before storage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import sanitizeHtml from "sanitize-html";
|
|
9
|
+
import { sanitizeContent } from "../timeline-store.js";
|
|
10
|
+
|
|
11
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
12
|
+
const MAX_RESULTS = 20;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validate the instance parameter to prevent SSRF.
|
|
16
|
+
* Only allows hostnames — no IPs, no localhost, no port numbers for exotic attacks.
|
|
17
|
+
* @param {string} instance - Raw instance parameter from query string
|
|
18
|
+
* @returns {string|null} Validated hostname or null
|
|
19
|
+
*/
|
|
20
|
+
function validateInstance(instance) {
|
|
21
|
+
if (!instance || typeof instance !== "string") return null;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
// Prepend https:// to parse as URL
|
|
25
|
+
const url = new URL(`https://${instance.trim()}`);
|
|
26
|
+
|
|
27
|
+
// Must be a plain hostname — no IP addresses, no localhost
|
|
28
|
+
const hostname = url.hostname;
|
|
29
|
+
if (
|
|
30
|
+
hostname === "localhost" ||
|
|
31
|
+
hostname === "127.0.0.1" ||
|
|
32
|
+
hostname === "0.0.0.0" ||
|
|
33
|
+
hostname === "::1" ||
|
|
34
|
+
hostname.startsWith("192.168.") ||
|
|
35
|
+
hostname.startsWith("10.") ||
|
|
36
|
+
hostname.startsWith("169.254.") ||
|
|
37
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
|
|
38
|
+
/^[0-9]{1,3}(\.[0-9]{1,3}){3}$/.test(hostname) || // IPv4
|
|
39
|
+
hostname.includes("[") // IPv6
|
|
40
|
+
) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Only allow the hostname (no path, no port override)
|
|
45
|
+
return hostname;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Map a Mastodon API status object to our timeline item format.
|
|
53
|
+
* @param {object} status - Mastodon API status
|
|
54
|
+
* @param {string} instance - Instance hostname (for handle construction)
|
|
55
|
+
* @returns {object} Timeline item compatible with ap-item-card.njk
|
|
56
|
+
*/
|
|
57
|
+
function mapMastodonStatusToItem(status, instance) {
|
|
58
|
+
const account = status.account || {};
|
|
59
|
+
const acct = account.acct || "";
|
|
60
|
+
// Mastodon acct is "user" for local, "user@remote" for remote
|
|
61
|
+
const handle = acct.includes("@") ? `@${acct}` : `@${acct}@${instance}`;
|
|
62
|
+
|
|
63
|
+
// Map mentions — store without leading @ (template prepends it)
|
|
64
|
+
const mentions = (status.mentions || []).map((m) => ({
|
|
65
|
+
name: m.acct.includes("@") ? m.acct : `${m.acct}@${instance}`,
|
|
66
|
+
url: m.url || "",
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
// Map hashtags
|
|
70
|
+
const category = (status.tags || []).map((t) => t.name || "");
|
|
71
|
+
|
|
72
|
+
// Map media attachments
|
|
73
|
+
const photo = [];
|
|
74
|
+
const video = [];
|
|
75
|
+
const audio = [];
|
|
76
|
+
for (const att of status.media_attachments || []) {
|
|
77
|
+
const url = att.url || att.remote_url || "";
|
|
78
|
+
if (!url) continue;
|
|
79
|
+
if (att.type === "image" || att.type === "gifv") {
|
|
80
|
+
photo.push(url);
|
|
81
|
+
} else if (att.type === "video") {
|
|
82
|
+
video.push(url);
|
|
83
|
+
} else if (att.type === "audio") {
|
|
84
|
+
audio.push(url);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
uid: status.url || status.uri || "",
|
|
90
|
+
url: status.url || status.uri || "",
|
|
91
|
+
type: "note",
|
|
92
|
+
name: "",
|
|
93
|
+
content: {
|
|
94
|
+
text: (status.content || "").replace(/<[^>]*>/g, ""),
|
|
95
|
+
html: sanitizeContent(status.content || ""),
|
|
96
|
+
},
|
|
97
|
+
summary: status.spoiler_text || "",
|
|
98
|
+
sensitive: status.sensitive || false,
|
|
99
|
+
published: status.created_at || new Date().toISOString(),
|
|
100
|
+
author: {
|
|
101
|
+
name: sanitizeHtml(account.display_name || account.username || "Unknown", { allowedTags: [], allowedAttributes: {} }),
|
|
102
|
+
url: account.url || "",
|
|
103
|
+
photo: account.avatar || account.avatar_static || "",
|
|
104
|
+
handle,
|
|
105
|
+
},
|
|
106
|
+
category,
|
|
107
|
+
mentions,
|
|
108
|
+
photo,
|
|
109
|
+
video,
|
|
110
|
+
audio,
|
|
111
|
+
inReplyTo: status.in_reply_to_id ? `https://${instance}/web/statuses/${status.in_reply_to_id}` : "",
|
|
112
|
+
createdAt: new Date().toISOString(),
|
|
113
|
+
// Explore-specific: track source instance
|
|
114
|
+
_explore: true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function exploreController(mountPath) {
|
|
119
|
+
return async (request, response, next) => {
|
|
120
|
+
try {
|
|
121
|
+
const rawInstance = request.query.instance || "";
|
|
122
|
+
const scope = request.query.scope === "federated" ? "federated" : "local";
|
|
123
|
+
const maxId = request.query.max_id || "";
|
|
124
|
+
|
|
125
|
+
// No instance specified — render clean initial page (no error)
|
|
126
|
+
if (!rawInstance.trim()) {
|
|
127
|
+
return response.render("activitypub-explore", {
|
|
128
|
+
title: response.locals.__("activitypub.reader.explore.title"),
|
|
129
|
+
instance: "",
|
|
130
|
+
scope,
|
|
131
|
+
items: [],
|
|
132
|
+
maxId: null,
|
|
133
|
+
error: null,
|
|
134
|
+
mountPath,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const instance = validateInstance(rawInstance);
|
|
139
|
+
if (!instance) {
|
|
140
|
+
return response.render("activitypub-explore", {
|
|
141
|
+
title: response.locals.__("activitypub.reader.explore.title"),
|
|
142
|
+
instance: rawInstance,
|
|
143
|
+
scope,
|
|
144
|
+
items: [],
|
|
145
|
+
maxId: null,
|
|
146
|
+
error: response.locals.__("activitypub.reader.explore.invalidInstance"),
|
|
147
|
+
mountPath,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Fetch public timeline from remote instance
|
|
152
|
+
const isLocal = scope === "local";
|
|
153
|
+
const apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
|
|
154
|
+
apiUrl.searchParams.set("local", isLocal ? "true" : "false");
|
|
155
|
+
apiUrl.searchParams.set("limit", String(MAX_RESULTS));
|
|
156
|
+
if (maxId) apiUrl.searchParams.set("max_id", maxId);
|
|
157
|
+
|
|
158
|
+
let items = [];
|
|
159
|
+
let nextMaxId = null;
|
|
160
|
+
let error = null;
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const controller = new AbortController();
|
|
164
|
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
165
|
+
|
|
166
|
+
const fetchRes = await fetch(apiUrl.toString(), {
|
|
167
|
+
headers: { Accept: "application/json" },
|
|
168
|
+
signal: controller.signal,
|
|
169
|
+
});
|
|
170
|
+
clearTimeout(timeoutId);
|
|
171
|
+
|
|
172
|
+
if (!fetchRes.ok) {
|
|
173
|
+
throw new Error(`Remote instance returned HTTP ${fetchRes.status}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const statuses = await fetchRes.json();
|
|
177
|
+
|
|
178
|
+
if (!Array.isArray(statuses)) {
|
|
179
|
+
throw new Error("Unexpected API response format");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
items = statuses.map((s) => mapMastodonStatusToItem(s, instance));
|
|
183
|
+
|
|
184
|
+
// Get next max_id from last item for pagination
|
|
185
|
+
if (statuses.length === MAX_RESULTS && statuses.length > 0) {
|
|
186
|
+
const last = statuses[statuses.length - 1];
|
|
187
|
+
nextMaxId = last.id || null;
|
|
188
|
+
}
|
|
189
|
+
} catch (fetchError) {
|
|
190
|
+
const msg = fetchError.name === "AbortError"
|
|
191
|
+
? response.locals.__("activitypub.reader.explore.timeout")
|
|
192
|
+
: response.locals.__("activitypub.reader.explore.loadError");
|
|
193
|
+
error = msg;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
response.render("activitypub-explore", {
|
|
197
|
+
title: response.locals.__("activitypub.reader.explore.title"),
|
|
198
|
+
instance,
|
|
199
|
+
scope,
|
|
200
|
+
items,
|
|
201
|
+
maxId: nextMaxId,
|
|
202
|
+
error,
|
|
203
|
+
mountPath,
|
|
204
|
+
// Pass empty interactionMap — explore posts are not in our DB
|
|
205
|
+
interactionMap: {},
|
|
206
|
+
csrfToken: "",
|
|
207
|
+
});
|
|
208
|
+
} catch (error) {
|
|
209
|
+
next(error);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* AJAX API endpoint for explore page infinite scroll.
|
|
216
|
+
* Returns JSON { html, maxId }.
|
|
217
|
+
*/
|
|
218
|
+
export function exploreApiController(mountPath) {
|
|
219
|
+
return async (request, response, next) => {
|
|
220
|
+
try {
|
|
221
|
+
const rawInstance = request.query.instance || "";
|
|
222
|
+
const scope = request.query.scope === "federated" ? "federated" : "local";
|
|
223
|
+
const maxId = request.query.max_id || "";
|
|
224
|
+
|
|
225
|
+
const instance = validateInstance(rawInstance);
|
|
226
|
+
if (!instance) {
|
|
227
|
+
return response.status(400).json({ error: "Invalid instance" });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const isLocal = scope === "local";
|
|
231
|
+
const apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
|
|
232
|
+
apiUrl.searchParams.set("local", isLocal ? "true" : "false");
|
|
233
|
+
apiUrl.searchParams.set("limit", String(MAX_RESULTS));
|
|
234
|
+
if (maxId) apiUrl.searchParams.set("max_id", maxId);
|
|
235
|
+
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
238
|
+
|
|
239
|
+
const fetchRes = await fetch(apiUrl.toString(), {
|
|
240
|
+
headers: { Accept: "application/json" },
|
|
241
|
+
signal: controller.signal,
|
|
242
|
+
});
|
|
243
|
+
clearTimeout(timeoutId);
|
|
244
|
+
|
|
245
|
+
if (!fetchRes.ok) {
|
|
246
|
+
return response.status(502).json({ error: `Remote returned ${fetchRes.status}` });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const statuses = await fetchRes.json();
|
|
250
|
+
if (!Array.isArray(statuses)) {
|
|
251
|
+
return response.status(502).json({ error: "Unexpected API response" });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const items = statuses.map((s) => mapMastodonStatusToItem(s, instance));
|
|
255
|
+
|
|
256
|
+
let nextMaxId = null;
|
|
257
|
+
if (statuses.length === MAX_RESULTS && statuses.length > 0) {
|
|
258
|
+
const last = statuses[statuses.length - 1];
|
|
259
|
+
nextMaxId = last.id || null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Render each card server-side
|
|
263
|
+
const templateData = {
|
|
264
|
+
...response.locals,
|
|
265
|
+
mountPath,
|
|
266
|
+
csrfToken: "",
|
|
267
|
+
interactionMap: {},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const htmlParts = await Promise.all(
|
|
271
|
+
items.map((item) => {
|
|
272
|
+
return new Promise((resolve, reject) => {
|
|
273
|
+
request.app.render(
|
|
274
|
+
"partials/ap-item-card.njk",
|
|
275
|
+
{ ...templateData, item },
|
|
276
|
+
(err, html) => {
|
|
277
|
+
if (err) reject(err);
|
|
278
|
+
else resolve(html);
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
})
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
response.json({
|
|
286
|
+
html: htmlParts.join(""),
|
|
287
|
+
maxId: nextMaxId,
|
|
288
|
+
});
|
|
289
|
+
} catch (error) {
|
|
290
|
+
next(error);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hashtag follow/unfollow controllers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { validateToken } from "../csrf.js";
|
|
6
|
+
import { followTag, unfollowTag } from "../storage/followed-tags.js";
|
|
7
|
+
|
|
8
|
+
export function followTagController(mountPath) {
|
|
9
|
+
return async (request, response, next) => {
|
|
10
|
+
try {
|
|
11
|
+
const { application } = request.app.locals;
|
|
12
|
+
|
|
13
|
+
// CSRF validation
|
|
14
|
+
if (!validateToken(request)) {
|
|
15
|
+
return response.status(403).json({ error: "Invalid CSRF token" });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const tag = typeof request.body.tag === "string" ? request.body.tag.trim() : "";
|
|
19
|
+
if (!tag) {
|
|
20
|
+
return response.redirect(`${mountPath}/admin/reader`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const collections = {
|
|
24
|
+
ap_followed_tags: application?.collections?.get("ap_followed_tags"),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
await followTag(collections, tag);
|
|
28
|
+
|
|
29
|
+
return response.redirect(`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}`);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
next(error);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function unfollowTagController(mountPath) {
|
|
37
|
+
return async (request, response, next) => {
|
|
38
|
+
try {
|
|
39
|
+
const { application } = request.app.locals;
|
|
40
|
+
|
|
41
|
+
// CSRF validation
|
|
42
|
+
if (!validateToken(request)) {
|
|
43
|
+
return response.status(403).json({ error: "Invalid CSRF token" });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const tag = typeof request.body.tag === "string" ? request.body.tag.trim() : "";
|
|
47
|
+
if (!tag) {
|
|
48
|
+
return response.redirect(`${mountPath}/admin/reader`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const collections = {
|
|
52
|
+
ap_followed_tags: application?.collections?.get("ap_followed_tags"),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
await unfollowTag(collections, tag);
|
|
56
|
+
|
|
57
|
+
return response.redirect(`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}`);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
next(error);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
getBlockedUrls,
|
|
19
19
|
getFilterMode,
|
|
20
20
|
} from "../storage/moderation.js";
|
|
21
|
+
import { getFollowedTags } from "../storage/followed-tags.js";
|
|
21
22
|
|
|
22
23
|
// Re-export controllers from split modules for backward compatibility
|
|
23
24
|
export {
|
|
@@ -38,6 +39,7 @@ export function readerController(mountPath) {
|
|
|
38
39
|
const collections = {
|
|
39
40
|
ap_timeline: application?.collections?.get("ap_timeline"),
|
|
40
41
|
ap_notifications: application?.collections?.get("ap_notifications"),
|
|
42
|
+
ap_followed_tags: application?.collections?.get("ap_followed_tags"),
|
|
41
43
|
};
|
|
42
44
|
|
|
43
45
|
// Query parameters
|
|
@@ -191,6 +193,14 @@ export function readerController(mountPath) {
|
|
|
191
193
|
// CSRF token for interaction forms
|
|
192
194
|
const csrfToken = getToken(request.session);
|
|
193
195
|
|
|
196
|
+
// Followed tags for sidebar
|
|
197
|
+
let followedTags = [];
|
|
198
|
+
try {
|
|
199
|
+
followedTags = await getFollowedTags(collections);
|
|
200
|
+
} catch {
|
|
201
|
+
// Non-critical — collection may not exist yet
|
|
202
|
+
}
|
|
203
|
+
|
|
194
204
|
response.render("activitypub-reader", {
|
|
195
205
|
title: response.locals.__("activitypub.reader.title"),
|
|
196
206
|
items,
|
|
@@ -201,6 +211,7 @@ export function readerController(mountPath) {
|
|
|
201
211
|
interactionMap,
|
|
202
212
|
csrfToken,
|
|
203
213
|
mountPath,
|
|
214
|
+
followedTags,
|
|
204
215
|
});
|
|
205
216
|
} catch (error) {
|
|
206
217
|
next(error);
|