@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 +113 -0
- package/lib/controllers/api.js +51 -0
- package/lib/controllers/blocklist.js +64 -0
- package/lib/controllers/dashboard.js +227 -0
- package/lib/controllers/sync-controller.js +51 -0
- package/lib/storage/blocklist.js +84 -0
- package/lib/storage/webmentions.js +252 -0
- package/lib/sync.js +289 -0
- package/lib/utils.js +115 -0
- package/locales/en.json +75 -0
- package/package.json +51 -0
- package/views/webmentions-blocklist.njk +193 -0
- package/views/webmentions.njk +222 -0
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
|
+
}
|