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:
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user