@@ -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 7d39aa4..ce374ad 100644
--- a/lib/mastodon/routes/statuses.js
+++ b/lib/mastodon/routes/statuses.js
@@ -29,12 +29,14 @@ import {
import { addTimelineItem } from "../../storage/timeline.js";
import { lookupWithSecurity } from "../../lookup-helpers.js";
import { addNotification } from "../../storage/notifications.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;
@@ -58,7 +60,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;
@@ -137,13 +139,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 || {};
@@ -172,32 +169,42 @@ router.post("/api/v1/statuses", async (req, res, next) => {
}
}
- // Build JF2 properties for the Micropub pipeline
+ // Build JF2 properties for the Micropub pipeline.
+ // Provide both text and html — linkify URLs since Micropub's markdown-it
+ // doesn't have linkify enabled. Mentions are preserved as plain text;
+ // the AP syndicator resolves them via WebFinger for federation delivery.
+ const contentText = statusText || "";
+ const contentHtml = contentText
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/(https?:\/\/[^\s<>&"')\]]+)/g, '
$1')
+ .replace(/\n/g, "
");
+
const jf2 = {
type: "entry",
- content: statusText || "",
+ content: { text: contentText, html: `
${contentHtml}
` },
};
if (inReplyTo) {
jf2["in-reply-to"] = inReplyTo;
}
- if (spoilerText) {
- jf2.summary = spoilerText;
- }
-
- if (sensitive === true || sensitive === "true") {
- jf2.sensitive = "true";
- }
-
if (visibility && visibility !== "public" && visibility !== "direct") {
jf2.visibility = visibility;
}
+ // Use content-warning (not summary) to match native reader behavior
+ if (spoilerText) {
+ jf2["content-warning"] = spoilerText;
+ jf2.sensitive = "true";
+ }
+
if (language) {
jf2["mp-language"] = language;
}
+
// ── Direct messages: bypass Micropub, send via native AP DM path ──────────
// Mastodon clients send visibility="direct" for DMs. These must NOT create
// a public blog post — instead send a Create/Note activity directly to the
@@ -358,101 +365,70 @@ router.post("/api/v1/statuses", async (req, res, next) => {
// Syndicate to AP only — posts from Mastodon clients belong to the fediverse.
// Never cross-post to Bluesky (conversations stay in their protocol).
// The publication URL is the AP syndicator's uid.
+
const publicationUrl = pluginOptions.publicationUrl || baseUrl;
jf2["mp-syndicate-to"] = [publicationUrl.replace(/\/$/, "") + "/"];
- // Create post via Micropub pipeline (same functions the Micropub endpoint uses)
- // postData.create() handles: normalization, post type detection, path rendering,
- // mp-syndicate-to validated against configured syndicators, MongoDB posts collection
+ // Create post via Micropub pipeline (same internal functions)
const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js");
const data = await postData.create(application, publication, jf2);
- // postContent.create() handles: template rendering, file creation in store
await postContent.create(publication, data);
const postUrl = data.properties.url;
console.info(`[Mastodon API] Created post via Micropub: ${postUrl}`);
- // Add to ap_timeline so the post is visible in the Mastodon Client API
+ // Return a minimal status to the Mastodon client.
+ // No timeline entry is created here — the post will appear in the timeline
+ // after the normal flow: Eleventy rebuild → syndication webhook → AP delivery.
const profile = await collections.ap_profile.findOne({});
const handle = pluginOptions.handle || "user";
- const actorUrl = profile?.url || `${publicationUrl}/users/${handle}`;
- // Extract hashtags from status text and merge with any Micropub categories
- const categories = data.properties.category || [];
- const inlineHashtags = (statusText || "").match(/(?:^|\s)#([a-zA-Z_]\w*)/g);
- if (inlineHashtags) {
- const existing = new Set(categories.map((c) => c.toLowerCase()));
- for (const match of inlineHashtags) {
- const tag = match.trim().slice(1).toLowerCase();
- if (!existing.has(tag)) {
- existing.add(tag);
- categories.push(tag);
- }
- }
- }
-
- // Resolve relative media URLs to absolute
- const resolveMedia = (items) => {
- if (!items || !items.length) return [];
- return items.map((item) => {
- if (typeof item === "string") {
- return item.startsWith("http") ? item : `${publicationUrl.replace(/\/$/, "")}/${item.replace(/^\//, "")}`;
- }
- if (item?.url && !item.url.startsWith("http")) {
- return { ...item, url: `${publicationUrl.replace(/\/$/, "")}/${item.url.replace(/^\//, "")}` };
- }
- return item;
- });
- };
-
- // Process content: linkify URLs and extract @mentions
- const rawContent = data.properties.content || { text: statusText || "", html: "" };
- const processedContent = processStatusContent(rawContent, statusText || "");
- const mentions = extractMentions(statusText || "");
-
- const now = new Date().toISOString();
- const timelineItem = await addTimelineItem(collections, {
- uid: postUrl,
+ res.json({
+ id: String(Date.now()),
+ created_at: new Date().toISOString(),
+ content: `
${contentHtml}
`,
url: postUrl,
- type: data.properties["post-type"] || "note",
- content: processedContent,
- summary: spoilerText || "",
- sensitive: sensitive === true || sensitive === "true",
+ uri: postUrl,
visibility: visibility || "public",
+ sensitive: sensitive === true || sensitive === "true",
+ spoiler_text: spoilerText || "",
+ in_reply_to_id: inReplyToId || null,
+ in_reply_to_account_id: null,
language: language || null,
- inReplyTo,
- published: data.properties.published || now,
- createdAt: now,
- author: {
- name: profile?.name || handle,
+ replies_count: 0,
+ reblogs_count: 0,
+ favourites_count: 0,
+ favourited: false,
+ reblogged: false,
+ bookmarked: false,
+ account: {
+ id: "owner",
+ username: handle,
+ acct: handle,
+ display_name: profile?.name || handle,
url: profile?.url || publicationUrl,
- photo: profile?.icon || "",
- handle: `@${handle}`,
+ avatar: profile?.icon || "",
+ avatar_static: profile?.icon || "",
+ header: "",
+ header_static: "",
+ followers_count: 0,
+ following_count: 0,
+ statuses_count: 0,
emojis: [],
- bot: false,
+ fields: [],
},
- photo: resolveMedia(data.properties.photo || []),
- video: resolveMedia(data.properties.video || []),
- audio: resolveMedia(data.properties.audio || []),
- category: categories,
- counts: { replies: 0, boosts: 0, likes: 0 },
- linkPreviews: [],
- mentions,
+ media_attachments: [],
+ mentions: extractMentions(contentText).map(m => ({
+ id: "0",
+ username: m.name.split("@")[1] || m.name,
+ acct: m.name.replace(/^@/, ""),
+ url: m.url,
+ })),
+ tags: [],
emojis: [],
});
-
- // Serialize and return
- const serialized = serializeStatus(timelineItem, {
- baseUrl,
- favouritedIds: new Set(),
- rebloggedIds: new Set(),
- bookmarkedIds: new Set(),
- pinnedIds: new Set(),
- });
-
- res.json(serialized);
} catch (error) {
next(error);
}
@@ -462,13 +438,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;
@@ -650,27 +621,22 @@ router.put("/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" });
@@ -695,13 +661,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" });
@@ -725,13 +686,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" });
@@ -755,13 +711,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" });
@@ -785,13 +736,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" });
@@ -813,13 +759,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" });
@@ -1048,6 +989,7 @@ async function loadItemInteractions(collections, item) {
}
/**
+
* Process status content: linkify bare URLs and convert @mentions to links.
*
* Mastodon clients send plain text — the server is responsible for
@@ -1091,6 +1033,7 @@ function processStatusContent(content, rawText) {
}
/**
+
* Extract @user@domain mentions from text into mention objects.
*
* @param {string} text - Status text
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/storage/tombstones.js b/lib/storage/tombstones.js
new file mode 100644
index 0000000..79d6cfe
--- /dev/null
+++ b/lib/storage/tombstones.js
@@ -0,0 +1,52 @@
+/**
+ * Tombstone storage for soft-deleted posts (FEP-4f05).
+ * When a post is deleted, a tombstone record is created so remote servers
+ * fetching the URL get a proper Tombstone response instead of 404.
+ * @module storage/tombstones
+ */
+
+/**
+ * Record a tombstone for a deleted post.
+ * @param {object} collections - MongoDB collections
+ * @param {object} data - { url, formerType, published, deleted }
+ */
+export async function addTombstone(collections, { url, formerType, published, deleted }) {
+ const { ap_tombstones } = collections;
+ if (!ap_tombstones) return;
+
+ await ap_tombstones.updateOne(
+ { url },
+ {
+ $set: {
+ url,
+ formerType: formerType || "Note",
+ published: published || null,
+ deleted: deleted || new Date().toISOString(),
+ },
+ },
+ { upsert: true },
+ );
+}
+
+/**
+ * Remove a tombstone (post re-published).
+ * @param {object} collections - MongoDB collections
+ * @param {string} url - Post URL
+ */
+export async function removeTombstone(collections, url) {
+ const { ap_tombstones } = collections;
+ if (!ap_tombstones) return;
+ await ap_tombstones.deleteOne({ url });
+}
+
+/**
+ * Look up a tombstone by URL.
+ * @param {object} collections - MongoDB collections
+ * @param {string} url - Post URL
+ * @returns {Promise
-