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

View File

@@ -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");
},

View File

@@ -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,

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);
};
}