fix: store and serve quick reply Notes for remote dereferencing

Remote servers (Mastodon, Bonfire) dereference Note IDs to verify
Create activities. Quick reply Notes had no public route — servers
got 302 to login and rejected the activity.

- Store quick reply Note data in ap_notes collection
- Add public GET /quick-replies/:id serving JSON-LD
- Use shared resolveAuthor() in compose.js for quick replies
This commit is contained in:
Ricardo
2026-02-22 21:49:04 +01:00
parent fc63bb5b96
commit e5c0fa1191
3 changed files with 84 additions and 23 deletions
+7
View File
@@ -59,6 +59,7 @@ import {
} from "./lib/controllers/featured-tags.js"; } from "./lib/controllers/featured-tags.js";
import { resolveController } from "./lib/controllers/resolve.js"; import { resolveController } from "./lib/controllers/resolve.js";
import { publicProfileController } from "./lib/controllers/public-profile.js"; import { publicProfileController } from "./lib/controllers/public-profile.js";
import { noteObjectController } from "./lib/controllers/note-object.js";
import { import {
refollowPauseController, refollowPauseController,
refollowResumeController, refollowResumeController,
@@ -161,6 +162,10 @@ export default class ActivityPubEndpoint {
return self._fedifyMiddleware(req, res, next); 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. // HTML fallback for actor URL — serve a public profile page.
// Fedify only serves JSON-LD; browsers get 406 and fall through here. // Fedify only serves JSON-LD; browsers get 406 and fall through here.
router.get("/users/:identifier", publicProfileController(self)); router.get("/users/:identifier", publicProfileController(self));
@@ -835,6 +840,7 @@ export default class ActivityPubEndpoint {
Indiekit.addCollection("ap_muted"); Indiekit.addCollection("ap_muted");
Indiekit.addCollection("ap_blocked"); Indiekit.addCollection("ap_blocked");
Indiekit.addCollection("ap_interactions"); Indiekit.addCollection("ap_interactions");
Indiekit.addCollection("ap_notes");
// Store collection references (posts resolved lazily) // Store collection references (posts resolved lazily)
const indiekitCollections = Indiekit.collections; const indiekitCollections = Indiekit.collections;
@@ -853,6 +859,7 @@ export default class ActivityPubEndpoint {
ap_muted: indiekitCollections.get("ap_muted"), ap_muted: indiekitCollections.get("ap_muted"),
ap_blocked: indiekitCollections.get("ap_blocked"), ap_blocked: indiekitCollections.get("ap_blocked"),
ap_interactions: indiekitCollections.get("ap_interactions"), ap_interactions: indiekitCollections.get("ap_interactions"),
ap_notes: indiekitCollections.get("ap_notes"),
get posts() { get posts() {
return indiekitCollections.get("posts"); return indiekitCollections.get("posts");
}, },
+24 -21
View File
@@ -5,6 +5,7 @@
import { Temporal } from "@js-temporal/polyfill"; import { Temporal } from "@js-temporal/polyfill";
import { getToken, validateToken } from "../csrf.js"; import { getToken, validateToken } from "../csrf.js";
import { sanitizeContent } from "../timeline-store.js"; import { sanitizeContent } from "../timeline-store.js";
import { resolveAuthor } from "../resolve-author.js";
/** /**
* Fetch syndication targets from the Micropub config endpoint. * Fetch syndication targets from the Micropub config endpoint.
@@ -205,34 +206,21 @@ export function submitComposeController(mountPath, plugin) {
); );
const followersUri = ctx.getFollowersUri(handle); const followersUri = ctx.getFollowersUri(handle);
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
// Resolve the original author BEFORE constructing the Note, // Resolve the original author BEFORE constructing the Note,
// so we can include them in cc (required for threading/notification) // so we can include them in cc (required for threading/notification)
let recipient = null; let recipient = null;
if (inReplyTo) { if (inReplyTo) {
try { recipient = await resolveAuthor(
const documentLoader = await ctx.getDocumentLoader({ inReplyTo,
identifier: handle, ctx,
});
const remoteObject = await ctx.lookupObject(new URL(inReplyTo), {
documentLoader, documentLoader,
}); application?.collections,
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,
); );
} }
}
// Build cc list: always include followers, add original author for replies // Build cc list: always include followers, add original author for replies
const ccList = [followersUri]; const ccList = [followersUri];
@@ -258,6 +246,21 @@ export function submitComposeController(mountPath, plugin) {
ccs: ccList, 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 // Send to followers
await ctx.sendActivity({ identifier: handle }, "followers", create, { await ctx.sendActivity({ identifier: handle }, "followers", create, {
preferSharedInbox: true, preferSharedInbox: true,
+51
View File
@@ -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);
};
}