@rmdes/indiekit-endpoint-microsub 1.0.55 → 1.0.57

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.
Files changed (75) hide show
  1. package/assets/reader.js +408 -0
  2. package/index.js +61 -49
  3. package/lib/activitypub/outbox-fetcher.js +14 -2
  4. package/lib/cache/redis.js +26 -7
  5. package/lib/controllers/channels.js +2 -2
  6. package/lib/controllers/reader/actor.js +142 -0
  7. package/lib/controllers/reader/channel.js +301 -0
  8. package/lib/controllers/reader/compose.js +242 -0
  9. package/lib/controllers/reader/deck.js +129 -0
  10. package/lib/controllers/reader/feed-repair.js +117 -0
  11. package/lib/controllers/reader/feed.js +246 -0
  12. package/lib/controllers/reader/index.js +126 -0
  13. package/lib/controllers/reader/search.js +157 -0
  14. package/lib/controllers/reader/timeline.js +250 -0
  15. package/lib/controllers/search.js +6 -0
  16. package/lib/controllers/timeline.js +6 -4
  17. package/lib/feeds/atom.js +1 -1
  18. package/lib/feeds/capabilities.js +5 -0
  19. package/lib/feeds/fetcher.js +5 -28
  20. package/lib/feeds/hfeed.js +1 -1
  21. package/lib/feeds/jsonfeed.js +1 -1
  22. package/lib/feeds/normalizer-hfeed.js +209 -0
  23. package/lib/feeds/normalizer-jsonfeed.js +171 -0
  24. package/lib/feeds/normalizer-rss.js +178 -0
  25. package/lib/feeds/normalizer.js +22 -614
  26. package/lib/feeds/rss.js +1 -1
  27. package/lib/media/proxy.js +82 -27
  28. package/lib/polling/processor.js +30 -21
  29. package/lib/polling/scheduler.js +2 -0
  30. package/lib/realtime/broker.js +6 -1
  31. package/lib/storage/channels.js +53 -42
  32. package/lib/storage/feeds.js +3 -1
  33. package/lib/storage/items-read-state.js +287 -0
  34. package/lib/storage/items-retention.js +174 -0
  35. package/lib/storage/items-search.js +34 -0
  36. package/lib/storage/items.js +113 -610
  37. package/lib/storage/read-state.js +1 -1
  38. package/lib/utils/async-handler.js +7 -0
  39. package/lib/utils/constants.js +7 -0
  40. package/lib/utils/csrf.js +51 -0
  41. package/lib/utils/html.js +25 -0
  42. package/lib/utils/sanitize.js +61 -0
  43. package/lib/utils/source-type.js +28 -0
  44. package/lib/utils/validation.js +8 -2
  45. package/lib/webmention/processor.js +1 -1
  46. package/lib/webmention/verifier.js +10 -21
  47. package/lib/websub/subscriber.js +12 -0
  48. package/locales/de.json +3 -0
  49. package/locales/en.json +2 -0
  50. package/locales/es-419.json +3 -0
  51. package/locales/es.json +3 -0
  52. package/locales/fr.json +3 -0
  53. package/locales/hi.json +3 -0
  54. package/locales/id.json +3 -0
  55. package/locales/it.json +3 -0
  56. package/locales/nl.json +3 -0
  57. package/locales/pl.json +3 -0
  58. package/locales/pt-BR.json +3 -0
  59. package/locales/pt.json +3 -0
  60. package/locales/sr.json +3 -0
  61. package/locales/sv.json +3 -0
  62. package/locales/zh-Hans-CN.json +3 -0
  63. package/package.json +3 -1
  64. package/views/actor.njk +2 -0
  65. package/views/channel-new.njk +1 -0
  66. package/views/channel.njk +3 -344
  67. package/views/compose.njk +1 -0
  68. package/views/deck-settings.njk +1 -0
  69. package/views/feed-edit.njk +3 -0
  70. package/views/feeds.njk +4 -0
  71. package/views/layouts/reader.njk +1 -0
  72. package/views/search.njk +2 -0
  73. package/views/settings.njk +2 -0
  74. package/views/timeline.njk +3 -271
  75. package/lib/controllers/reader.js +0 -1580
