@rmdes/indiekit-endpoint-webmention-io 1.0.0

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 ADDED
@@ -0,0 +1,113 @@
1
+ import express from "express";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+
5
+ import { dashboardController } from "./lib/controllers/dashboard.js";
6
+ import { blocklistController } from "./lib/controllers/blocklist.js";
7
+ import { syncController } from "./lib/controllers/sync-controller.js";
8
+ import { apiController } from "./lib/controllers/api.js";
9
+ import { startSync, stopSync } from "./lib/sync.js";
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ const protectedRouter = express.Router();
14
+ const publicRouter = express.Router();
15
+
16
+ const defaults = {
17
+ mountPath: "/webmentions",
18
+ syncInterval: 900_000, // 15 minutes
19
+ cacheTtl: 60, // seconds for public API Cache-Control
20
+ };
21
+
22
+ export default class WebmentionEndpoint {
23
+ name = "Webmention moderation endpoint";
24
+
25
+ constructor(options = {}) {
26
+ this.options = { ...defaults, ...options };
27
+ this.mountPath = this.options.mountPath;
28
+ }
29
+
30
+ get localesDirectory() {
31
+ return path.join(__dirname, "locales");
32
+ }
33
+
34
+ get viewsDirectory() {
35
+ return path.join(__dirname, "views");
36
+ }
37
+
38
+ get navigationItems() {
39
+ return {
40
+ href: this.options.mountPath,
41
+ text: "webmention-io.title",
42
+ requiresDatabase: true,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Protected routes (require authentication)
48
+ * Admin dashboard, moderation, sync controls
49
+ */
50
+ get routes() {
51
+ // Dashboard — paginated webmention list with moderation
52
+ protectedRouter.get("/", dashboardController.list);
53
+
54
+ // Blocklist management page
55
+ protectedRouter.get("/blocklist", blocklistController.list);
56
+
57
+ // Sync controls
58
+ protectedRouter.post("/sync", syncController.sync);
59
+ protectedRouter.post("/sync/full", syncController.fullSync);
60
+
61
+ // Moderation actions
62
+ protectedRouter.post("/:wmId/hide", dashboardController.hide);
63
+ protectedRouter.post("/:wmId/unhide", dashboardController.unhide);
64
+
65
+ // Block a domain (hides all mentions + adds to blocklist)
66
+ protectedRouter.post("/block", dashboardController.blockDomainHandler);
67
+
68
+ // Unblock a domain
69
+ protectedRouter.post(
70
+ "/blocklist/:domain/delete",
71
+ blocklistController.unblock,
72
+ );
73
+
74
+ // Privacy removal (permanent delete + block)
75
+ protectedRouter.post("/privacy-remove", dashboardController.privacyRemove);
76
+
77
+ return protectedRouter;
78
+ }
79
+
80
+ /**
81
+ * Public routes (no authentication required)
82
+ * JF2 JSON API — drop-in replacement for webmention.io proxy
83
+ */
84
+ get routesPublic() {
85
+ publicRouter.get("/api/mentions", apiController.getMentions);
86
+
87
+ return publicRouter;
88
+ }
89
+
90
+ init(Indiekit) {
91
+ Indiekit.addEndpoint(this);
92
+
93
+ // Add MongoDB collections
94
+ Indiekit.addCollection("webmentions");
95
+ Indiekit.addCollection("webmentionBlocklist");
96
+
97
+ // Store config in application for controller access
98
+ Indiekit.config.application.webmentionConfig = this.options;
99
+ Indiekit.config.application.webmentionEndpoint = this.mountPath;
100
+
101
+ // Store database getter for controller access
102
+ Indiekit.config.application.getWebmentionDb = () => Indiekit.database;
103
+
104
+ // Start background sync if database is available
105
+ if (Indiekit.config.application.mongodbUrl) {
106
+ startSync(Indiekit, this.options);
107
+ }
108
+ }
109
+
110
+ destroy() {
111
+ stopSync();
112
+ }
113
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Public JSON API controller
3
+ * Drop-in replacement for webmention.io API and the proxy plugin
4
+ */
5
+
6
+ import { getWebmentions, documentToJf2 } from "../storage/webmentions.js";
7
+
8
+ export const apiController = {
9
+ /**
10
+ * GET /api/mentions - Public JF2 webmentions API
11
+ */
12
+ async getMentions(request, response) {
13
+ try {
14
+ const { application } = request.app.locals;
15
+ const db = application.getWebmentionDb();
16
+
17
+ if (!db) {
18
+ return response.status(503).json({ error: "Database unavailable" });
19
+ }
20
+
21
+ const collection = db.collection("webmentions");
22
+
23
+ const target = request.query.target || null;
24
+ const wmProperty = request.query["wm-property"] || null;
25
+ const perPage = Math.min(Number(request.query["per-page"]) || 50, 10000);
26
+ const page = Number(request.query.page) || 0;
27
+
28
+ const { items } = await getWebmentions(collection, {
29
+ target,
30
+ wmProperty,
31
+ showHidden: false,
32
+ page,
33
+ perPage,
34
+ });
35
+
36
+ const children = items.map(documentToJf2);
37
+
38
+ const cacheTtl = application.webmentionConfig?.cacheTtl || 60;
39
+ response.set("Cache-Control", `public, max-age=${cacheTtl}`);
40
+
41
+ response.json({
42
+ type: "feed",
43
+ name: "Webmentions",
44
+ children,
45
+ });
46
+ } catch (error) {
47
+ console.error("[Webmentions] API error:", error);
48
+ response.status(500).json({ error: "Failed to fetch webmentions" });
49
+ }
50
+ },
51
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Blocklist controller
3
+ */
4
+
5
+ import { getBlocklist, unblockDomain } from "../storage/blocklist.js";
6
+ import { unhideByDomain } from "../storage/webmentions.js";
7
+
8
+ export const blocklistController = {
9
+ /**
10
+ * GET /blocklist - Blocklist management page
11
+ */
12
+ async list(request, response) {
13
+ const { application } = request.app.locals;
14
+
15
+ try {
16
+ const db = application.getWebmentionDb();
17
+ let entries = [];
18
+
19
+ if (db) {
20
+ const collection = db.collection("webmentionBlocklist");
21
+ entries = await getBlocklist(collection);
22
+ }
23
+
24
+ response.render("webmentions-blocklist", {
25
+ title: response.locals.__("webmention-io.blocklist.title"),
26
+ entries,
27
+ wmEndpoint: application.webmentionEndpoint,
28
+ });
29
+ } catch (error) {
30
+ console.error("[Webmentions] Blocklist error:", error);
31
+ response.status(500).render("error", {
32
+ title: "Error",
33
+ message: "Failed to load blocklist",
34
+ error: error.message,
35
+ });
36
+ }
37
+ },
38
+
39
+ /**
40
+ * POST /blocklist/:domain/delete - Unblock a domain
41
+ */
42
+ async unblock(request, response) {
43
+ const { application } = request.app.locals;
44
+
45
+ try {
46
+ const domain = decodeURIComponent(request.params.domain);
47
+ const db = application.getWebmentionDb();
48
+ const blockCollection = db.collection("webmentionBlocklist");
49
+ const wmCollection = db.collection("webmentions");
50
+
51
+ await unblockDomain(blockCollection, domain);
52
+
53
+ // Unhide mentions that were hidden by blocklist (not manual or privacy)
54
+ const unhidden = await unhideByDomain(wmCollection, domain);
55
+
56
+ response.redirect(
57
+ application.webmentionEndpoint + "/blocklist?unblocked=1&unhidden=" + unhidden,
58
+ );
59
+ } catch (error) {
60
+ console.error("[Webmentions] Unblock error:", error);
61
+ response.redirect(application.webmentionEndpoint + "/blocklist?error=unblock-failed");
62
+ }
63
+ },
64
+ };
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Dashboard controller
3
+ * Admin UI for webmention moderation
4
+ */
5
+
6
+ import {
7
+ getWebmentions,
8
+ getWebmentionCounts,
9
+ hideWebmention,
10
+ unhideWebmention,
11
+ hideByDomain,
12
+ deleteByDomain,
13
+ } from "../storage/webmentions.js";
14
+ import { blockDomain } from "../storage/blocklist.js";
15
+ import { getSyncState } from "../sync.js";
16
+ import { getMentionType, getMentionTitle, getAuthorName } from "../utils.js";
17
+
18
+ export const dashboardController = {
19
+ /**
20
+ * GET / - Webmentions dashboard
21
+ */
22
+ async list(request, response) {
23
+ const { application } = request.app.locals;
24
+
25
+ try {
26
+ const db = application.getWebmentionDb();
27
+ if (!db) {
28
+ return response.render("webmentions", {
29
+ title: response.locals.__("webmention-io.title"),
30
+ webmentions: [],
31
+ counts: { total: 0, hidden: 0, visible: 0 },
32
+ syncState: getSyncState(),
33
+ cursor: {},
34
+ filter: "all",
35
+ typeFilter: "all",
36
+ wmEndpoint: application.webmentionEndpoint,
37
+ });
38
+ }
39
+
40
+ const collection = db.collection("webmentions");
41
+
42
+ const page = Number(request.query.page) || 0;
43
+ const limit = Number(request.query.limit) || 20;
44
+ const filter = request.query.filter || "all";
45
+ const typeFilter = request.query.type || "all";
46
+
47
+ // Build query options
48
+ const queryOptions = {
49
+ page,
50
+ perPage: limit,
51
+ };
52
+
53
+ if (filter === "hidden") {
54
+ queryOptions.showHidden = true;
55
+ } else if (filter === "visible") {
56
+ queryOptions.showHidden = false;
57
+ } else {
58
+ // "all" — show everything
59
+ queryOptions.showHidden = true;
60
+ }
61
+
62
+ if (typeFilter !== "all") {
63
+ queryOptions.wmProperty = typeFilter;
64
+ }
65
+
66
+ const { items, total } = await getWebmentions(collection, queryOptions);
67
+ const counts = await getWebmentionCounts(collection);
68
+
69
+ // Transform for the mention() macro
70
+ const webmentions = items.map((item) => {
71
+ let html;
72
+ if (item.contentHtml) {
73
+ html = item.contentHtml;
74
+ }
75
+
76
+ return {
77
+ id: item.wmId,
78
+ "wm-id": item.wmId,
79
+ "wm-property": item.wmProperty,
80
+ "wm-target": item.wmTarget,
81
+ icon: getMentionType(item.wmProperty),
82
+ locale: application.locale,
83
+ title: getMentionTitle({ name: item.name, "wm-property": item.wmProperty }),
84
+ description: html ? { html } : undefined,
85
+ published: item.published || item.wmReceived,
86
+ url: item.sourceUrl,
87
+ user: {
88
+ avatar: { src: item.authorPhoto },
89
+ name: item.authorName || getAuthorName({
90
+ author: { name: item.authorName, url: item.authorUrl },
91
+ url: item.sourceUrl,
92
+ }),
93
+ url: item.authorUrl,
94
+ },
95
+ // Moderation metadata
96
+ hidden: item.hidden,
97
+ hiddenReason: item.hiddenReason,
98
+ sourceDomain: item.sourceDomain,
99
+ };
100
+ });
101
+
102
+ // Pagination cursor
103
+ const cursor = {
104
+ next: { href: `?page=${page + 1}&filter=${filter}&type=${typeFilter}` },
105
+ };
106
+ if (page > 0) {
107
+ cursor.previous = { href: `?page=${page - 1}&filter=${filter}&type=${typeFilter}` };
108
+ }
109
+
110
+ response.render("webmentions", {
111
+ title: response.locals.__("webmention-io.title"),
112
+ webmentions,
113
+ counts,
114
+ syncState: getSyncState(),
115
+ cursor,
116
+ filter,
117
+ typeFilter,
118
+ wmEndpoint: application.webmentionEndpoint,
119
+ });
120
+ } catch (error) {
121
+ console.error("[Webmentions] Dashboard error:", error);
122
+ response.status(500).render("error", {
123
+ title: "Error",
124
+ message: "Failed to load webmentions",
125
+ error: error.message,
126
+ });
127
+ }
128
+ },
129
+
130
+ /**
131
+ * POST /:wmId/hide - Hide a webmention
132
+ */
133
+ async hide(request, response) {
134
+ const { application } = request.app.locals;
135
+
136
+ try {
137
+ const wmId = Number.parseInt(request.params.wmId, 10);
138
+ const db = application.getWebmentionDb();
139
+ const collection = db.collection("webmentions");
140
+
141
+ await hideWebmention(collection, wmId, "manual");
142
+
143
+ response.redirect(application.webmentionEndpoint + "?hidden=1");
144
+ } catch (error) {
145
+ console.error("[Webmentions] Hide error:", error);
146
+ response.redirect(application.webmentionEndpoint + "?error=hide-failed");
147
+ }
148
+ },
149
+
150
+ /**
151
+ * POST /:wmId/unhide - Restore a webmention
152
+ */
153
+ async unhide(request, response) {
154
+ const { application } = request.app.locals;
155
+
156
+ try {
157
+ const wmId = Number.parseInt(request.params.wmId, 10);
158
+ const db = application.getWebmentionDb();
159
+ const collection = db.collection("webmentions");
160
+
161
+ await unhideWebmention(collection, wmId);
162
+
163
+ response.redirect(application.webmentionEndpoint + "?unhidden=1");
164
+ } catch (error) {
165
+ console.error("[Webmentions] Unhide error:", error);
166
+ response.redirect(application.webmentionEndpoint + "?error=unhide-failed");
167
+ }
168
+ },
169
+
170
+ /**
171
+ * POST /block - Block a domain
172
+ */
173
+ async blockDomainHandler(request, response) {
174
+ const { application } = request.app.locals;
175
+
176
+ try {
177
+ const { domain } = request.body;
178
+ if (!domain) {
179
+ return response.redirect(application.webmentionEndpoint + "?error=no-domain");
180
+ }
181
+
182
+ const db = application.getWebmentionDb();
183
+ const wmCollection = db.collection("webmentions");
184
+ const blockCollection = db.collection("webmentionBlocklist");
185
+
186
+ // Hide all existing mentions from this domain
187
+ const hidden = await hideByDomain(wmCollection, domain, "blocklist");
188
+
189
+ // Add to blocklist
190
+ await blockDomain(blockCollection, domain, "spam", hidden);
191
+
192
+ response.redirect(application.webmentionEndpoint + "?blocked=1&domain=" + encodeURIComponent(domain));
193
+ } catch (error) {
194
+ console.error("[Webmentions] Block error:", error);
195
+ response.redirect(application.webmentionEndpoint + "?error=block-failed");
196
+ }
197
+ },
198
+
199
+ /**
200
+ * POST /privacy-remove - Privacy removal (delete + block)
201
+ */
202
+ async privacyRemove(request, response) {
203
+ const { application } = request.app.locals;
204
+
205
+ try {
206
+ const { domain } = request.body;
207
+ if (!domain) {
208
+ return response.redirect(application.webmentionEndpoint + "/blocklist?error=no-domain");
209
+ }
210
+
211
+ const db = application.getWebmentionDb();
212
+ const wmCollection = db.collection("webmentions");
213
+ const blockCollection = db.collection("webmentionBlocklist");
214
+
215
+ // Permanently delete all mentions from this domain
216
+ const deleted = await deleteByDomain(wmCollection, domain);
217
+
218
+ // Add to blocklist with privacy reason
219
+ await blockDomain(blockCollection, domain, "privacy", deleted);
220
+
221
+ response.redirect(application.webmentionEndpoint + "/blocklist?removed=1&count=" + deleted);
222
+ } catch (error) {
223
+ console.error("[Webmentions] Privacy remove error:", error);
224
+ response.redirect(application.webmentionEndpoint + "/blocklist?error=remove-failed");
225
+ }
226
+ },
227
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Sync controller - manual sync triggers
3
+ */
4
+
5
+ import { runSync, runFullSync } from "../sync.js";
6
+
7
+ export const syncController = {
8
+ /**
9
+ * POST /sync - Trigger incremental sync
10
+ */
11
+ async sync(request, response) {
12
+ const { application } = request.app.locals;
13
+
14
+ try {
15
+ const db = application.getWebmentionDb();
16
+ const options = application.webmentionConfig || {};
17
+ const result = await runSync(db, options);
18
+
19
+ if (result.error) {
20
+ response.redirect(application.webmentionEndpoint + "?error=" + encodeURIComponent(result.error));
21
+ } else {
22
+ response.redirect(application.webmentionEndpoint + "?synced=1&added=" + result.mentionsAdded);
23
+ }
24
+ } catch (error) {
25
+ console.error("[Webmentions] Manual sync error:", error);
26
+ response.redirect(application.webmentionEndpoint + "?error=sync-failed");
27
+ }
28
+ },
29
+
30
+ /**
31
+ * POST /sync/full - Trigger full re-sync
32
+ */
33
+ async fullSync(request, response) {
34
+ const { application } = request.app.locals;
35
+
36
+ try {
37
+ const db = application.getWebmentionDb();
38
+ const options = application.webmentionConfig || {};
39
+ const result = await runFullSync(db, options);
40
+
41
+ if (result.error) {
42
+ response.redirect(application.webmentionEndpoint + "?error=" + encodeURIComponent(result.error));
43
+ } else {
44
+ response.redirect(application.webmentionEndpoint + "?synced=1&added=" + result.mentionsAdded);
45
+ }
46
+ } catch (error) {
47
+ console.error("[Webmentions] Full sync error:", error);
48
+ response.redirect(application.webmentionEndpoint + "?error=sync-failed");
49
+ }
50
+ },
51
+ };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Webmention blocklist MongoDB storage
3
+ */
4
+
5
+ /**
6
+ * Ensure indexes exist
7
+ * @param {object} collection - MongoDB collection
8
+ */
9
+ export async function ensureBlocklistIndexes(collection) {
10
+ await collection.createIndex({ domain: 1 }, { unique: true });
11
+ }
12
+
13
+ /**
14
+ * Add a domain to the blocklist
15
+ * @param {object} collection - MongoDB collection
16
+ * @param {string} domain - Domain to block
17
+ * @param {string} reason - Reason ("spam", "privacy", "manual")
18
+ * @param {number} mentionsHidden - Count of mentions hidden
19
+ * @returns {Promise<boolean>} true if inserted, false if already existed
20
+ */
21
+ export async function blockDomain(collection, domain, reason = "spam", mentionsHidden = 0) {
22
+ try {
23
+ await collection.insertOne({
24
+ domain,
25
+ reason,
26
+ blockedAt: new Date(),
27
+ mentionsHidden,
28
+ });
29
+ return true;
30
+ } catch (error) {
31
+ // Duplicate key — domain already blocked
32
+ if (error.code === 11000) {
33
+ // Update reason and count
34
+ await collection.updateOne(
35
+ { domain },
36
+ {
37
+ $set: { reason },
38
+ $inc: { mentionsHidden },
39
+ },
40
+ );
41
+ return false;
42
+ }
43
+ throw error;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Remove a domain from the blocklist
49
+ * @param {object} collection - MongoDB collection
50
+ * @param {string} domain - Domain to unblock
51
+ */
52
+ export async function unblockDomain(collection, domain) {
53
+ await collection.deleteOne({ domain });
54
+ }
55
+
56
+ /**
57
+ * Get all blocked domains
58
+ * @param {object} collection - MongoDB collection
59
+ * @returns {Promise<Array>}
60
+ */
61
+ export async function getBlocklist(collection) {
62
+ return collection.find({}).sort({ blockedAt: -1 }).toArray();
63
+ }
64
+
65
+ /**
66
+ * Check if a domain is blocked
67
+ * @param {object} collection - MongoDB collection
68
+ * @param {string} domain - Domain to check
69
+ * @returns {Promise<boolean>}
70
+ */
71
+ export async function isDomainBlocked(collection, domain) {
72
+ const entry = await collection.findOne({ domain });
73
+ return !!entry;
74
+ }
75
+
76
+ /**
77
+ * Get set of all blocked domains (for efficient sync filtering)
78
+ * @param {object} collection - MongoDB collection
79
+ * @returns {Promise<Set<string>>}
80
+ */
81
+ export async function getBlockedDomainSet(collection) {
82
+ const entries = await collection.find({}, { projection: { domain: 1 } }).toArray();
83
+ return new Set(entries.map((e) => e.domain));
84
+ }