fix: backfill actor avatar/handle, URL scheme validation, visibility validation, short-post dereferenceable, autolink before escape
This commit is contained in:
@@ -24,6 +24,20 @@ export function remoteProfileController(mountPath, plugin) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject non-http(s) schemes and loopback/private-range hosts
|
||||||
|
try {
|
||||||
|
const _parsed = new URL(actorUrl);
|
||||||
|
if (!["http:", "https:"].includes(_parsed.protocol)) {
|
||||||
|
return response.status(400).render("error", { title: "Error", content: "Invalid actor URL" });
|
||||||
|
}
|
||||||
|
const _h = _parsed.hostname;
|
||||||
|
if (_h === "localhost" || _h === "127.0.0.1" || _h === "::1" || /^10\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\./.test(_h)) {
|
||||||
|
return response.status(400).render("error", { title: "Error", content: "Invalid actor URL" });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return response.status(400).render("error", { title: "Error", content: "Invalid actor URL" });
|
||||||
|
}
|
||||||
|
|
||||||
if (!plugin._federation) {
|
if (!plugin._federation) {
|
||||||
return response.status(503).render("error", {
|
return response.status(503).render("error", {
|
||||||
title: "Error",
|
title: "Error",
|
||||||
@@ -162,10 +176,16 @@ export function followController(mountPath, plugin) {
|
|||||||
const { url } = request.body;
|
const { url } = request.body;
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return response.status(400).json({
|
return response.status(400).json({ success: false, error: "Missing actor URL" });
|
||||||
success: false,
|
}
|
||||||
error: "Missing actor URL",
|
|
||||||
});
|
try {
|
||||||
|
const _p = new URL(url);
|
||||||
|
if (!["http:", "https:"].includes(_p.protocol)) throw new Error("scheme");
|
||||||
|
const _h = _p.hostname;
|
||||||
|
if (_h === "localhost" || _h === "127.0.0.1" || _h === "::1" || /^10\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\./.test(_h)) throw new Error("private");
|
||||||
|
} catch {
|
||||||
|
return response.status(400).json({ success: false, error: "Invalid actor URL" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await plugin.followActor(url);
|
const result = await plugin.followActor(url);
|
||||||
|
|||||||
+38
-17
@@ -792,25 +792,46 @@ function setupOutbox(federation, mountPath, handle, collections) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl) {
|
function setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl) {
|
||||||
// Shared lookup: find post by URL path, convert to Fedify Note/Article
|
// Shared lookup: find post by URL path, convert to Fedify Note/Article.
|
||||||
|
// Falls back to ap_timeline for short posts (UUIDs) that bypass the Micropub pipeline.
|
||||||
async function resolvePost(ctx, id) {
|
async function resolvePost(ctx, id) {
|
||||||
if (!collections.posts || !publicationUrl) return null;
|
if (!publicationUrl) return null;
|
||||||
const postUrl = `${publicationUrl.replace(/\/$/, "")}/${id}`;
|
const postUrl = `${publicationUrl.replace(/\/$/, "")}/${id}`;
|
||||||
// Match with or without trailing slash — AP object URLs omit the slash
|
|
||||||
// but posts are stored with one, so an exact match would fail.
|
// Primary: look up in posts collection (Micropub pipeline posts)
|
||||||
const post = await collections.posts.findOne({
|
if (collections.posts) {
|
||||||
"properties.url": { $in: [postUrl, postUrl + "/"] },
|
const post = await collections.posts.findOne({
|
||||||
});
|
"properties.url": { $in: [postUrl, postUrl + "/"] },
|
||||||
if (!post) return null;
|
});
|
||||||
if (post?.properties?.["post-status"] === "draft") return null;
|
if (post) {
|
||||||
if (post?.properties?.visibility === "unlisted") return null;
|
if (post?.properties?.["post-status"] === "draft") return null;
|
||||||
// Soft-deleted posts should not be dereferenceable
|
if (post?.properties?.visibility === "unlisted") return null;
|
||||||
if (post.properties?.deleted) return null;
|
if (post.properties?.deleted) return null;
|
||||||
const actorUrl = ctx.getActorUri(handle).href;
|
const actorUrl = ctx.getActorUri(handle).href;
|
||||||
const activity = await jf2ToAS2Activity(post.properties, actorUrl, publicationUrl);
|
const activity = await jf2ToAS2Activity(post.properties, actorUrl, publicationUrl);
|
||||||
// Only Create activities wrap Note/Article objects
|
if (!(activity instanceof Create)) return null;
|
||||||
if (!(activity instanceof Create)) return null;
|
return await activity.getObject();
|
||||||
return await activity.getObject();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: ap_timeline for short posts (UUID-based canonical URLs)
|
||||||
|
if (collections.ap_timeline) {
|
||||||
|
const item = await collections.ap_timeline.findOne({
|
||||||
|
uid: postUrl,
|
||||||
|
"author.url": { $regex: new RegExp(`^${publicationUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\/+$/, "")}`) },
|
||||||
|
});
|
||||||
|
if (item && item.content) {
|
||||||
|
const actorUri = ctx.getActorUri(handle);
|
||||||
|
return new Note({
|
||||||
|
id: new URL(postUrl),
|
||||||
|
attributedTo: actorUri,
|
||||||
|
content: item.content.html || item.content.text || "",
|
||||||
|
published: item.published ? new Date(item.published) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note dispatcher — handles note, reply, bookmark, jam, rsvp, checkin
|
// Note dispatcher — handles note, reply, bookmark, jam, rsvp, checkin
|
||||||
|
|||||||
@@ -201,6 +201,11 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
|
|||||||
return res.status(422).json({ error: "Validation failed: Text content is required" });
|
return res.status(422).json({ error: "Validation failed: Text content is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_VISIBILITIES = ["public", "unlisted", "private", "direct"];
|
||||||
|
if (visibility && !VALID_VISIBILITIES.includes(visibility)) {
|
||||||
|
return res.status(422).json({ error: "Validation failed: Invalid visibility" });
|
||||||
|
}
|
||||||
|
|
||||||
// Short standalone posts (≤ 280 chars, no media, no reply, not a DM) skip
|
// Short standalone posts (≤ 280 chars, no media, no reply, not a DM) skip
|
||||||
// the Micropub pipeline — they federate directly without creating a blog entry.
|
// the Micropub pipeline — they federate directly without creating a blog entry.
|
||||||
const _isShortPost =
|
const _isShortPost =
|
||||||
@@ -224,11 +229,17 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
|
|||||||
const _spNow = new Date().toISOString();
|
const _spNow = new Date().toISOString();
|
||||||
|
|
||||||
const _spText = (statusText || "").trim();
|
const _spText = (statusText || "").trim();
|
||||||
|
// Auto-link URLs before escaping so the href is the raw URL,
|
||||||
|
// then escape only the text segments (not the generated tags).
|
||||||
const _spHtml = _spText
|
const _spHtml = _spText
|
||||||
.replace(/&/g, "&")
|
.replace(/(https?:\/\/[^\s<>"')]+)/g, "\x00$1\x00")
|
||||||
.replace(/</g, "<")
|
.split("\x00")
|
||||||
.replace(/>/g, ">")
|
.map((seg, i) =>
|
||||||
.replace(/(https?:\/\/[^\s<>&"')]+)/g, '<a href="$1">$1</a>')
|
i % 2 === 1
|
||||||
|
? `<a href="${seg}">${seg.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")}</a>`
|
||||||
|
: seg.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"),
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
.replace(/\n/g, "<br>");
|
.replace(/\n/g, "<br>");
|
||||||
|
|
||||||
const _spTlItem = await addTimelineItem(collections, {
|
const _spTlItem = await addTimelineItem(collections, {
|
||||||
|
|||||||
@@ -73,12 +73,15 @@ export async function backfillFromOutbox({ actorUrl, remoteActor, ctx, handle, c
|
|||||||
} else if (activity instanceof Announce) {
|
} else if (activity instanceof Announce) {
|
||||||
object = await activity.getObject({ documentLoader }).catch(() => null);
|
object = await activity.getObject({ documentLoader }).catch(() => null);
|
||||||
if (object) {
|
if (object) {
|
||||||
// For boosts: actor info comes from the remote actor we just followed
|
const _username = remoteActor.preferredUsername?.toString() || "";
|
||||||
|
const _domain = (() => { try { return new URL(actorUrl).hostname; } catch { return ""; } })();
|
||||||
|
let _photo = "";
|
||||||
|
try { const _icon = await remoteActor.getIcon(); _photo = _icon?.url?.href || ""; } catch { /* ignore */ }
|
||||||
boostedBy = {
|
boostedBy = {
|
||||||
name: remoteActor.name?.toString() || remoteActor.preferredUsername?.toString() || "",
|
name: remoteActor.name?.toString() || _username || "",
|
||||||
url: actorUrl,
|
url: actorUrl,
|
||||||
photo: "",
|
photo: _photo,
|
||||||
handle: "",
|
handle: _username && _domain ? `@${_username}@${_domain}` : "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user