@@ -3,7 +3,7 @@
3
3
  * @module storage/read-state
4
4
  */
5
5
 
6
- import { markItemsRead, markItemsUnread, getUnreadCount } from "./items.js";
6
+ import { markItemsRead, markItemsUnread, getUnreadCount } from "./items-read-state.js";
7
7
 
8
8
  /**
9
9
  * Mark entries as read for a user
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Wrap async route handlers to catch rejections and pass to Express error middleware
3
+ * @param {Function} fn - Async route handler
4
+ * @returns {Function} Wrapped handler
5
+ */
6
+ export const asyncHandler = (fn) => (req, res, next) =>
7
+ Promise.resolve(fn(req, res, next)).catch(next);
@@ -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,25 @@
1
+ /**
2
+ * Shared HTML utilities
3
+ * @module utils/html
4
+ */
5
+
6
+ /**
7
+ * Extract image URLs from HTML content (fallback for items without explicit photos)
8
+ * @param {string} html - HTML content
9
+ * @returns {string[]} Array of image URLs
10
+ */
11
+ export function extractImagesFromHtml(html) {
12
+ if (!html) {
13
+ return [];
14
+ }
15
+ const urls = [];
16
+ const imgRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
17
+ let match;
18
+ while ((match = imgRegex.exec(html)) !== null) {
19
+ const src = match[1];
20
+ if (src && !urls.includes(src)) {
21
+ urls.push(src);
22
+ }
23
+ }
24
+ return urls;
25
+ }
@@ -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
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * URL classification utilities
3
+ * @module utils/source-type
4
+ */
5
+
6
+ /**
7
+ * Classify a URL by its platform/protocol
8
+ * @param {string} url - URL to classify
9
+ * @returns {{ type: string, protocol: string }}
10
+ */
11
+ export function classifyUrl(url) {
12
+ if (!url || typeof url !== "string") return { type: "web", protocol: "web" };
13
+ const lower = url.toLowerCase();
14
+ if (lower.includes("bsky.app") || lower.includes("bluesky")) {
15
+ return { type: "bluesky", protocol: "atmosphere" };
16
+ }
17
+ if (
18
+ lower.includes("mastodon.") ||
19
+ lower.includes("mstdn.") ||
20
+ lower.includes("fosstodon.") ||
21
+ lower.includes("pleroma.") ||
22
+ lower.includes("misskey.") ||
23
+ lower.includes("pixelfed.")
24
+ ) {
25
+ return { type: "mastodon", protocol: "fediverse" };
26
+ }
27
+ return { type: "web", protocol: "web" };
28
+ }
@@ -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;
@@ -78,7 +78,7 @@ export async function processWebmention(application, source, target, userId) {
78
78
  }
79
79
 
80
80
  // Publish real-time event
