@rmdes/indiekit-endpoint-microsub 1.0.54 → 1.0.56
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 +24 -13
- package/lib/activitypub/outbox-fetcher.js +14 -2
- package/lib/cache/redis.js +14 -4
- package/lib/controllers/channels.js +2 -2
- package/lib/controllers/reader.js +5 -23
- package/lib/controllers/search.js +6 -0
- package/lib/controllers/timeline.js +2 -2
- package/lib/feeds/capabilities.js +5 -0
- package/lib/feeds/fetcher.js +6 -0
- package/lib/feeds/normalizer.js +3 -55
- package/lib/media/proxy.js +83 -27
- package/lib/polling/processor.js +27 -4
- package/lib/polling/scheduler.js +2 -0
- package/lib/realtime/broker.js +6 -1
- package/lib/storage/channels.js +53 -42
- package/lib/storage/feeds.js +3 -1
- package/lib/storage/items.js +18 -24
- package/lib/utils/constants.js +7 -0
- package/lib/utils/csrf.js +51 -0
- package/lib/utils/sanitize.js +61 -0
- package/lib/utils/validation.js +8 -2
- package/lib/webmention/verifier.js +10 -21
- package/lib/websub/subscriber.js +12 -0
- package/package.json +3 -1
- package/views/actor.njk +2 -0
- package/views/channel-new.njk +1 -0
- package/views/channel.njk +9 -3
- package/views/compose.njk +1 -0
- package/views/deck-settings.njk +1 -0
- package/views/feed-edit.njk +3 -0
- package/views/feeds.njk +4 -0
- package/views/layouts/reader.njk +1 -0
- package/views/search.njk +2 -0
- package/views/settings.njk +2 -0
- package/views/timeline.njk +6 -3
package/lib/storage/channels.js
CHANGED
|
@@ -112,8 +112,35 @@ export async function createChannel(application, { name, userId }) {
|
|
|
112
112
|
return channel;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
import { UNREAD_RETENTION_DAYS } from "../utils/constants.js";
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get unread counts for multiple channels in a single aggregation query.
|
|
119
|
+
* Replaces the N+1 countDocuments pattern.
|
|
120
|
+
* @param {object} itemsCollection - MongoDB items collection
|
|
121
|
+
* @param {Array<import("mongodb").ObjectId>} channelIds - Channel IDs
|
|
122
|
+
* @param {string} userId - User ID
|
|
123
|
+
* @returns {Promise<Map<string, number>>} Map of channelId string → unread count
|
|
124
|
+
*/
|
|
125
|
+
async function getUnreadCounts(itemsCollection, channelIds, userId) {
|
|
126
|
+
const cutoffDate = new Date();
|
|
127
|
+
cutoffDate.setDate(cutoffDate.getDate() - UNREAD_RETENTION_DAYS);
|
|
128
|
+
|
|
129
|
+
const pipeline = [
|
|
130
|
+
{
|
|
131
|
+
$match: {
|
|
132
|
+
channelId: { $in: channelIds },
|
|
133
|
+
readBy: { $ne: userId },
|
|
134
|
+
published: { $gte: cutoffDate },
|
|
135
|
+
_stripped: { $ne: true },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{ $group: { _id: "$channelId", count: { $sum: 1 } } },
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const results = await itemsCollection.aggregate(pipeline).toArray();
|
|
142
|
+
return new Map(results.map((r) => [r._id.toString(), r.count]));
|
|
143
|
+
}
|
|
117
144
|
|
|
118
145
|
/**
|
|
119
146
|
* Get all channels for a user
|
|
@@ -133,27 +160,18 @@ export async function getChannels(application, userId) {
|
|
|
133
160
|
.sort({ order: 1 })
|
|
134
161
|
.toArray();
|
|
135
162
|
|
|
136
|
-
//
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
uid: channel.uid,
|
|
152
|
-
name: channel.name,
|
|
153
|
-
unread: unreadCount > 0 ? unreadCount : false,
|
|
154
|
-
};
|
|
155
|
-
}),
|
|
156
|
-
);
|
|
163
|
+
// Single aggregation query for all channel unread counts
|
|
164
|
+
const channelIds = channels.map((c) => c._id);
|
|
165
|
+
const unreadMap = await getUnreadCounts(itemsCollection, channelIds, userId);
|
|
166
|
+
|
|
167
|
+
const channelsWithCounts = channels.map((channel) => {
|
|
168
|
+
const unreadCount = unreadMap.get(channel._id.toString()) || 0;
|
|
169
|
+
return {
|
|
170
|
+
uid: channel.uid,
|
|
171
|
+
name: channel.name,
|
|
172
|
+
unread: unreadCount > 0 ? unreadCount : false,
|
|
173
|
+
};
|
|
174
|
+
});
|
|
157
175
|
|
|
158
176
|
// Always include notifications channel first
|
|
159
177
|
const notificationsChannel = channelsWithCounts.find(
|
|
@@ -189,25 +207,18 @@ export async function getChannelsWithColors(application, userId) {
|
|
|
189
207
|
.sort({ order: 1 })
|
|
190
208
|
.toArray();
|
|
191
209
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return {
|
|
205
|
-
...channel,
|
|
206
|
-
color: channel.color || getChannelColor(index),
|
|
207
|
-
unread: unreadCount > 0 ? unreadCount : false,
|
|
208
|
-
};
|
|
209
|
-
}),
|
|
210
|
-
);
|
|
210
|
+
// Single aggregation query for all channel unread counts
|
|
211
|
+
const channelIds = channels.map((c) => c._id);
|
|
212
|
+
const unreadMap = await getUnreadCounts(itemsCollection, channelIds, userId);
|
|
213
|
+
|
|
214
|
+
const enriched = channels.map((channel, index) => {
|
|
215
|
+
const unreadCount = unreadMap.get(channel._id.toString()) || 0;
|
|
216
|
+
return {
|
|
217
|
+
...channel,
|
|
218
|
+
color: channel.color || getChannelColor(index),
|
|
219
|
+
unread: unreadCount > 0 ? unreadCount : false,
|
|
220
|
+
};
|
|
221
|
+
});
|
|
211
222
|
|
|
212
223
|
// Notifications first, then by order
|
|
213
224
|
const notifications = enriched.find((c) => c.uid === "notifications");
|
package/lib/storage/feeds.js
CHANGED
|
@@ -249,7 +249,7 @@ export async function deleteFeedsForChannel(application, channelId) {
|
|
|
249
249
|
* @param {object} application - Indiekit application
|
|
250
250
|
* @returns {Promise<Array>} Array of feeds to fetch
|
|
251
251
|
*/
|
|
252
|
-
export async function getFeedsToFetch(application) {
|
|
252
|
+
export async function getFeedsToFetch(application, limit = 25) {
|
|
253
253
|
const collection = getCollection(application);
|
|
254
254
|
const now = new Date();
|
|
255
255
|
|
|
@@ -257,6 +257,8 @@ export async function getFeedsToFetch(application) {
|
|
|
257
257
|
.find({
|
|
258
258
|
$or: [{ nextFetchAt: undefined }, { nextFetchAt: { $lte: now } }],
|
|
259
259
|
})
|
|
260
|
+
.sort({ nextFetchAt: 1 })
|
|
261
|
+
.limit(limit)
|
|
260
262
|
.toArray();
|
|
261
263
|
}
|
|
262
264
|
|
package/lib/storage/items.js
CHANGED
|
@@ -55,12 +55,6 @@ function getCollection(application) {
|
|
|
55
55
|
export async function addItem(application, { channelId, feedId, uid, item }) {
|
|
56
56
|
const collection = getCollection(application);
|
|
57
57
|
|
|
58
|
-
// Check for duplicate
|
|
59
|
-
const existing = await collection.findOne({ channelId, uid });
|
|
60
|
-
if (existing) {
|
|
61
|
-
return; // Duplicate, don't add
|
|
62
|
-
}
|
|
63
|
-
|
|
64
58
|
const document = {
|
|
65
59
|
channelId,
|
|
66
60
|
feedId,
|
|
@@ -86,8 +80,14 @@ export async function addItem(application, { channelId, feedId, uid, item }) {
|
|
|
86
80
|
createdAt: new Date().toISOString(),
|
|
87
81
|
};
|
|
88
82
|
|
|
89
|
-
|
|
90
|
-
|
|
83
|
+
try {
|
|
84
|
+
await collection.insertOne(document);
|
|
85
|
+
return document;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
// Duplicate key error (unique index on channelId + uid) — expected for dedup
|
|
88
|
+
if (error.code === 11000) return;
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
@@ -841,8 +841,7 @@ export async function deleteItemsForFeed(application, feedId) {
|
|
|
841
841
|
return result.deletedCount;
|
|
842
842
|
}
|
|
843
843
|
|
|
844
|
-
|
|
845
|
-
const UNREAD_RETENTION_DAYS = 30;
|
|
844
|
+
import { UNREAD_RETENTION_DAYS } from "../utils/constants.js";
|
|
846
845
|
|
|
847
846
|
/**
|
|
848
847
|
* Get unread count for a channel
|
|
@@ -881,24 +880,13 @@ export async function searchItems(application, channelId, query, limit = 20) {
|
|
|
881
880
|
const objectId =
|
|
882
881
|
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
883
882
|
|
|
884
|
-
// Use
|
|
885
|
-
const escapedQuery = query.replaceAll(
|
|
886
|
-
/[$()*+.?[\\\]^{|}]/g,
|
|
887
|
-
String.raw`\$&`,
|
|
888
|
-
);
|
|
889
|
-
const regex = new RegExp(escapedQuery, "i");
|
|
883
|
+
// Use MongoDB text index for efficient full-text search
|
|
890
884
|
const items = await collection
|
|
891
885
|
.find({
|
|
892
886
|
channelId: objectId,
|
|
893
|
-
$
|
|
894
|
-
{ name: regex },
|
|
895
|
-
{ "content.text": regex },
|
|
896
|
-
{ "content.html": regex },
|
|
897
|
-
{ summary: regex },
|
|
898
|
-
],
|
|
887
|
+
$text: { $search: query },
|
|
899
888
|
})
|
|
900
|
-
|
|
901
|
-
.sort({ published: -1 })
|
|
889
|
+
.sort({ score: { $meta: "textScore" } })
|
|
902
890
|
.limit(limit)
|
|
903
891
|
.toArray();
|
|
904
892
|
|
|
@@ -945,6 +933,12 @@ export async function createIndexes(application) {
|
|
|
945
933
|
// URL matching index for mark_read operations
|
|
946
934
|
await collection.createIndex({ channelId: 1, url: 1 });
|
|
947
935
|
|
|
936
|
+
// Compound index for unread count aggregation (P7)
|
|
937
|
+
await collection.createIndex({ channelId: 1, _stripped: 1, published: -1 });
|
|
938
|
+
|
|
939
|
+
// Cross-channel timeline query index (P12)
|
|
940
|
+
await collection.createIndex({ _stripped: 1, published: -1 });
|
|
941
|
+
|
|
948
942
|
// Full-text search index with weights
|
|
949
943
|
// Higher weight = more importance in relevance scoring
|
|
950
944
|
await collection.createIndex(
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSRF protection middleware for reader UI
|
|
3
|
+
* Uses session-based tokens (not cookies)
|
|
4
|
+
* @module utils/csrf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate or retrieve CSRF token from session.
|
|
11
|
+
* Exposes token as `response.locals.csrfToken` for templates.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} request - Express request
|
|
14
|
+
* @param {object} response - Express response
|
|
15
|
+
* @param {Function} next - Express next
|
|
16
|
+
*/
|
|
17
|
+
export function csrfToken(request, response, next) {
|
|
18
|
+
if (request.session) {
|
|
19
|
+
if (!request.session.csrfToken) {
|
|
20
|
+
request.session.csrfToken = crypto.randomUUID();
|
|
21
|
+
}
|
|
22
|
+
response.locals.csrfToken = request.session.csrfToken;
|
|
23
|
+
}
|
|
24
|
+
next();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate CSRF token on POST requests.
|
|
29
|
+
* Checks `_csrf` field in body or `x-csrf-token` header.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} request - Express request
|
|
32
|
+
* @param {object} response - Express response
|
|
33
|
+
* @param {Function} next - Express next
|
|
34
|
+
*/
|
|
35
|
+
export function csrfValidate(request, response, next) {
|
|
36
|
+
if (request.method !== "POST") return next();
|
|
37
|
+
|
|
38
|
+
const sessionToken = request.session?.csrfToken;
|
|
39
|
+
if (!sessionToken) {
|
|
40
|
+
return response.status(403).send("CSRF token missing from session");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const submittedToken =
|
|
44
|
+
request.body?._csrf || request.headers["x-csrf-token"];
|
|
45
|
+
|
|
46
|
+
if (!submittedToken || submittedToken !== sessionToken) {
|
|
47
|
+
return response.status(403).send("CSRF token invalid");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
next();
|
|
51
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTML sanitization configuration
|
|
3
|
+
* Used by both RSS/Atom normalizer and ActivityPub outbox fetcher
|
|
4
|
+
* @module utils/sanitize
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Allowed HTML tags and attributes for sanitize-html
|
|
9
|
+
*/
|
|
10
|
+
export const SANITIZE_OPTIONS = {
|
|
11
|
+
allowedTags: [
|
|
12
|
+
"a",
|
|
13
|
+
"abbr",
|
|
14
|
+
"b",
|
|
15
|
+
"blockquote",
|
|
16
|
+
"br",
|
|
17
|
+
"code",
|
|
18
|
+
"em",
|
|
19
|
+
"figcaption",
|
|
20
|
+
"figure",
|
|
21
|
+
"h1",
|
|
22
|
+
"h2",
|
|
23
|
+
"h3",
|
|
24
|
+
"h4",
|
|
25
|
+
"h5",
|
|
26
|
+
"h6",
|
|
27
|
+
"hr",
|
|
28
|
+
"i",
|
|
29
|
+
"img",
|
|
30
|
+
"li",
|
|
31
|
+
"ol",
|
|
32
|
+
"p",
|
|
33
|
+
"pre",
|
|
34
|
+
"s",
|
|
35
|
+
"span",
|
|
36
|
+
"strike",
|
|
37
|
+
"strong",
|
|
38
|
+
"sub",
|
|
39
|
+
"sup",
|
|
40
|
+
"table",
|
|
41
|
+
"tbody",
|
|
42
|
+
"td",
|
|
43
|
+
"th",
|
|
44
|
+
"thead",
|
|
45
|
+
"tr",
|
|
46
|
+
"u",
|
|
47
|
+
"ul",
|
|
48
|
+
"video",
|
|
49
|
+
"audio",
|
|
50
|
+
"source",
|
|
51
|
+
],
|
|
52
|
+
allowedAttributes: {
|
|
53
|
+
a: ["href", "title", "rel"],
|
|
54
|
+
img: ["src", "alt", "title", "width", "height"],
|
|
55
|
+
video: ["src", "poster", "controls", "width", "height"],
|
|
56
|
+
audio: ["src", "controls"],
|
|
57
|
+
source: ["src", "type"],
|
|
58
|
+
"*": ["class"],
|
|
59
|
+
},
|
|
60
|
+
allowedSchemes: ["http", "https", "mailto"],
|
|
61
|
+
};
|
package/lib/utils/validation.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { IndiekitError } from "@indiekit/error";
|
|
7
|
+
import safeRegex from "safe-regex2";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Valid Microsub actions
|
|
@@ -30,7 +31,7 @@ export const VALID_CHANNEL_METHODS = ["delete", "order"];
|
|
|
30
31
|
/**
|
|
31
32
|
* Valid timeline methods
|
|
32
33
|
*/
|
|
33
|
-
export const VALID_TIMELINE_METHODS = ["mark_read", "mark_unread", "remove"];
|
|
34
|
+
export const VALID_TIMELINE_METHODS = ["mark_read", "mark_read_source", "mark_unread", "remove"];
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* Valid exclude types for channel filtering
|
|
@@ -173,7 +174,12 @@ export function validateExcludeRegex(pattern) {
|
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
try {
|
|
176
|
-
new RegExp(pattern);
|
|
177
|
+
const regex = new RegExp(pattern);
|
|
178
|
+
// Reject patterns vulnerable to catastrophic backtracking (ReDoS)
|
|
179
|
+
if (!safeRegex(regex)) {
|
|
180
|
+
console.warn(`[Microsub] Rejected unsafe regex pattern: ${pattern}`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
177
183
|
return pattern;
|
|
178
184
|
} catch {
|
|
179
185
|
return;
|
|
@@ -6,27 +6,8 @@
|
|
|
6
6
|
import { mf2 } from "microformats-parser";
|
|
7
7
|
import sanitizeHtml from "sanitize-html";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
*/
|
|
12
|
-
const SANITIZE_OPTIONS = {
|
|
13
|
-
allowedTags: [
|
|
14
|
-
"a", "abbr", "b", "blockquote", "br", "code", "em", "figcaption",
|
|
15
|
-
"figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img",
|
|
16
|
-
"li", "ol", "p", "pre", "s", "span", "strike", "strong", "sub",
|
|
17
|
-
"sup", "table", "tbody", "td", "th", "thead", "tr", "u", "ul",
|
|
18
|
-
"video", "audio", "source",
|
|
19
|
-
],
|
|
20
|
-
allowedAttributes: {
|
|
21
|
-
a: ["href", "title", "rel"],
|
|
22
|
-
img: ["src", "alt", "title", "width", "height"],
|
|
23
|
-
video: ["src", "poster", "controls", "width", "height"],
|
|
24
|
-
audio: ["src", "controls"],
|
|
25
|
-
source: ["src", "type"],
|
|
26
|
-
"*": ["class"],
|
|
27
|
-
},
|
|
28
|
-
allowedSchemes: ["http", "https", "mailto"],
|
|
29
|
-
};
|
|
9
|
+
import { isPrivateUrl } from "../media/proxy.js";
|
|
10
|
+
import { SANITIZE_OPTIONS } from "../utils/sanitize.js";
|
|
30
11
|
|
|
31
12
|
/**
|
|
32
13
|
* Verify a webmention
|
|
@@ -36,6 +17,14 @@ const SANITIZE_OPTIONS = {
|
|
|
36
17
|
*/
|
|
37
18
|
export async function verifyWebmention(source, target) {
|
|
38
19
|
try {
|
|
20
|
+
// SSRF protection — block private/internal IPs (highest risk: unauthenticated endpoint)
|
|
21
|
+
if (await isPrivateUrl(source)) {
|
|
22
|
+
return {
|
|
23
|
+
verified: false,
|
|
24
|
+
error: "Source URL blocked (private/internal address)",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
39
28
|
// Fetch the source URL
|
|
40
29
|
const response = await fetch(source, {
|
|
41
30
|
headers: {
|
package/lib/websub/subscriber.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import crypto from "node:crypto";
|
|
7
7
|
|
|
8
|
+
import { isPrivateUrl } from "../media/proxy.js";
|
|
8
9
|
import { updateFeedWebsub } from "../storage/feeds.js";
|
|
9
10
|
|
|
10
11
|
const DEFAULT_LEASE_SECONDS = 86_400 * 7; // 7 days
|
|
@@ -24,6 +25,12 @@ export async function subscribe(application, feed, callbackUrl) {
|
|
|
24
25
|
const topic = feed.websub.topic || feed.url;
|
|
25
26
|
const secret = generateSecret();
|
|
26
27
|
|
|
28
|
+
// SSRF protection — hub URL comes from untrusted feed content
|
|
29
|
+
if (await isPrivateUrl(feed.websub.hub)) {
|
|
30
|
+
console.warn(`[Microsub] WebSub blocked private hub URL: ${feed.websub.hub}`);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
try {
|
|
28
35
|
const response = await fetch(feed.websub.hub, {
|
|
29
36
|
method: "POST",
|
|
@@ -137,6 +144,11 @@ export function verifySignature(signature, body, secret) {
|
|
|
137
144
|
// Normalize algorithm name
|
|
138
145
|
const algo = algorithm.toLowerCase().replace("sha", "sha");
|
|
139
146
|
|
|
147
|
+
// Warn about deprecated SHA-1 (accepted for compatibility, but SHA-256 preferred)
|
|
148
|
+
if (algo === "sha1") {
|
|
149
|
+
console.warn("[Microsub] WebSub: hub using deprecated SHA-1 signature (SHA-256 preferred)");
|
|
150
|
+
}
|
|
151
|
+
|
|
140
152
|
try {
|
|
141
153
|
const expectedHash = crypto
|
|
142
154
|
.createHmac(algo, secret)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-microsub",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.56",
|
|
4
4
|
"description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|
|
@@ -46,6 +46,8 @@
|
|
|
46
46
|
"ioredis": "^5.3.0",
|
|
47
47
|
"luxon": "^3.4.0",
|
|
48
48
|
"microformats-parser": "^2.0.0",
|
|
49
|
+
"express-rate-limit": "^7.0.0",
|
|
50
|
+
"safe-regex2": "^4.0.0",
|
|
49
51
|
"sanitize-html": "^2.11.0"
|
|
50
52
|
},
|
|
51
53
|
"publishConfig": {
|
package/views/actor.njk
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
{% if canFollow %}
|
|
47
47
|
{% if isFollowing %}
|
|
48
48
|
<form action="{{ baseUrl }}/actor/unfollow" method="POST" style="display: inline;">
|
|
49
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
49
50
|
<input type="hidden" name="actorUrl" value="{{ actorUrl }}">
|
|
50
51
|
<button type="submit" class="button button--secondary button--small">
|
|
51
52
|
{{ icon("tick") }} Following
|
|
@@ -53,6 +54,7 @@
|
|
|
53
54
|
</form>
|
|
54
55
|
{% else %}
|
|
55
56
|
<form action="{{ baseUrl }}/actor/follow" method="POST" style="display: inline;">
|
|
57
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
56
58
|
<input type="hidden" name="actorUrl" value="{{ actorUrl }}">
|
|
57
59
|
<input type="hidden" name="actorName" value="{{ actor.name }}">
|
|
58
60
|
<button type="submit" class="button button--primary button--small">
|
package/views/channel-new.njk
CHANGED
package/views/channel.njk
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
<div class="ms-channel__actions">
|
|
8
8
|
{% if not showRead and items.length > 0 %}
|
|
9
9
|
<form action="{{ baseUrl }}/api/mark-read" method="POST" style="display: inline;">
|
|
10
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
10
11
|
<input type="hidden" name="channel" value="{{ channel.uid }}">
|
|
11
12
|
<input type="hidden" name="entry" value="last-read-entry">
|
|
12
13
|
<button type="submit" class="button button--secondary button--small">
|
|
@@ -40,6 +41,7 @@
|
|
|
40
41
|
|
|
41
42
|
{% if items.length > 0 %}
|
|
42
43
|
<form method="POST" action="{{ baseUrl }}/api/mark-view-read" id="mark-view-form">
|
|
44
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
43
45
|
<input type="hidden" name="channel" value="{{ channel.uid }}">
|
|
44
46
|
{% for item in items %}
|
|
45
47
|
{% if not item._is_read %}
|
|
@@ -95,6 +97,9 @@
|
|
|
95
97
|
</div>
|
|
96
98
|
|
|
97
99
|
<script type="module">
|
|
100
|
+
// CSRF token for AJAX requests
|
|
101
|
+
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
102
|
+
|
|
98
103
|
// Keyboard navigation (j/k for items, o to open)
|
|
99
104
|
const timeline = document.getElementById('timeline');
|
|
100
105
|
if (timeline) {
|
|
@@ -164,6 +169,7 @@
|
|
|
164
169
|
method: 'POST',
|
|
165
170
|
headers: {
|
|
166
171
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
172
|
+
'X-CSRF-Token': csrfToken,
|
|
167
173
|
},
|
|
168
174
|
body: formData.toString(),
|
|
169
175
|
credentials: 'same-origin'
|
|
@@ -233,7 +239,7 @@
|
|
|
233
239
|
|
|
234
240
|
const response = await fetch(microsubApiUrl, {
|
|
235
241
|
method: 'POST',
|
|
236
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
242
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrfToken },
|
|
237
243
|
body: formData.toString(),
|
|
238
244
|
credentials: 'same-origin'
|
|
239
245
|
});
|
|
@@ -289,7 +295,7 @@
|
|
|
289
295
|
try {
|
|
290
296
|
const response = await fetch('/readlater/save', {
|
|
291
297
|
method: 'POST',
|
|
292
|
-
headers: { 'Content-Type': 'application/json' },
|
|
298
|
+
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
|
|
293
299
|
body: JSON.stringify({ url, title: title || url, source: 'microsub' }),
|
|
294
300
|
credentials: 'same-origin'
|
|
295
301
|
});
|
|
@@ -406,7 +412,7 @@
|
|
|
406
412
|
try {
|
|
407
413
|
const response = await fetch(markViewApiUrl, {
|
|
408
414
|
method: 'POST',
|
|
409
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
415
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrfToken },
|
|
410
416
|
body: formData.toString(),
|
|
411
417
|
credentials: 'same-origin'
|
|
412
418
|
});
|
package/views/compose.njk
CHANGED
package/views/deck-settings.njk
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
</header>
|
|
11
11
|
|
|
12
12
|
<form action="{{ baseUrl }}/deck/settings" method="POST">
|
|
13
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
13
14
|
<p>Select which channels appear as columns in your deck, and their order.</p>
|
|
14
15
|
|
|
15
16
|
<div class="ms-deck-settings__channels">
|
package/views/feed-edit.njk
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
</div>
|
|
36
36
|
|
|
37
37
|
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/edit" class="ms-feed-edit__form">
|
|
38
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
38
39
|
{{ input({
|
|
39
40
|
id: "url",
|
|
40
41
|
name: "url",
|
|
@@ -64,6 +65,7 @@
|
|
|
64
65
|
<h3>Other Actions</h3>
|
|
65
66
|
|
|
66
67
|
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/rediscover" class="ms-feed-edit__action">
|
|
68
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
67
69
|
<p>Run feed discovery on the current URL to find the actual RSS/Atom feed.</p>
|
|
68
70
|
{{ button({
|
|
69
71
|
text: "Rediscover Feed",
|
|
@@ -72,6 +74,7 @@
|
|
|
72
74
|
</form>
|
|
73
75
|
|
|
74
76
|
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/refresh" class="ms-feed-edit__action">
|
|
77
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
75
78
|
<p>Force refresh this feed now.</p>
|
|
76
79
|
{{ button({
|
|
77
80
|
text: "Refresh Now",
|
package/views/feeds.njk
CHANGED
|
@@ -62,16 +62,19 @@
|
|
|
62
62
|
{{ icon("updatePost") }}
|
|
63
63
|
</a>
|
|
64
64
|
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/rediscover" style="display:inline;">
|
|
65
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
65
66
|
<button type="submit" class="button button--secondary button--small" title="Rediscover feed">
|
|
66
67
|
{{ icon("syndicate") }}
|
|
67
68
|
</button>
|
|
68
69
|
</form>
|
|
69
70
|
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/refresh" style="display:inline;">
|
|
71
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
70
72
|
<button type="submit" class="button button--secondary button--small" title="Refresh now">
|
|
71
73
|
{{ icon("repost") }}
|
|
72
74
|
</button>
|
|
73
75
|
</form>
|
|
74
76
|
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/remove" style="display:inline;">
|
|
77
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
75
78
|
<input type="hidden" name="url" value="{{ feed.url }}">
|
|
76
79
|
<button type="submit" class="button button--warning button--small" title="Unfollow">
|
|
77
80
|
{{ icon("delete") }}
|
|
@@ -91,6 +94,7 @@
|
|
|
91
94
|
<div class="ms-feeds__add">
|
|
92
95
|
<h3>{{ __("microsub.feeds.follow") }}</h3>
|
|
93
96
|
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="ms-feeds__form">
|
|
97
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
94
98
|
{{ input({
|
|
95
99
|
id: "url",
|
|
96
100
|
name: "url",
|
package/views/layouts/reader.njk
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
{% block content %}
|
|
8
8
|
<link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-microsub/styles.css">
|
|
9
|
+
<meta name="csrf-token" content="{{ csrfToken }}">
|
|
9
10
|
{% include "partials/breadcrumbs.njk" %}
|
|
10
11
|
{% include "partials/view-switcher.njk" %}
|
|
11
12
|
{% block reader %}{% endblock %}
|
package/views/search.njk
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
<h2>{{ __("microsub.search.title") }}</h2>
|
|
10
10
|
|
|
11
11
|
<form method="post" action="{{ baseUrl }}/search" class="ms-search__form">
|
|
12
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
12
13
|
{{ input({
|
|
13
14
|
id: "query",
|
|
14
15
|
name: "query",
|
|
@@ -60,6 +61,7 @@
|
|
|
60
61
|
</div>
|
|
61
62
|
{% if result.valid %}
|
|
62
63
|
<form method="post" action="{{ baseUrl }}/subscribe" class="ms-search__subscribe">
|
|
64
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
63
65
|
<input type="hidden" name="url" value="{{ result.url }}">
|
|
64
66
|
<label for="channel-{{ loop.index }}" class="-!-visually-hidden">{{ __("microsub.channels.title") }}</label>
|
|
65
67
|
<select name="channel" id="channel-{{ loop.index }}" class="select select--small">
|
package/views/settings.njk
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
<h2>{{ __("microsub.settings.title", { channel: channel.name }) }}</h2>
|
|
10
10
|
|
|
11
11
|
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/settings">
|
|
12
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
12
13
|
{{ checkboxes({
|
|
13
14
|
name: "excludeTypes",
|
|
14
15
|
values: channel.settings.excludeTypes,
|
|
@@ -64,6 +65,7 @@
|
|
|
64
65
|
<h3>{{ __("microsub.settings.dangerZone") }}</h3>
|
|
65
66
|
<p class="hint">{{ __("microsub.settings.deleteWarning") }}</p>
|
|
66
67
|
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/delete" onsubmit="return confirm('{{ __("microsub.settings.deleteConfirm") }}');">
|
|
68
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
67
69
|
{{ button({
|
|
68
70
|
text: __("microsub.settings.delete"),
|
|
69
71
|
classes: "button--danger"
|