Authorize
@@ -301,6 +326,36 @@ router.post("/oauth/authorize", async (req, res, next) => {
decision,
} = req.body;
+ // Validate CSRF token
+ if (!validateCsrf(req)) {
+ return res.status(403).json({
+ error: "invalid_request",
+ error_description: "Invalid or missing CSRF token",
+ });
+ }
+
+ const collections = req.app.locals.mastodonCollections;
+
+ // Re-validate redirect_uri against registered app URIs.
+ // The GET handler validates this, but POST body can be tampered.
+ const app = await collections.ap_oauth_apps.findOne({ clientId: client_id });
+ if (!app) {
+ return res.status(400).json({
+ error: "invalid_client",
+ error_description: "Client application not found",
+ });
+ }
+ if (
+ redirect_uri &&
+ redirect_uri !== "urn:ietf:wg:oauth:2.0:oob" &&
+ !app.redirectUris.includes(redirect_uri)
+ ) {
+ return res.status(400).json({
+ error: "invalid_redirect_uri",
+ error_description: "Redirect URI not registered for this application",
+ });
+ }
+
// User denied
if (decision === "deny") {
if (redirect_uri && redirect_uri !== "urn:ietf:wg:oauth:2.0:oob") {
@@ -320,7 +375,6 @@ router.post("/oauth/authorize", async (req, res, next) => {
// Generate authorization code
const code = randomHex(32);
- const collections = req.app.locals.mastodonCollections;
// Note: accessToken is NOT set here — it's added later during token exchange.
// The sparse unique index on accessToken skips documents where the field is
@@ -354,7 +408,7 @@ router.post("/oauth/authorize", async (req, res, next) => {
Authorization Code
Copy this code and paste it into the application:
-
${code}
+
${escapeHtml(code)}
`);
}
@@ -390,7 +444,7 @@ router.post("/oauth/token", async (req, res, next) => {
const app = await collections.ap_oauth_apps.findOne({
clientId,
- clientSecret,
+ clientSecretHash: hashSecret(clientSecret),
confidential: true,
});
@@ -410,6 +464,7 @@ router.post("/oauth/token", async (req, res, next) => {
accessToken,
createdAt: new Date(),
grantType: "client_credentials",
+ expiresAt: new Date(Date.now() + 3600 * 1000),
});
return res.json({
@@ -417,6 +472,7 @@ router.post("/oauth/token", async (req, res, next) => {
token_type: "Bearer",
scope: "read",
created_at: Math.floor(Date.now() / 1000),
+ expires_in: 3600,
});
}
@@ -447,7 +503,14 @@ router.post("/oauth/token", async (req, res, next) => {
const newRefreshToken = randomHex(64);
await collections.ap_oauth_tokens.updateOne(
{ _id: existing._id },
- { $set: { accessToken: newAccessToken, refreshToken: newRefreshToken } },
+ {
+ $set: {
+ accessToken: newAccessToken,
+ refreshToken: newRefreshToken,
+ expiresAt: new Date(Date.now() + 3600 * 1000),
+ refreshExpiresAt: new Date(Date.now() + 90 * 24 * 3600 * 1000),
+ },
+ },
);
return res.json({
@@ -456,6 +519,7 @@ router.post("/oauth/token", async (req, res, next) => {
scope: existing.scopes.join(" "),
created_at: Math.floor(existing.createdAt.getTime() / 1000),
refresh_token: newRefreshToken,
+ expires_in: 3600,
});
}
@@ -523,13 +587,21 @@ router.post("/oauth/token", async (req, res, next) => {
}
}
- // Generate access token and refresh token.
- // Clear expiresAt — it was set for the auth code, not the access token.
+ // Generate access token and refresh token with expiry.
+ const ACCESS_TOKEN_TTL = 3600 * 1000; // 1 hour
+ const REFRESH_TOKEN_TTL = 90 * 24 * 3600 * 1000; // 90 days
const accessToken = randomHex(64);
const refreshToken = randomHex(64);
await collections.ap_oauth_tokens.updateOne(
{ _id: grant._id },
- { $set: { accessToken, refreshToken, expiresAt: null } },
+ {
+ $set: {
+ accessToken,
+ refreshToken,
+ expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL),
+ refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL),
+ },
+ },
);
res.json({
@@ -538,6 +610,7 @@ router.post("/oauth/token", async (req, res, next) => {
scope: grant.scopes.join(" "),
created_at: Math.floor(grant.createdAt.getTime() / 1000),
refresh_token: refreshToken,
+ expires_in: 3600,
});
} catch (error) {
next(error);
@@ -622,7 +695,7 @@ function redirectToUri(res, originalUri, fullUrl) {
-
+
Redirecting…
diff --git a/lib/mastodon/routes/search.js b/lib/mastodon/routes/search.js
index 6c4a259..9f41887 100644
--- a/lib/mastodon/routes/search.js
+++ b/lib/mastodon/routes/search.js
@@ -8,12 +8,14 @@ import { serializeStatus } from "../entities/status.js";
import { serializeAccount } from "../entities/account.js";
import { parseLimit } from "../helpers/pagination.js";
import { resolveRemoteAccount } from "../helpers/resolve-account.js";
+import { tokenRequired } from "../middleware/token-required.js";
+import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v2/search ─────────────────────────────────────────────────────
-router.get("/api/v2/search", async (req, res, next) => {
+router.get("/api/v2/search", tokenRequired, scopeRequired("read", "read:search"), async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js
index 86af005..ac0f9bf 100644
--- a/lib/mastodon/routes/statuses.js
+++ b/lib/mastodon/routes/statuses.js
@@ -22,12 +22,14 @@ import {
bookmarkPost, unbookmarkPost,
} from "../helpers/interactions.js";
import { addTimelineItem } from "../../storage/timeline.js";
+import { tokenRequired } from "../middleware/token-required.js";
+import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v1/statuses/:id ───────────────────────────────────────────────
-router.get("/api/v1/statuses/:id", async (req, res, next) => {
+router.get("/api/v1/statuses/:id", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
@@ -55,7 +57,7 @@ router.get("/api/v1/statuses/:id", async (req, res, next) => {
// ─── GET /api/v1/statuses/:id/context ───────────────────────────────────────
-router.get("/api/v1/statuses/:id/context", async (req, res, next) => {
+router.get("/api/v1/statuses/:id/context", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
@@ -134,13 +136,8 @@ router.get("/api/v1/statuses/:id/context", async (req, res, next) => {
// Creates a post via the Micropub pipeline so it goes through the full flow:
// Micropub → content file → Eleventy build → syndication → AP federation.
-router.post("/api/v1/statuses", async (req, res, next) => {
+router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => {
try {
- const token = req.mastodonToken;
- if (!token) {
- return res.status(401).json({ error: "The access token is invalid" });
- }
-
const { application, publication } = req.app.locals;
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
@@ -302,13 +299,8 @@ router.post("/api/v1/statuses", async (req, res, next) => {
// Deletes via Micropub pipeline (removes content file + MongoDB post) and
// cleans up the ap_timeline entry.
-router.delete("/api/v1/statuses/:id", async (req, res, next) => {
+router.delete("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => {
try {
- const token = req.mastodonToken;
- if (!token) {
- return res.status(401).json({ error: "The access token is invalid" });
- }
-
const { application, publication } = req.app.locals;
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
@@ -368,27 +360,22 @@ router.delete("/api/v1/statuses/:id", async (req, res, next) => {
// ─── GET /api/v1/statuses/:id/favourited_by ─────────────────────────────────
-router.get("/api/v1/statuses/:id/favourited_by", async (req, res) => {
+router.get("/api/v1/statuses/:id/favourited_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res) => {
// Stub — we don't track who favourited remotely
res.json([]);
});
// ─── GET /api/v1/statuses/:id/reblogged_by ──────────────────────────────────
-router.get("/api/v1/statuses/:id/reblogged_by", async (req, res) => {
+router.get("/api/v1/statuses/:id/reblogged_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res) => {
// Stub — we don't track who boosted remotely
res.json([]);
});
// ─── POST /api/v1/statuses/:id/favourite ────────────────────────────────────
-router.post("/api/v1/statuses/:id/favourite", async (req, res, next) => {
+router.post("/api/v1/statuses/:id/favourite", tokenRequired, scopeRequired("write", "write:favourites"), async (req, res, next) => {
try {
- const token = req.mastodonToken;
- if (!token) {
- return res.status(401).json({ error: "The access token is invalid" });
- }
-
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
@@ -413,13 +400,8 @@ router.post("/api/v1/statuses/:id/favourite", async (req, res, next) => {
// ─── POST /api/v1/statuses/:id/unfavourite ──────────────────────────────────
-router.post("/api/v1/statuses/:id/unfavourite", async (req, res, next) => {
+router.post("/api/v1/statuses/:id/unfavourite", tokenRequired, scopeRequired("write", "write:favourites"), async (req, res, next) => {
try {
- const token = req.mastodonToken;
- if (!token) {
- return res.status(401).json({ error: "The access token is invalid" });
- }
-
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
@@ -443,13 +425,8 @@ router.post("/api/v1/statuses/:id/unfavourite", async (req, res, next) => {
// ─── POST /api/v1/statuses/:id/reblog ───────────────────────────────────────
-router.post("/api/v1/statuses/:id/reblog", async (req, res, next) => {
+router.post("/api/v1/statuses/:id/reblog", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => {
try {
- const token = req.mastodonToken;
- if (!token) {
- return res.status(401).json({ error: "The access token is invalid" });
- }
-
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
@@ -473,13 +450,8 @@ router.post("/api/v1/statuses/:id/reblog", async (req, res, next) => {
// ─── POST /api/v1/statuses/:id/unreblog ─────────────────────────────────────
-router.post("/api/v1/statuses/:id/unreblog", async (req, res, next) => {
+router.post("/api/v1/statuses/:id/unreblog", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => {
try {
- const token = req.mastodonToken;
- if (!token) {
- return res.status(401).json({ error: "The access token is invalid" });
- }
-
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
@@ -503,13 +475,8 @@ router.post("/api/v1/statuses/:id/unreblog", async (req, res, next) => {
// ─── POST /api/v1/statuses/:id/bookmark ─────────────────────────────────────
-router.post("/api/v1/statuses/:id/bookmark", async (req, res, next) => {
+router.post("/api/v1/statuses/:id/bookmark", tokenRequired, scopeRequired("write", "write:bookmarks"), async (req, res, next) => {
try {
- const token = req.mastodonToken;
- if (!token) {
- return res.status(401).json({ error: "The access token is invalid" });
- }
-
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
@@ -531,13 +498,8 @@ router.post("/api/v1/statuses/:id/bookmark", async (req, res, next) => {
// ─── POST /api/v1/statuses/:id/unbookmark ───────────────────────────────────
-router.post("/api/v1/statuses/:id/unbookmark", async (req, res, next) => {
+router.post("/api/v1/statuses/:id/unbookmark", tokenRequired, scopeRequired("write", "write:bookmarks"), async (req, res, next) => {
try {
- const token = req.mastodonToken;
- if (!token) {
- return res.status(401).json({ error: "The access token is invalid" });
- }
-
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
diff --git a/lib/mastodon/routes/timelines.js b/lib/mastodon/routes/timelines.js
index 5e628e5..991565e 100644
--- a/lib/mastodon/routes/timelines.js
+++ b/lib/mastodon/routes/timelines.js
@@ -10,18 +10,15 @@ import { serializeStatus } from "../entities/status.js";
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
import { loadModerationData, applyModerationFilters } from "../../item-processing.js";
import { enrichAccountStats } from "../helpers/enrich-accounts.js";
+import { tokenRequired } from "../middleware/token-required.js";
+import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v1/timelines/home ─────────────────────────────────────────────
-router.get("/api/v1/timelines/home", async (req, res, next) => {
+router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try {
- const token = req.mastodonToken;
- if (!token) {
- return res.status(401).json({ error: "The access token is invalid" });
- }
-
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit);
diff --git a/lib/og-unfurl.js b/lib/og-unfurl.js
index a3505c0..6d514f3 100644
--- a/lib/og-unfurl.js
+++ b/lib/og-unfurl.js
@@ -3,6 +3,8 @@
* @module og-unfurl
*/
+import { lookup } from "node:dns/promises";
+import { isIP } from "node:net";
import { unfurl } from "unfurl.js";
import { extractObjectData } from "./timeline-store.js";
import { lookupWithSecurity } from "./lookup-helpers.js";
@@ -45,45 +47,58 @@ function extractDomain(url) {
}
/**
- * Check if a URL points to a private/reserved IP or localhost (SSRF protection)
- * @param {string} url - URL to check
- * @returns {boolean} True if URL targets a private network
+ * Check if an IP address is in a private/reserved range.
+ * @param {string} ip - IPv4 or IPv6 address
+ * @returns {boolean} True if private/reserved
*/
-function isPrivateUrl(url) {
+function isPrivateIP(ip) {
+ if (isIP(ip) === 4) {
+ const parts = ip.split(".").map(Number);
+ const [a, b] = parts;
+ if (a === 10) return true; // 10.0.0.0/8
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local)
+ if (a === 127) return true; // 127.0.0.0/8
+ if (a === 0) return true; // 0.0.0.0/8
+ }
+ if (isIP(ip) === 6) {
+ const lower = ip.toLowerCase();
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // ULA
+ if (lower.startsWith("fe80")) return true; // link-local
+ if (lower === "::1") return true; // loopback
+ }
+ return false;
+}
+
+/**
+ * Check if a URL resolves to a private/reserved IP (SSRF protection).
+ * Performs DNS resolution to defeat DNS rebinding attacks.
+ * @param {string} url - URL to check
+ * @returns {Promise
} True if URL targets a private network
+ */
+async function isPrivateResolved(url) {
try {
const urlObj = new URL(url);
- const hostname = urlObj.hostname.toLowerCase();
// Block non-http(s) schemes
if (urlObj.protocol !== "http:" && urlObj.protocol !== "https:") {
return true;
}
- // Block localhost variants
- if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]") {
- return true;
- }
+ const hostname = urlObj.hostname.toLowerCase().replace(/^\[|\]$/g, "");
- // Block private IPv4 ranges
- const ipv4Match = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
- if (ipv4Match) {
- const [, a, b] = ipv4Match.map(Number);
- if (a === 10) return true; // 10.0.0.0/8
- if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
- if (a === 192 && b === 168) return true; // 192.168.0.0/16
- if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local / cloud metadata)
- if (a === 127) return true; // 127.0.0.0/8
- if (a === 0) return true; // 0.0.0.0/8
- }
+ // Block obvious localhost variants
+ if (hostname === "localhost") return true;
- // Block IPv6 private ranges (basic check)
- if (hostname.startsWith("[fc") || hostname.startsWith("[fd") || hostname.startsWith("[fe80")) {
- return true;
- }
+ // If hostname is already an IP, check directly (no DNS needed)
+ if (isIP(hostname)) return isPrivateIP(hostname);
- return false;
+ // DNS resolution — check the resolved IP
+ const { address } = await lookup(hostname);
+ return isPrivateIP(address);
} catch {
- return true; // Invalid URL, treat as private
+ return true; // DNS failure or invalid URL — treat as private
}
}
@@ -115,14 +130,14 @@ function extractLinks(html) {
/**
* Check if URL is likely an ActivityPub object or media file
* @param {string} url - URL to check
- * @returns {boolean} True if URL should be skipped
+ * @returns {Promise} True if URL should be skipped
*/
-function shouldSkipUrl(url) {
+async function shouldSkipUrl(url) {
try {
const urlObj = new URL(url);
// SSRF protection — skip private/internal URLs
- if (isPrivateUrl(url)) {
+ if (await isPrivateResolved(url)) {
return true;
}
@@ -158,9 +173,9 @@ export async function fetchLinkPreviews(html) {
const links = extractLinks(html);
- // Filter links
- const urlsToFetch = links
- .filter((link) => {
+ // Filter links — async because shouldSkipUrl performs DNS resolution
+ const filterResults = await Promise.all(
+ links.map(async (link) => {
// Skip mention links (class="mention")
if (link.classes.includes("mention")) return false;
@@ -168,10 +183,14 @@ export async function fetchLinkPreviews(html) {
if (link.classes.includes("hashtag")) return false;
// Skip AP object URLs and media files
- if (shouldSkipUrl(link.url)) return false;
+ if (await shouldSkipUrl(link.url)) return false;
return true;
- })
+ }),
+ );
+
+ const urlsToFetch = links
+ .filter((_, index) => filterResults[index])
.map((link) => link.url)
.filter((url, index, self) => self.indexOf(url) === index) // Dedupe
.slice(0, MAX_PREVIEWS); // Cap at max
diff --git a/lib/storage/moderation.js b/lib/storage/moderation.js
index f30dcec..91f693d 100644
--- a/lib/storage/moderation.js
+++ b/lib/storage/moderation.js
@@ -3,6 +3,8 @@
* @module storage/moderation
*/
+import { invalidateModerationCache } from "../item-processing.js";
+
/**
* Add a muted URL or keyword
* @param {object} collections - MongoDB collections
@@ -32,6 +34,7 @@ export async function addMuted(collections, { url, keyword }) {
const filter = url ? { url } : { keyword };
await ap_muted.updateOne(filter, { $setOnInsert: entry }, { upsert: true });
+ invalidateModerationCache();
return await ap_muted.findOne(filter);
}
@@ -55,7 +58,9 @@ export async function removeMuted(collections, { url, keyword }) {
throw new Error("Either url or keyword must be provided");
}
- return await ap_muted.deleteOne(filter);
+ const result = await ap_muted.deleteOne(filter);
+ invalidateModerationCache();
+ return result;
}
/**
@@ -122,6 +127,7 @@ export async function addBlocked(collections, url) {
// Upsert to avoid duplicates
await ap_blocked.updateOne({ url }, { $setOnInsert: entry }, { upsert: true });
+ invalidateModerationCache();
return await ap_blocked.findOne({ url });
}
@@ -133,7 +139,9 @@ export async function addBlocked(collections, url) {
*/
export async function removeBlocked(collections, url) {
const { ap_blocked } = collections;
- return await ap_blocked.deleteOne({ url });
+ const result = await ap_blocked.deleteOne({ url });
+ invalidateModerationCache();
+ return result;
}
/**
@@ -204,4 +212,5 @@ export async function setFilterMode(collections, mode) {
if (!ap_profile) return;
const valid = mode === "warn" ? "warn" : "hide";
await ap_profile.updateOne({}, { $set: { moderationFilterMode: valid } });
+ invalidateModerationCache();
}
diff --git a/lib/syndicator.js b/lib/syndicator.js
new file mode 100644
index 0000000..b76596d
--- /dev/null
+++ b/lib/syndicator.js
@@ -0,0 +1,239 @@
+/**
+ * ActivityPub syndicator — delivers posts to followers via Fedify.
+ * @module syndicator
+ */
+import {
+ jf2ToAS2Activity,
+ parseMentions,
+} from "./jf2-to-as2.js";
+import { lookupWithSecurity } from "./lookup-helpers.js";
+import { logActivity } from "./activity-log.js";
+
+/**
+ * Create the ActivityPub syndicator object.
+ * @param {object} plugin - ActivityPubEndpoint instance
+ * @returns {object} Syndicator compatible with Indiekit's syndicator API
+ */
+export function createSyndicator(plugin) {
+ return {
+ name: "ActivityPub syndicator",
+ options: { checked: plugin.options.checked },
+
+ get info() {
+ const hostname = plugin._publicationUrl
+ ? new URL(plugin._publicationUrl).hostname
+ : "example.com";
+ return {
+ checked: plugin.options.checked,
+ name: `@${plugin.options.actor.handle}@${hostname}`,
+ uid: plugin._publicationUrl || "https://example.com/",
+ service: {
+ name: "ActivityPub (Fediverse)",
+ photo: "/assets/@rmdes-indiekit-endpoint-activitypub/icon.svg",
+ url: plugin._publicationUrl || "https://example.com/",
+ },
+ };
+ },
+
+ async syndicate(properties) {
+ if (!plugin._federation) {
+ return undefined;
+ }
+
+ try {
+ const actorUrl = plugin._getActorUrl();
+ const handle = plugin.options.actor.handle;
+
+ const ctx = plugin._federation.createContext(
+ new URL(plugin._publicationUrl),
+ { handle, publicationUrl: plugin._publicationUrl },
+ );
+
+ // For replies, resolve the original post author for proper
+ // addressing (CC) and direct inbox delivery
+ let replyToActor = null;
+ if (properties["in-reply-to"]) {
+ try {
+ const remoteObject = await lookupWithSecurity(ctx,
+ new URL(properties["in-reply-to"]),
+ );
+ if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
+ const author = await remoteObject.getAttributedTo();
+ const authorActor = Array.isArray(author) ? author[0] : author;
+ if (authorActor?.id) {
+ replyToActor = {
+ url: authorActor.id.href,
+ handle: authorActor.preferredUsername || null,
+ recipient: authorActor,
+ };
+ console.info(
+ `[ActivityPub] Reply to ${properties["in-reply-to"]} — resolved author: ${replyToActor.url}`,
+ );
+ }
+ }
+ } catch (error) {
+ console.warn(
+ `[ActivityPub] Could not resolve reply-to author for ${properties["in-reply-to"]}: ${error.message}`,
+ );
+ }
+ }
+
+ // Resolve @user@domain mentions in content via WebFinger
+ const contentText = properties.content?.html || properties.content || "";
+ const mentionHandles = parseMentions(contentText);
+ const resolvedMentions = [];
+ const mentionRecipients = [];
+
+ for (const { handle } of mentionHandles) {
+ try {
+ const mentionedActor = await lookupWithSecurity(ctx,
+ new URL(`acct:${handle}`),
+ );
+ if (mentionedActor?.id) {
+ resolvedMentions.push({
+ handle,
+ actorUrl: mentionedActor.id.href,
+ profileUrl: mentionedActor.url?.href || null,
+ });
+ mentionRecipients.push({
+ handle,
+ actorUrl: mentionedActor.id.href,
+ actor: mentionedActor,
+ });
+ console.info(
+ `[ActivityPub] Resolved mention @${handle} → ${mentionedActor.id.href}`,
+ );
+ }
+ } catch (error) {
+ console.warn(
+ `[ActivityPub] Could not resolve mention @${handle}: ${error.message}`,
+ );
+ // Still add with no actorUrl so it gets a fallback link
+ resolvedMentions.push({ handle, actorUrl: null });
+ }
+ }
+
+ const activity = jf2ToAS2Activity(
+ properties,
+ actorUrl,
+ plugin._publicationUrl,
+ {
+ replyToActorUrl: replyToActor?.url,
+ replyToActorHandle: replyToActor?.handle,
+ visibility: plugin.options.defaultVisibility,
+ mentions: resolvedMentions,
+ },
+ );
+
+ if (!activity) {
+ await logActivity(plugin._collections.ap_activities, {
+ direction: "outbound",
+ type: "Syndicate",
+ actorUrl: plugin._publicationUrl,
+ objectUrl: properties.url,
+ summary: `Syndication skipped: could not convert post to AS2`,
+ });
+ return undefined;
+ }
+
+ // Count followers for logging
+ const followerCount =
+ await plugin._collections.ap_followers.countDocuments();
+
+ console.info(
+ `[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
+ );
+
+ // Send to followers via shared inboxes with collection sync (FEP-8fcf)
+ await ctx.sendActivity(
+ { identifier: handle },
+ "followers",
+ activity,
+ {
+ preferSharedInbox: true,
+ syncCollection: true,
+ orderingKey: properties.url,
+ },
+ );
+
+ // For replies, also deliver to the original post author's inbox
+ // so their server can thread the reply under the original post
+ if (replyToActor?.recipient) {
+ try {
+ await ctx.sendActivity(
+ { identifier: handle },
+ replyToActor.recipient,
+ activity,
+ { orderingKey: properties.url },
+ );
+ console.info(
+ `[ActivityPub] Reply delivered to author: ${replyToActor.url}`,
+ );
+ } catch (error) {
+ console.warn(
+ `[ActivityPub] Failed to deliver reply to ${replyToActor.url}: ${error.message}`,
+ );
+ }
+ }
+
+ // Deliver to mentioned actors' inboxes (skip reply-to author, already delivered above)
+ for (const { handle: mHandle, actorUrl: mUrl, actor: mActor } of mentionRecipients) {
+ if (replyToActor?.url === mUrl) continue;
+ try {
+ await ctx.sendActivity(
+ { identifier: handle },
+ mActor,
+ activity,
+ { orderingKey: properties.url },
+ );
+ console.info(
+ `[ActivityPub] Mention delivered to @${mHandle}: ${mUrl}`,
+ );
+ } catch (error) {
+ console.warn(
+ `[ActivityPub] Failed to deliver mention to @${mHandle}: ${error.message}`,
+ );
+ }
+ }
+
+ // Determine activity type name
+ const typeName =
+ activity.constructor?.name || "Create";
+ const replyNote = replyToActor
+ ? ` (reply to ${replyToActor.url})`
+ : "";
+ const mentionNote = mentionRecipients.length > 0
+ ? ` (mentions: ${mentionRecipients.map(m => `@${m.handle}`).join(", ")})`
+ : "";
+
+ await logActivity(plugin._collections.ap_activities, {
+ direction: "outbound",
+ type: typeName,
+ actorUrl: plugin._publicationUrl,
+ objectUrl: properties.url,
+ targetUrl: properties["in-reply-to"] || undefined,
+ summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}${mentionNote}`,
+ });
+
+ console.info(
+ `[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`,
+ );
+
+ return properties.url || undefined;
+ } catch (error) {
+ console.error("[ActivityPub] Syndication failed:", error.message);
+ await logActivity(plugin._collections.ap_activities, {
+ direction: "outbound",
+ type: "Syndicate",
+ actorUrl: plugin._publicationUrl,
+ objectUrl: properties.url,
+ summary: `Syndication failed: ${error.message}`,
+ }).catch(() => {});
+ return undefined;
+ }
+ },
+
+ delete: async (url) => plugin.delete(url),
+ update: async (properties) => plugin.update(properties),
+ };
+}
diff --git a/lib/timeline-store.js b/lib/timeline-store.js
index 28b05bf..8dfd120 100644
--- a/lib/timeline-store.js
+++ b/lib/timeline-store.js
@@ -28,7 +28,7 @@ export function sanitizeContent(html) {
},
allowedSchemes: ["http", "https", "mailto"],
allowedSchemesByTag: {
- img: ["http", "https", "data"]
+ img: ["http", "https"]
}
});
}
@@ -46,11 +46,16 @@ export function replaceCustomEmoji(html, emojis) {
if (!emojis?.length || !html) return html;
let result = html;
for (const { shortcode, url } of emojis) {
+ // Validate URL is HTTP(S) only — reject data:, javascript:, etc.
+ if (!url || (!url.startsWith("https://") && !url.startsWith("http://"))) continue;
+ // Escape HTML special characters in URL and shortcode to prevent attribute injection
+ const safeUrl = url.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
+ const safeShortcode = shortcode.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
const escaped = shortcode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`:${escaped}:`, "g");
result = result.replace(
pattern,
- ` `,
+ ` `,
);
}
return result;
@@ -347,20 +352,11 @@ export async function extractObjectData(object, options = {}) {
// Quote URL — Fedify reads quoteUrl / _misskey_quote / quoteUri
const quoteUrl = object.quoteUrl?.href || "";
- // Interaction counts — extract from AP Collection objects
+ // Interaction counts — not fetched at ingest time. The three collection
+ // fetches (getReplies, getLikes, getShares) each trigger an HTTP round-trip
+ // for counts that are ephemeral and stale moments after fetching. Removed
+ // per audit M11 to save 3 network calls per inbound activity.
const counts = { replies: null, boosts: null, likes: null };
- try {
- const replies = await object.getReplies?.(loaderOpts);
- if (replies?.totalItems != null) counts.replies = replies.totalItems;
- } catch { /* ignore — collection may not exist */ }
- try {
- const likes = await object.getLikes?.(loaderOpts);
- if (likes?.totalItems != null) counts.likes = likes.totalItems;
- } catch { /* ignore */ }
- try {
- const shares = await object.getShares?.(loaderOpts);
- if (shares?.totalItems != null) counts.boosts = shares.totalItems;
- } catch { /* ignore */ }
// Replace custom emoji :shortcode: in content with inline tags.
// Applied after sanitization — these are trusted emoji from the post's tags.
diff --git a/package-lock.json b/package-lock.json
index 91815f4..455819b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
- "version": "2.1.2",
+ "version": "3.8.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@rmdes/indiekit-endpoint-activitypub",
- "version": "2.1.2",
+ "version": "3.8.7",
"license": "MIT",
"dependencies": {
"@fedify/debugger": "^2.0.0",
@@ -14,6 +14,7 @@
"@fedify/redis": "^2.0.0",
"@js-temporal/polyfill": "^0.5.0",
"express": "^5.0.0",
+ "express-rate-limit": "^7.5.1",
"ioredis": "^5.9.3",
"sanitize-html": "^2.13.1",
"unfurl.js": "^6.4.0"
@@ -22,6 +23,7 @@
"node": ">=22"
},
"peerDependencies": {
+ "@indiekit/endpoint-micropub": "^1.0.0-beta.25",
"@indiekit/error": "^1.0.0-beta.25",
"@indiekit/frontend": "^1.0.0-beta.25"
}
@@ -1167,10 +1169,31 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@indiekit/endpoint-micropub": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@indiekit/endpoint-micropub/-/endpoint-micropub-1.0.0-beta.27.tgz",
+ "integrity": "sha512-0NAiAYte5u+w3kh2dDAXbzA9b8Hujoiue59OHEen8/w1ZHyOI/Zp1ctlErrakFBqElPx6ZyfDbbrBXpaCudXbQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@indiekit/error": "^1.0.0-beta.27",
+ "@indiekit/util": "^1.0.0-beta.25",
+ "@paulrobertlloyd/mf2tojf2": "^3.0.0",
+ "debug": "^4.3.2",
+ "express": "^5.0.0",
+ "lodash": "^4.17.21",
+ "markdown-it": "^14.0.0",
+ "newbase60": "^1.3.1",
+ "turndown": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@indiekit/error": {
- "version": "1.0.0-beta.25",
- "resolved": "https://registry.npmjs.org/@indiekit/error/-/error-1.0.0-beta.25.tgz",
- "integrity": "sha512-ZDM6cyC4qPaosv4Ji1gGObSYpOlHNMqys9v428E7/XvK1qT3uW5S8mAeqGu7ErbWdMZINe0ua0fuZwBlGmSPLg==",
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@indiekit/error/-/error-1.0.0-beta.27.tgz",
+ "integrity": "sha512-Y0XIM1fptHf3i4cfxcIMqueMtqEJ6rOn2qtiYCmJcreiuG72CwaOjXtTW7CELpW/o4B0aZ9pUTEr8ef2+qvRIQ==",
"license": "MIT",
"peer": true,
"engines": {
@@ -1249,6 +1272,13 @@
],
"license": "MIT"
},
+ "node_modules/@mixmark-io/domino": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
+ "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
+ "license": "BSD-2-Clause",
+ "peer": true
+ },
"node_modules/@mongodb-js/saslprep": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz",
@@ -1355,6 +1385,19 @@
"node": ">=14"
}
},
+ "node_modules/@paulrobertlloyd/mf2tojf2": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@paulrobertlloyd/mf2tojf2/-/mf2tojf2-3.0.0.tgz",
+ "integrity": "sha512-R94UVfQ1RrJSvVEco7jk3yeACLCtEixLm6sPnBNjEPpvYr9IitOh9xSFWTT5eFSjT9qYEpBz9SYv3N/g7LK3Dg==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "microformats-parser": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
"node_modules/@sindresorhus/slugify": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-3.0.0.tgz",
@@ -2046,6 +2089,21 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -2770,6 +2828,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/microformats-parser": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/microformats-parser/-/microformats-parser-2.0.4.tgz",
+ "integrity": "sha512-DA2yt3uz2JjupBGoNvaG9ngBP5vSTI1ky2yhxBai/RnQrlzo+gEzuCdvwIIjj2nh3uVPDybTP5u7uua7pOa6LA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "parse5": "^7.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2943,6 +3014,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/newbase60": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/newbase60/-/newbase60-1.3.1.tgz",
+ "integrity": "sha512-2bjwvv8ytc4YQXXnV7lSz7yzQv01eYcdhhX/lo3OWkXgRSxfbbQb922s+6uiC4i5HbNlNu8Vtu9mSZ/xKRaTkg==",
+ "peer": true
+ },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -3028,6 +3105,32 @@
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "license": "BSD-2-Clause",
+ "peer": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -3515,6 +3618,16 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/turndown": {
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz",
+ "integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@mixmark-io/domino": "^2.2.0"
+ }
+ },
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
diff --git a/package.json b/package.json
index f29b3e3..e1f150a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
- "version": "3.8.5",
+ "version": "3.8.7",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",
@@ -42,6 +42,7 @@
"@fedify/redis": "^2.0.0",
"@js-temporal/polyfill": "^0.5.0",
"express": "^5.0.0",
+ "express-rate-limit": "^7.5.1",
"ioredis": "^5.9.3",
"sanitize-html": "^2.13.1",
"unfurl.js": "^6.4.0"
diff --git a/views/activitypub-federation-mgmt.njk b/views/activitypub-federation-mgmt.njk
index d76b37e..d2eca50 100644
--- a/views/activitypub-federation-mgmt.njk
+++ b/views/activitypub-federation-mgmt.njk
@@ -34,7 +34,7 @@
{% endif %}
-