81
- const redis = getRedisClient(application);
81
+ const redis = await getRedisClient(application);
82
82
  if (redis && userId) {
83
83
  await publishEvent(redis, `microsub:user:${userId}`, {
84
84
  type: "new-notification",
@@ -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/locales/de.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Leser",
5
5
  "empty": "Keine Elemente zum Anzeigen",
6
6
  "markAllRead": "Alle als gelesen markieren",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Gelesene anzeigen ({{count}})",
8
11
  "hideRead": "Gelesene Elemente ausblenden",
9
12
  "allRead": "Alles aufgeholt!",
package/locales/en.json CHANGED
@@ -5,6 +5,8 @@
5
5
  "empty": "No items to display",
6
6
  "markAllRead": "Mark all as read",
7
7
  "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
8
10
  "showRead": "Show read ({{count}})",
9
11
  "hideRead": "Hide read items",
10
12
  "allRead": "All caught up!",
@@ -4,6 +4,9 @@
4
4
  "title": "Lector",
5
5
  "empty": "No hay elementos para mostrar",
6
6
  "markAllRead": "Marcar todo como leído",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Mostrar leídos ({{count}})",
8
11
  "hideRead": "Ocultar elementos leídos",
9
12
  "allRead": "¡Todo al día!",
package/locales/es.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Lector",
5
5
  "empty": "No hay elementos para mostrar",
6
6
  "markAllRead": "Marcar todo como leído",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Mostrar leídos ({{count}})",
8
11
  "hideRead": "Ocultar elementos leídos",
9
12
  "allRead": "¡Todo al día!",
package/locales/fr.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Lecteur",
5
5
  "empty": "Aucun élément à afficher",
6
6
  "markAllRead": "Tout marquer comme lu",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Afficher les lus ({{count}})",
8
11
  "hideRead": "Masquer les éléments lus",
9
12
  "allRead": "Tout est à jour !",
package/locales/hi.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "रीडर",
5
5
  "empty": "प्रदर्शित करने के लिए कोई आइटम नहीं",
6
6
  "markAllRead": "सभी को पढ़ा हुआ चिह्नित करें",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "पढ़े हुए दिखाएं ({{count}})",
8
11
  "hideRead": "पढ़े हुए आइटम छिपाएं",
9
12
  "allRead": "सब पकड़ लिया!",
package/locales/id.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Pembaca",
5
5
  "empty": "Tidak ada item untuk ditampilkan",
6
6
  "markAllRead": "Tandai semua sudah dibaca",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Tampilkan yang sudah dibaca ({{count}})",
8
11
  "hideRead": "Sembunyikan item yang sudah dibaca",
9
12
  "allRead": "Semua sudah terbaca!",
package/locales/it.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Lettore",
5
5
  "empty": "Nessun elemento da visualizzare",
6
6
  "markAllRead": "Segna tutto come letto",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Mostra letti ({{count}})",
8
11
  "hideRead": "Nascondi elementi letti",
9
12
  "allRead": "Tutto aggiornato!",
package/locales/nl.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Lezer",
5
5
  "empty": "Geen items om weer te geven",
6
6
  "markAllRead": "Alles als gelezen markeren",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Toon gelezen ({{count}})",
8
11
  "hideRead": "Verberg gelezen items",
9
12
  "allRead": "Alles bijgewerkt!",
package/locales/pl.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Czytnik",
5
5
  "empty": "Brak elementów do wyświetlenia",
6
6
  "markAllRead": "Oznacz wszystkie jako przeczytane",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Pokaż przeczytane ({{count}})",
8
11
  "hideRead": "Ukryj przeczytane elementy",
9
12
  "allRead": "Wszystko przeczytane!",
@@ -4,6 +4,9 @@
4
4
  "title": "Leitor",
5
5
  "empty": "Nenhum item para exibir",
6
6
  "markAllRead": "Marcar tudo como lido",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Mostrar lidos ({{count}})",
8
11
  "hideRead": "Ocultar itens lidos",
9
12
  "allRead": "Tudo em dia!",
package/locales/pt.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Leitor",
5
5
  "empty": "Nenhum item para exibir",
6
6
  "markAllRead": "Marcar tudo como lido",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Mostrar lidos ({{count}})",
8
11
  "hideRead": "Ocultar itens lidos",
9
12
  "allRead": "Tudo em dia!",
package/locales/sr.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Читач",
5
5
  "empty": "Нема ставки за приказ",
6
6
  "markAllRead": "Означи све као прочитано",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Прикажи прочитано ({{count}})",
8
11
  "hideRead": "Сакриј прочитане ставке",
9
12
  "allRead": "Све ажурирано!",
package/locales/sv.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Läsare",
5
5
  "empty": "Inga objekt att visa",
6
6
  "markAllRead": "Markera allt som läst",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "Visa lästa ({{count}})",
8
11
  "hideRead": "Dölj lästa objekt",
9
12
  "allRead": "Allt är uppdaterat!",
@@ -4,6 +4,9 @@
4
4
  "title": "阅读器",
5
5
  "empty": "没有可显示的项目",
6
6
  "markAllRead": "全部标记为已读",
7
+ "markViewRead": "Mark current view as read",
8
+ "filterChannels": "Filter channels",
9
+ "apply": "Apply",
7
10
  "showRead": "显示已读 ({{count}})",
8
11
  "hideRead": "隐藏已读项目",
9
12
  "allRead": "全部已读!",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.55",
3
+ "version": "1.0.57",
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",