@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.
@@ -112,8 +112,35 @@ export async function createChannel(application, { name, userId }) {
112
112
  return channel;
113
113
  }
114
114
 
115
- // Retention period for unread count (only count recent items)
116
- const UNREAD_RETENTION_DAYS = 30;
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
- // Calculate cutoff date for unread counts (only count recent items)
137
- const cutoffDate = new Date();
138
- cutoffDate.setDate(cutoffDate.getDate() - UNREAD_RETENTION_DAYS);
139
-
140
- // Get unread counts for each channel (only recent items)
141
- const channelsWithCounts = await Promise.all(
142
- channels.map(async (channel) => {
143
- const unreadCount = await itemsCollection.countDocuments({
144
- channelId: channel._id,
145
- readBy: { $ne: userId },
146
- published: { $gte: cutoffDate },
147
- _stripped: { $ne: true },
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
- const cutoffDate = new Date();
193
- cutoffDate.setDate(cutoffDate.getDate() - UNREAD_RETENTION_DAYS);
194
-
195
- const enriched = await Promise.all(
196
- channels.map(async (channel, index) => {
197
- const unreadCount = await itemsCollection.countDocuments({
198
- channelId: channel._id,
199
- readBy: { $ne: userId },
200
- published: { $gte: cutoffDate },
201
- _stripped: { $ne: true },
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");
@@ -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
 
@@ -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
- await collection.insertOne(document);
90
- return document;
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
- // Retention period for unread count (only count recent items)
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 regex search (consider adding text index for better performance)
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
- $or: [
894
- { name: regex },
895
- { "content.text": regex },
896
- { "content.html": regex },
897
- { summary: regex },
898
- ],
887
+ $text: { $search: query },
899
888
  })
900
- // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort
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,7 @@
1
+ /**
2
+ * Shared constants used across multiple modules
3
+ * @module utils/constants
4
+ */
5
+
6
+ /** Retention period for unread count queries (only count recent items) */
7
+ export const UNREAD_RETENTION_DAYS = 30;
@@ -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
+ };
@@ -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
- * Sanitize HTML options (matches normalizer.js)
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: {
@@ -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.54",
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">
@@ -9,6 +9,7 @@
9
9
  <h2>{{ __("microsub.channels.new") }}</h2>
10
10
 
11
11
  <form method="post" action="{{ baseUrl }}/channels/new">
12
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
12
13
  {{ input({
13
14
  id: "name",
14
15
  name: "name",
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
@@ -45,6 +45,7 @@
45
45
  {% endif %}
46
46
 
47
47
  <form method="post" action="{{ baseUrl }}/compose">
48
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
48
49
  {% if replyTo %}
49
50
  <input type="hidden" name="in-reply-to" value="{{ replyTo }}">
50
51
  {% endif %}
@@ -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">
@@ -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",
@@ -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">
@@ -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"