diff --git a/lib/controllers/profile.remote.js b/lib/controllers/profile.remote.js index 9a99fb5..803ac67 100644 --- a/lib/controllers/profile.remote.js +++ b/lib/controllers/profile.remote.js @@ -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) { return response.status(503).render("error", { title: "Error", @@ -162,10 +176,16 @@ export function followController(mountPath, plugin) { const { url } = request.body; if (!url) { - return response.status(400).json({ - success: false, - error: "Missing actor URL", - }); + return response.status(400).json({ 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); diff --git a/lib/federation-setup.js b/lib/federation-setup.js index bb3c73c..04f3894 100644 --- a/lib/federation-setup.js +++ b/lib/federation-setup.js @@ -792,25 +792,46 @@ function setupOutbox(federation, mountPath, handle, collections) { } 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) { - if (!collections.posts || !publicationUrl) return null; + if (!publicationUrl) return null; 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. - 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?.properties?.visibility === "unlisted") return null; - // Soft-deleted posts should not be dereferenceable - if (post.properties?.deleted) return null; - const actorUrl = ctx.getActorUri(handle).href; - const activity = await jf2ToAS2Activity(post.properties, actorUrl, publicationUrl); - // Only Create activities wrap Note/Article objects - if (!(activity instanceof Create)) return null; - return await activity.getObject(); + + // Primary: look up in posts collection (Micropub pipeline posts) + if (collections.posts) { + const post = await collections.posts.findOne({ + "properties.url": { $in: [postUrl, postUrl + "/"] }, + }); + if (post) { + if (post?.properties?.["post-status"] === "draft") return null; + if (post?.properties?.visibility === "unlisted") return null; + if (post.properties?.deleted) return null; + const actorUrl = ctx.getActorUri(handle).href; + const activity = await jf2ToAS2Activity(post.properties, actorUrl, publicationUrl); + if (!(activity instanceof Create)) return null; + 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 diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index a0132e8..1cb4dd3 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -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" }); } + 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 // the Micropub pipeline — they federate directly without creating a blog entry. const _isShortPost = @@ -224,11 +229,17 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta const _spNow = new Date().toISOString(); 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 - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/(https?:\/\/[^\s<>&"')]+)/g, '$1') + .replace(/(https?:\/\/[^\s<>"')]+)/g, "\x00$1\x00") + .split("\x00") + .map((seg, i) => + i % 2 === 1 + ? `${seg.replace(/&/g, "&").replace(//g, ">")}` + : seg.replace(/&/g, "&").replace(//g, ">"), + ) + .join("") .replace(/\n/g, "
"); const _spTlItem = await addTimelineItem(collections, { diff --git a/lib/outbox-backfill.js b/lib/outbox-backfill.js index 7722e67..c6e3fda 100644 --- a/lib/outbox-backfill.js +++ b/lib/outbox-backfill.js @@ -73,12 +73,15 @@ export async function backfillFromOutbox({ actorUrl, remoteActor, ctx, handle, c } else if (activity instanceof Announce) { object = await activity.getObject({ documentLoader }).catch(() => null); 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 = { - name: remoteActor.name?.toString() || remoteActor.preferredUsername?.toString() || "", + name: remoteActor.name?.toString() || _username || "", url: actorUrl, - photo: "", - handle: "", + photo: _photo, + handle: _username && _domain ? `@${_username}@${_domain}` : "", }; } }