mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
feat: ActivityPub reader — timeline, notifications, compose, moderation
Add a dedicated fediverse reader view with: - Timeline view showing posts from followed accounts with threading, content warnings, boosts, and media display - Compose form with dual-path posting (quick AP reply + Micropub blog post) - Native AP interactions (like, boost, reply, follow/unfollow) - Notifications view for likes, boosts, follows, mentions, replies - Moderation tools (mute/block actors, keyword filters) - Remote actor profile pages with follow state - Automatic timeline cleanup with configurable retention - CSRF protection, XSS prevention, input validation throughout Removes Microsub bridge dependency — AP content now lives in its own MongoDB collections (ap_timeline, ap_notifications, ap_interactions, ap_muted, ap_blocked). Bumps version to 1.1.0.
This commit is contained in:
323
lib/controllers/compose.js
Normal file
323
lib/controllers/compose.js
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Compose controllers — reply form via Micropub or direct AP.
|
||||
*/
|
||||
|
||||
import { Temporal } from "@js-temporal/polyfill";
|
||||
import { getTimelineItem } from "../storage/timeline.js";
|
||||
import { getToken, validateToken } from "../csrf.js";
|
||||
|
||||
/**
|
||||
* Fetch syndication targets from the Micropub config endpoint.
|
||||
* @param {object} application - Indiekit application locals
|
||||
* @param {string} token - Session access token
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async function getSyndicationTargets(application, token) {
|
||||
try {
|
||||
const micropubEndpoint = application.micropubEndpoint;
|
||||
|
||||
if (!micropubEndpoint) return [];
|
||||
|
||||
const micropubUrl = micropubEndpoint.startsWith("http")
|
||||
? micropubEndpoint
|
||||
: new URL(micropubEndpoint, application.url).href;
|
||||
|
||||
const configUrl = `${micropubUrl}?q=config`;
|
||||
const configResponse = await fetch(configUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (configResponse.ok) {
|
||||
const config = await configResponse.json();
|
||||
return config["syndicate-to"] || [];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /admin/reader/compose — Show compose form.
|
||||
* @param {string} mountPath - Plugin mount path
|
||||
* @param {object} plugin - ActivityPub plugin instance
|
||||
*/
|
||||
export function composeController(mountPath, plugin) {
|
||||
return async (request, response, next) => {
|
||||
try {
|
||||
const { application } = request.app.locals;
|
||||
const replyTo = request.query.replyTo || "";
|
||||
|
||||
// Fetch reply context (the post being replied to)
|
||||
let replyContext = null;
|
||||
|
||||
if (replyTo) {
|
||||
const collections = {
|
||||
ap_timeline: application?.collections?.get("ap_timeline"),
|
||||
};
|
||||
|
||||
// Try to find the post in our timeline first
|
||||
replyContext = await getTimelineItem(collections, replyTo);
|
||||
|
||||
// If not in timeline, try to look up remotely
|
||||
if (!replyContext && plugin._federation) {
|
||||
try {
|
||||
const handle = plugin.options.actor.handle;
|
||||
const ctx = plugin._federation.createContext(
|
||||
new URL(plugin._publicationUrl),
|
||||
{ handle, publicationUrl: plugin._publicationUrl },
|
||||
);
|
||||
const remoteObject = await ctx.lookupObject(new URL(replyTo));
|
||||
|
||||
if (remoteObject) {
|
||||
let authorName = "";
|
||||
let authorUrl = "";
|
||||
|
||||
if (typeof remoteObject.getAttributedTo === "function") {
|
||||
const author = await remoteObject.getAttributedTo();
|
||||
const actor = Array.isArray(author) ? author[0] : author;
|
||||
|
||||
if (actor) {
|
||||
authorName =
|
||||
actor.name?.toString() ||
|
||||
actor.preferredUsername?.toString() ||
|
||||
"";
|
||||
authorUrl = actor.id?.href || "";
|
||||
}
|
||||
}
|
||||
|
||||
replyContext = {
|
||||
url: replyTo,
|
||||
name: remoteObject.name?.toString() || "",
|
||||
content: {
|
||||
text:
|
||||
remoteObject.content?.toString()?.slice(0, 300) || "",
|
||||
},
|
||||
author: { name: authorName, url: authorUrl },
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Could not resolve — form still works without context
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch syndication targets for Micropub path
|
||||
const token = request.session?.access_token;
|
||||
const syndicationTargets = token
|
||||
? await getSyndicationTargets(application, token)
|
||||
: [];
|
||||
|
||||
const csrfToken = getToken(request.session);
|
||||
|
||||
response.render("activitypub-compose", {
|
||||
title: response.locals.__("activitypub.compose.title"),
|
||||
replyTo,
|
||||
replyContext,
|
||||
syndicationTargets,
|
||||
csrfToken,
|
||||
mountPath,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/reader/compose — Submit reply via Micropub or direct AP.
|
||||
* @param {string} mountPath - Plugin mount path
|
||||
* @param {object} plugin - ActivityPub plugin instance
|
||||
*/
|
||||
export function submitComposeController(mountPath, plugin) {
|
||||
return async (request, response, next) => {
|
||||
try {
|
||||
if (!validateToken(request)) {
|
||||
return response.status(403).render("error", {
|
||||
title: "Error",
|
||||
content: "Invalid CSRF token",
|
||||
});
|
||||
}
|
||||
|
||||
const { application } = request.app.locals;
|
||||
const { content, mode } = request.body;
|
||||
const inReplyTo = request.body["in-reply-to"];
|
||||
const syndicateTo = request.body["mp-syndicate-to"];
|
||||
|
||||
if (!content || !content.trim()) {
|
||||
return response.status(400).render("error", {
|
||||
title: "Error",
|
||||
content: response.locals.__("activitypub.compose.errorEmpty"),
|
||||
});
|
||||
}
|
||||
|
||||
// Quick reply — direct AP
|
||||
if (mode === "quick") {
|
||||
if (!plugin._federation) {
|
||||
return response.status(503).render("error", {
|
||||
title: "Error",
|
||||
content: "Federation not initialized",
|
||||
});
|
||||
}
|
||||
|
||||
const { Create, Note } = await import("@fedify/fedify");
|
||||
const handle = plugin.options.actor.handle;
|
||||
const ctx = plugin._federation.createContext(
|
||||
new URL(plugin._publicationUrl),
|
||||
{ handle, publicationUrl: plugin._publicationUrl },
|
||||
);
|
||||
|
||||
const noteId = `urn:uuid:${crypto.randomUUID()}`;
|
||||
const actorUri = ctx.getActorUri(handle);
|
||||
|
||||
const note = new Note({
|
||||
id: new URL(noteId),
|
||||
attribution: actorUri,
|
||||
content: content.trim(),
|
||||
inReplyTo: inReplyTo ? new URL(inReplyTo) : undefined,
|
||||
published: Temporal.Now.instant(),
|
||||
});
|
||||
|
||||
const create = new Create({
|
||||
id: new URL(`${noteId}#activity`),
|
||||
actor: actorUri,
|
||||
object: note,
|
||||
});
|
||||
|
||||
// Send to followers
|
||||
await ctx.sendActivity({ identifier: handle }, "followers", create, {
|
||||
preferSharedInbox: true,
|
||||
syncCollection: true,
|
||||
orderingKey: noteId,
|
||||
});
|
||||
|
||||
// If replying, also send to the original author
|
||||
if (inReplyTo) {
|
||||
try {
|
||||
const remoteObject = await ctx.lookupObject(new URL(inReplyTo));
|
||||
|
||||
if (
|
||||
remoteObject &&
|
||||
typeof remoteObject.getAttributedTo === "function"
|
||||
) {
|
||||
const author = await remoteObject.getAttributedTo();
|
||||
const recipient = Array.isArray(author)
|
||||
? author[0]
|
||||
: author;
|
||||
|
||||
if (recipient) {
|
||||
await ctx.sendActivity(
|
||||
{ identifier: handle },
|
||||
recipient,
|
||||
create,
|
||||
{ orderingKey: noteId },
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — followers still got it
|
||||
}
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[ActivityPub] Sent quick reply${inReplyTo ? ` to ${inReplyTo}` : ""}`,
|
||||
);
|
||||
|
||||
return response.redirect(`${mountPath}/admin/reader`);
|
||||
}
|
||||
|
||||
// Micropub path — post as blog reply
|
||||
const micropubEndpoint = application.micropubEndpoint;
|
||||
|
||||
if (!micropubEndpoint) {
|
||||
return response.status(500).render("error", {
|
||||
title: "Error",
|
||||
content: "Micropub endpoint not configured",
|
||||
});
|
||||
}
|
||||
|
||||
const micropubUrl = micropubEndpoint.startsWith("http")
|
||||
? micropubEndpoint
|
||||
: new URL(micropubEndpoint, application.url).href;
|
||||
|
||||
const token = request.session?.access_token;
|
||||
|
||||
if (!token) {
|
||||
return response.redirect(
|
||||
"/session/login?redirect=" + request.originalUrl,
|
||||
);
|
||||
}
|
||||
|
||||
const micropubData = new URLSearchParams();
|
||||
micropubData.append("h", "entry");
|
||||
micropubData.append("content", content.trim());
|
||||
|
||||
if (inReplyTo) {
|
||||
micropubData.append("in-reply-to", inReplyTo);
|
||||
}
|
||||
|
||||
if (syndicateTo) {
|
||||
const targets = Array.isArray(syndicateTo)
|
||||
? syndicateTo
|
||||
: [syndicateTo];
|
||||
|
||||
for (const target of targets) {
|
||||
micropubData.append("mp-syndicate-to", target);
|
||||
}
|
||||
}
|
||||
|
||||
const micropubResponse = await fetch(micropubUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: micropubData.toString(),
|
||||
});
|
||||
|
||||
if (
|
||||
micropubResponse.ok ||
|
||||
micropubResponse.status === 201 ||
|
||||
micropubResponse.status === 202
|
||||
) {
|
||||
const location = micropubResponse.headers.get("Location");
|
||||
console.info(
|
||||
`[ActivityPub] Created blog reply via Micropub: ${location || "success"}`,
|
||||
);
|
||||
|
||||
return response.redirect(`${mountPath}/admin/reader`);
|
||||
}
|
||||
|
||||
const errorBody = await micropubResponse.text();
|
||||
let errorMessage = `Micropub error: ${micropubResponse.statusText}`;
|
||||
|
||||
try {
|
||||
const errorJson = JSON.parse(errorBody);
|
||||
|
||||
if (errorJson.error_description) {
|
||||
errorMessage = String(errorJson.error_description);
|
||||
} else if (errorJson.error) {
|
||||
errorMessage = String(errorJson.error);
|
||||
}
|
||||
} catch {
|
||||
// Not JSON
|
||||
}
|
||||
|
||||
return response.status(micropubResponse.status).render("error", {
|
||||
title: "Error",
|
||||
content: errorMessage,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[ActivityPub] Compose submit failed:", error.message);
|
||||
return response.status(500).render("error", {
|
||||
title: "Error",
|
||||
content: "Failed to create post. Please try again later.",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user