diff --git a/index.js b/index.js index 287088e..01f609f 100644 --- a/index.js +++ b/index.js @@ -59,6 +59,7 @@ import { } from "./lib/controllers/featured-tags.js"; import { resolveController } from "./lib/controllers/resolve.js"; import { publicProfileController } from "./lib/controllers/public-profile.js"; +import { noteObjectController } from "./lib/controllers/note-object.js"; import { refollowPauseController, refollowResumeController, @@ -161,6 +162,10 @@ export default class ActivityPubEndpoint { return self._fedifyMiddleware(req, res, next); }); + // Serve stored quick reply Notes as JSON-LD so remote servers can + // dereference the Note ID during Create activity verification. + router.get("/quick-replies/:id", noteObjectController(self)); + // HTML fallback for actor URL — serve a public profile page. // Fedify only serves JSON-LD; browsers get 406 and fall through here. router.get("/users/:identifier", publicProfileController(self)); @@ -835,6 +840,7 @@ export default class ActivityPubEndpoint { Indiekit.addCollection("ap_muted"); Indiekit.addCollection("ap_blocked"); Indiekit.addCollection("ap_interactions"); + Indiekit.addCollection("ap_notes"); // Store collection references (posts resolved lazily) const indiekitCollections = Indiekit.collections; @@ -853,6 +859,7 @@ export default class ActivityPubEndpoint { ap_muted: indiekitCollections.get("ap_muted"), ap_blocked: indiekitCollections.get("ap_blocked"), ap_interactions: indiekitCollections.get("ap_interactions"), + ap_notes: indiekitCollections.get("ap_notes"), get posts() { return indiekitCollections.get("posts"); }, diff --git a/lib/controllers/compose.js b/lib/controllers/compose.js index 4655588..25a77fc 100644 --- a/lib/controllers/compose.js +++ b/lib/controllers/compose.js @@ -5,6 +5,7 @@ import { Temporal } from "@js-temporal/polyfill"; import { getToken, validateToken } from "../csrf.js"; import { sanitizeContent } from "../timeline-store.js"; +import { resolveAuthor } from "../resolve-author.js"; /** * Fetch syndication targets from the Micropub config endpoint. @@ -205,33 +206,20 @@ export function submitComposeController(mountPath, plugin) { ); const followersUri = ctx.getFollowersUri(handle); + const documentLoader = await ctx.getDocumentLoader({ + identifier: handle, + }); + // Resolve the original author BEFORE constructing the Note, // so we can include them in cc (required for threading/notification) let recipient = null; if (inReplyTo) { - try { - const documentLoader = await ctx.getDocumentLoader({ - identifier: handle, - }); - const remoteObject = await ctx.lookupObject(new URL(inReplyTo), { - documentLoader, - }); - - if ( - remoteObject && - typeof remoteObject.getAttributedTo === "function" - ) { - const author = await remoteObject.getAttributedTo({ - documentLoader, - }); - recipient = Array.isArray(author) ? author[0] : author; - } - } catch (error) { - console.warn( - `[ActivityPub] lookupObject failed for ${inReplyTo} (quick reply):`, - error.message, - ); - } + recipient = await resolveAuthor( + inReplyTo, + ctx, + documentLoader, + application?.collections, + ); } // Build cc list: always include followers, add original author for replies @@ -258,6 +246,21 @@ export function submitComposeController(mountPath, plugin) { ccs: ccList, }); + // Store the Note so remote servers can dereference its ID + const ap_notes = application?.collections?.get("ap_notes"); + if (ap_notes) { + await ap_notes.insertOne({ + _id: uuid, + noteId, + actorUrl: actorUri.href, + content: content.trim(), + inReplyTo: inReplyTo || null, + published: new Date().toISOString(), + to: ["https://www.w3.org/ns/activitystreams#Public"], + cc: ccList.map((u) => (u instanceof URL ? u.href : u.href || u)), + }); + } + // Send to followers await ctx.sendActivity({ identifier: handle }, "followers", create, { preferSharedInbox: true, diff --git a/lib/controllers/note-object.js b/lib/controllers/note-object.js new file mode 100644 index 0000000..8e6dffa --- /dev/null +++ b/lib/controllers/note-object.js @@ -0,0 +1,51 @@ +/** + * Public route handler for serving quick reply Notes as ActivityPub JSON-LD. + * + * Remote servers dereference Note IDs to verify Create activities. + * Without this, quick replies are rejected by servers that validate + * the Note's ID URL (Mastodon with Authorized Fetch, Bonfire, etc.). + */ + +/** + * GET /quick-replies/:id — serve a stored Note as JSON-LD. + * @param {object} plugin - ActivityPub plugin instance + */ +export function noteObjectController(plugin) { + return async (request, response) => { + const { id } = request.params; + + const { application } = request.app.locals; + const ap_notes = application?.collections?.get("ap_notes"); + + if (!ap_notes) { + return response.status(404).json({ error: "Not Found" }); + } + + const note = await ap_notes.findOne({ _id: id }); + + if (!note) { + return response.status(404).json({ error: "Not Found" }); + } + + const noteJson = { + "@context": "https://www.w3.org/ns/activitystreams", + id: note.noteId, + type: "Note", + attributedTo: note.actorUrl, + content: note.content, + published: note.published, + to: note.to, + cc: note.cc, + }; + + if (note.inReplyTo) { + noteJson.inReplyTo = note.inReplyTo; + } + + response + .status(200) + .set("Content-Type", "application/activity+json; charset=utf-8") + .set("Cache-Control", "public, max-age=3600") + .json(noteJson); + }; +}