Merge upstream rmdes:main — v2.11.0, v2.12.0, v2.12.1 into svemagie/main (v2.12.2)

Integrates upstream features (visibility/CW compose controls, @mention
support, federation management page, layout fix) while preserving
svemagie DM support. Visibility and syndication controls are hidden
for direct messages.
This commit is contained in:
svemagie
2026-03-15 19:25:54 +01:00
9 changed files with 1104 additions and 5 deletions

View File

@@ -1094,6 +1094,58 @@
outline-offset: -2px;
}
.ap-compose__cw {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.ap-compose__cw-toggle {
cursor: pointer;
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-s);
color: var(--color-on-offset);
}
.ap-compose__cw-input {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
background: var(--color-offset);
color: var(--color-on-background);
font: inherit;
font-size: var(--font-size-s);
padding: var(--space-s);
width: 100%;
}
.ap-compose__cw-input:focus {
border-color: var(--color-primary);
outline: none;
}
.ap-compose__visibility {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
display: flex;
flex-wrap: wrap;
gap: var(--space-s) var(--space-m);
padding: var(--space-m);
}
.ap-compose__visibility legend {
font-weight: 600;
}
.ap-compose__visibility-option {
cursor: pointer;
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-s);
}
.ap-compose__syndication {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
@@ -3138,3 +3190,221 @@
}
}
/* ==========================================================================
Federation Management
========================================================================== */
.ap-federation__section {
margin-block-end: var(--space-l);
}
.ap-federation__section h2 {
margin-block-end: var(--space-s);
}
.ap-federation__stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: var(--space-s);
}
.ap-federation__stat-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
padding: var(--space-s);
background: var(--color-offset);
border-radius: var(--border-radius-small);
text-align: center;
}
.ap-federation__stat-count {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--color-on-background);
}
.ap-federation__stat-label {
font-size: var(--font-size-s);
color: var(--color-on-offset);
word-break: break-word;
}
.ap-federation__actions-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-s);
align-items: center;
}
.ap-federation__result {
margin-block-start: var(--space-xs);
color: var(--color-green50);
font-size: var(--font-size-s);
}
.ap-federation__error {
margin-block-start: var(--space-xs);
color: var(--color-red45);
font-size: var(--font-size-s);
}
.ap-federation__lookup-form {
display: flex;
gap: var(--space-s);
}
.ap-federation__lookup-input {
flex: 1;
min-width: 0;
padding: 0.5rem 0.75rem;
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
font: inherit;
color: var(--color-on-background);
background: var(--color-background);
}
.ap-federation__json-view {
margin-block-start: var(--space-s);
padding: var(--space-m);
background: var(--color-offset);
border-radius: var(--border-radius-small);
font-family: monospace;
font-size: var(--font-size-s);
color: var(--color-on-background);
max-height: 24rem;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.ap-federation__posts-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.ap-federation__post-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-m);
padding: var(--space-s);
background: var(--color-offset);
border-radius: var(--border-radius-small);
}
.ap-federation__post-info {
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-width: 0;
}
.ap-federation__post-title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ap-federation__post-meta {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-s);
color: var(--color-on-offset);
}
.ap-federation__post-actions {
display: flex;
gap: var(--space-xs);
flex-shrink: 0;
}
.ap-federation__post-btn {
padding: var(--space-xs) var(--space-s);
font-size: var(--font-size-s);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
background: var(--color-background);
color: var(--color-on-background);
cursor: pointer;
}
.ap-federation__post-btn:hover {
background: var(--color-offset);
}
.ap-federation__post-btn--danger {
color: var(--color-red45);
border-color: var(--color-red45);
}
.ap-federation__post-btn--danger:hover {
background: color-mix(in srgb, var(--color-red45) 10%, transparent);
}
.ap-federation__modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--tint-neutral) 10% / 0.5);
}
.ap-federation__modal {
width: min(90vw, 48rem);
max-height: 80vh;
display: flex;
flex-direction: column;
background: var(--color-background);
border-radius: var(--border-radius-small);
box-shadow: 0 4px 24px hsl(var(--tint-neutral) 10% / 0.2);
}
.ap-federation__modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-s) var(--space-m);
border-block-end: var(--border-width-thin) solid var(--color-outline);
}
.ap-federation__modal-header h3 {
margin: 0;
font-size: var(--font-size-m);
}
.ap-federation__modal-close {
font-size: var(--font-size-xl);
line-height: 1;
padding: 0 var(--space-xs);
border: none;
background: none;
color: var(--color-on-offset);
cursor: pointer;
}
.ap-federation__modal .ap-federation__json-view {
margin: 0;
border-radius: 0 0 var(--border-radius-small) var(--border-radius-small);
flex: 1;
overflow: auto;
}
@media (max-width: 40rem) {
.ap-federation__post-row {
flex-direction: column;
align-items: flex-start;
}
.ap-federation__lookup-form {
flex-direction: column;
}
}

111
index.js
View File

@@ -8,6 +8,7 @@ import {
import {
jf2ToActivityStreams,
jf2ToAS2Activity,
parseMentions,
} from "./lib/jf2-to-as2.js";
import { dashboardController } from "./lib/controllers/dashboard.js";
import {
@@ -99,6 +100,13 @@ import { resolveAuthor } from "./lib/resolve-author.js";
import { scheduleCleanup } from "./lib/timeline-cleanup.js";
import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
import { deleteFederationController } from "./lib/controllers/federation-delete.js";
import {
federationMgmtController,
rebroadcastController,
viewApJsonController,
broadcastActorUpdateController,
lookupObjectController,
} from "./lib/controllers/federation-mgmt.js";
const defaults = {
mountPath: "/activitypub",
@@ -169,6 +177,11 @@ export default class ActivityPubEndpoint {
text: "activitypub.myProfile.title",
requiresDatabase: true,
},
{
href: `${this.options.mountPath}/admin/federation`,
text: "activitypub.federationMgmt.title",
requiresDatabase: true,
},
];
}
@@ -313,6 +326,11 @@ export default class ActivityPubEndpoint {
router.post("/admin/refollow/resume", refollowResumeController(mp, this));
router.get("/admin/refollow/status", refollowStatusController(mp));
router.post("/admin/federation/delete", deleteFederationController(mp, this));
router.get("/admin/federation", federationMgmtController(mp, this));
router.post("/admin/federation/rebroadcast", rebroadcastController(mp, this));
router.get("/admin/federation/ap-json", viewApJsonController(mp, this));
router.post("/admin/federation/broadcast-actor", broadcastActorUpdateController(mp, this));
router.get("/admin/federation/lookup", lookupObjectController(mp, this));
return router;
}
@@ -326,6 +344,38 @@ export default class ActivityPubEndpoint {
const router = express.Router(); // eslint-disable-line new-cap
const self = this;
// Intercept Micropub delete actions to broadcast Delete to fediverse.
// Wraps res.json to detect successful delete responses, then fires
// broadcastDelete asynchronously so remote servers remove the post.
router.use((req, res, next) => {
if (req.method !== "POST") return next();
if (!req.path.endsWith("/micropub")) return next();
const action = req.query?.action || req.body?.action;
if (action !== "delete") return next();
const postUrl = req.query?.url || req.body?.url;
if (!postUrl) return next();
const originalJson = res.json.bind(res);
res.json = function (body) {
// Fire broadcastDelete after successful delete (status 200)
if (res.statusCode === 200 && body?.success === "delete") {
console.info(
`[ActivityPub] Micropub delete detected for ${postUrl}, broadcasting Delete to followers`,
);
self.broadcastDelete(postUrl).catch((error) => {
console.warn(
`[ActivityPub] broadcastDelete after Micropub delete failed: ${error.message}`,
);
});
}
return originalJson(body);
};
return next();
});
// Let Fedify handle NodeInfo data (/nodeinfo/2.1)
// Only pass GET/HEAD requests — POST/PUT/DELETE must not go through
// Fedify here, because fromExpressRequest() consumes the body stream,
@@ -484,6 +534,41 @@ export default class ActivityPubEndpoint {
}
}
// Resolve @user@domain mentions in content via WebFinger
const contentText = properties.content?.html || properties.content || "";
const mentionHandles = parseMentions(contentText);
const resolvedMentions = [];
const mentionRecipients = [];
for (const { handle } of mentionHandles) {
try {
const mentionedActor = await ctx.lookupObject(
new URL(`acct:${handle}`),
);
if (mentionedActor?.id) {
resolvedMentions.push({
handle,
actorUrl: mentionedActor.id.href,
profileUrl: mentionedActor.url?.href || null,
});
mentionRecipients.push({
handle,
actorUrl: mentionedActor.id.href,
actor: mentionedActor,
});
console.info(
`[ActivityPub] Resolved mention @${handle}${mentionedActor.id.href}`,
);
}
} catch (error) {
console.warn(
`[ActivityPub] Could not resolve mention @${handle}: ${error.message}`,
);
// Still add with no actorUrl so it gets a fallback link
resolvedMentions.push({ handle, actorUrl: null });
}
}
const activity = jf2ToAS2Activity(
properties,
actorUrl,
@@ -492,6 +577,7 @@ export default class ActivityPubEndpoint {
replyToActorUrl: replyToActor?.url,
replyToActorHandle: replyToActor?.handle,
visibility: self.options.defaultVisibility,
mentions: resolvedMentions,
},
);
@@ -546,12 +632,35 @@ export default class ActivityPubEndpoint {
}
}
// Deliver to mentioned actors' inboxes (skip reply-to author, already delivered above)
for (const { handle: mHandle, actorUrl: mUrl, actor: mActor } of mentionRecipients) {
if (replyToActor?.url === mUrl) continue;
try {
await ctx.sendActivity(
{ identifier: handle },
mActor,
activity,
{ orderingKey: properties.url },
);
console.info(
`[ActivityPub] Mention delivered to @${mHandle}: ${mUrl}`,
);
} catch (error) {
console.warn(
`[ActivityPub] Failed to deliver mention to @${mHandle}: ${error.message}`,
);
}
}
// Determine activity type name
const typeName =
activity.constructor?.name || "Create";
const replyNote = replyToActor
? ` (reply to ${replyToActor.url})`
: "";
const mentionNote = mentionRecipients.length > 0
? ` (mentions: ${mentionRecipients.map(m => `@${m.handle}`).join(", ")})`
: "";
await logActivity(self._collections.ap_activities, {
direction: "outbound",
@@ -559,7 +668,7 @@ export default class ActivityPubEndpoint {
actorUrl: self._publicationUrl,
objectUrl: properties.url,
targetUrl: properties["in-reply-to"] || undefined,
summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}`,
summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}${mentionNote}`,
});
console.info(

View File

@@ -221,7 +221,8 @@ export function submitComposeController(mountPath, plugin) {
}
const { application } = request.app.locals;
const { content } = request.body;
const { content, visibility, summary } = request.body;
const cwEnabled = request.body["cw-enabled"];
const inReplyTo = request.body["in-reply-to"];
const syndicateTo = request.body["mp-syndicate-to"];
const isDirect = request.body["is-direct"] === "true";
@@ -353,6 +354,15 @@ export function submitComposeController(mountPath, plugin) {
micropubData.append("in-reply-to", inReplyTo);
}
if (visibility && visibility !== "public") {
micropubData.append("visibility", visibility);
}
if (cwEnabled && summary && summary.trim()) {
micropubData.append("summary", summary.trim());
micropubData.append("sensitive", "true");
}
if (syndicateTo) {
const targets = Array.isArray(syndicateTo)
? syndicateTo

View File

@@ -0,0 +1,310 @@
/**
* Federation Management controllers — admin page for inspecting and managing
* the relationship between local content and the fediverse.
*/
import { getToken, validateToken } from "../csrf.js";
import { jf2ToActivityStreams } from "../jf2-to-as2.js";
const PAGE_SIZE = 20;
const AP_COLLECTIONS = [
"ap_followers",
"ap_following",
"ap_activities",
"ap_keys",
"ap_kv",
"ap_profile",
"ap_featured",
"ap_featured_tags",
"ap_timeline",
"ap_notifications",
"ap_muted",
"ap_blocked",
"ap_interactions",
"ap_followed_tags",
"ap_messages",
"ap_explore_tabs",
"ap_reports",
];
/**
* GET /admin/federation — main federation management page.
*/
export function federationMgmtController(mountPath, plugin) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const collections = application?.collections;
// Parallel: collection stats + posts + recent activities
const [collectionStats, postsResult, recentActivities] =
await Promise.all([
getCollectionStats(collections),
getPaginatedPosts(collections, request.query.page),
getRecentActivities(collections),
]);
const csrfToken = getToken(request.session);
const actorUrl = plugin._getActorUrl?.() || "";
response.render("activitypub-federation-mgmt", {
title: response.locals.__("activitypub.federationMgmt.title"),
parent: {
href: mountPath,
text: response.locals.__("activitypub.title"),
},
collectionStats,
posts: postsResult.posts,
cursor: postsResult.cursor,
recentActivities,
csrfToken,
mountPath,
publicationUrl: plugin._publicationUrl,
actorUrl,
debugDashboardEnabled: plugin.options.debugDashboard,
});
} catch (error) {
next(error);
}
};
}
/**
* POST /admin/federation/rebroadcast — re-send a Create activity for a post.
*/
export function rebroadcastController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response
.status(403)
.json({ success: false, error: "Invalid CSRF token" });
}
const { url } = request.body;
if (!url) {
return response
.status(400)
.json({ success: false, error: "Missing post URL" });
}
if (!plugin._federation) {
return response
.status(503)
.json({ success: false, error: "Federation not initialized" });
}
const { application } = request.app.locals;
const postsCol = application?.collections?.get("posts");
if (!postsCol) {
return response
.status(500)
.json({ success: false, error: "Posts collection not available" });
}
const post = await postsCol.findOne({ "properties.url": url });
if (!post) {
return response
.status(404)
.json({ success: false, error: "Post not found" });
}
// Reuse the full syndication pipeline (mention resolution, visibility,
// addressing, delivery) via the syndicator
await plugin.syndicator.syndicate(post.properties);
return response.json({ success: true, url });
} catch (error) {
next(error);
}
};
}
/**
* GET /admin/federation/ap-json — view ActivityStreams JSON for a post.
*/
export function viewApJsonController(mountPath, plugin) {
return async (request, response, next) => {
try {
const { url } = request.query;
if (!url) {
return response
.status(400)
.json({ error: "Missing url query parameter" });
}
const { application } = request.app.locals;
const postsCol = application?.collections?.get("posts");
if (!postsCol) {
return response
.status(500)
.json({ error: "Posts collection not available" });
}
const post = await postsCol.findOne({ "properties.url": url });
if (!post) {
return response.status(404).json({ error: "Post not found" });
}
const actorUrl = plugin._getActorUrl?.() || "";
const as2 = jf2ToActivityStreams(
post.properties,
actorUrl,
plugin._publicationUrl,
);
return response.json(as2);
} catch (error) {
next(error);
}
};
}
/**
* POST /admin/federation/broadcast-actor — broadcast an Update(Person)
* activity to all followers via Fedify.
*/
export function broadcastActorUpdateController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response
.status(403)
.json({ success: false, error: "Invalid CSRF token" });
}
if (!plugin._federation) {
return response
.status(503)
.json({ success: false, error: "Federation not initialized" });
}
await plugin.broadcastActorUpdate();
return response.json({ success: true });
} catch (error) {
next(error);
}
};
}
/**
* GET /admin/federation/lookup — resolve a URL or @user@domain handle
* via Fedify's lookupObject (authenticated document loader).
*/
export function lookupObjectController(mountPath, plugin) {
return async (request, response, next) => {
try {
const query = (request.query.q || "").trim();
if (!query) {
return response
.status(400)
.json({ error: "Missing q query parameter" });
}
if (!plugin._federation) {
return response
.status(503)
.json({ error: "Federation not initialized" });
}
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
const object = await ctx.lookupObject(query, { documentLoader });
if (!object) {
return response
.status(404)
.json({ error: "Could not resolve object" });
}
const jsonLd = await object.toJsonLd();
return response.json(jsonLd);
} catch (error) {
return response
.status(500)
.json({ error: error.message || "Lookup failed" });
}
};
}
// --- Helpers ---
async function getCollectionStats(collections) {
if (!collections) return [];
const stats = await Promise.all(
AP_COLLECTIONS.map(async (name) => {
const col = collections.get(name);
const count = col ? await col.countDocuments() : 0;
return { name, count };
}),
);
return stats;
}
async function getPaginatedPosts(collections, pageParam) {
const postsCol = collections?.get("posts");
if (!postsCol) return { posts: [], cursor: null };
const page = Math.max(1, Number.parseInt(pageParam, 10) || 1);
const totalCount = await postsCol.countDocuments();
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const rawPosts = await postsCol
.find()
.sort({ "properties.published": -1 })
.skip((page - 1) * PAGE_SIZE)
.limit(PAGE_SIZE)
.toArray();
const posts = rawPosts.map((post) => {
const props = post.properties || {};
const url = props.url || "";
const content = props.content?.text || props.content?.html || "";
const name =
props.name || (content ? content.slice(0, 80) : url.split("/").pop());
return {
url,
name,
postType: props["post-type"] || "unknown",
published: props.published || null,
syndication: props.syndication || [],
deleted: props.deleted || false,
};
});
const cursor = buildCursor(page, totalPages, "admin/federation");
return { posts, cursor };
}
async function getRecentActivities(collections) {
const col = collections?.get("ap_activities");
if (!col) return [];
return col.find().sort({ receivedAt: -1 }).limit(5).toArray();
}
function buildCursor(page, totalPages, basePath) {
if (totalPages <= 1) return null;
return {
previous:
page > 1 ? { href: `${basePath}?page=${page - 1}` } : undefined,
next:
page < totalPages
? { href: `${basePath}?page=${page + 1}` }
: undefined,
};
}

View File

@@ -36,6 +36,50 @@ function linkifyUrls(html) {
);
}
/**
* Parse @user@domain mention patterns from text content.
* Returns array of { handle: "user@domain", username: "user", domain: "domain.tld" }.
*/
export function parseMentions(text) {
if (!text) return [];
// Strip HTML tags for parsing
const plain = text.replace(/<[^>]*>/g, " ");
const mentionRegex = /(?<![\/\w])@([\w.-]+)@([\w.-]+\.\w{2,})/g;
const mentions = [];
const seen = new Set();
let match;
while ((match = mentionRegex.exec(plain)) !== null) {
const handle = `${match[1]}@${match[2]}`;
if (!seen.has(handle.toLowerCase())) {
seen.add(handle.toLowerCase());
mentions.push({ handle, username: match[1], domain: match[2] });
}
}
return mentions;
}
/**
* Replace @user@domain patterns in HTML with linked mentions.
* resolvedMentions: [{ handle, actorUrl, profileUrl? }]
* Uses profileUrl (human-readable) for href, falls back to Mastodon-style URL.
*/
function linkifyMentions(html, resolvedMentions) {
if (!html || !resolvedMentions?.length) return html;
for (const { handle, profileUrl } of resolvedMentions) {
// Escape handle for regex (dots, hyphens)
const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Match @handle not already inside an HTML tag attribute or anchor text
const pattern = new RegExp(`(?<!["\\/\\w])@${escaped}(?![\\w])`, "gi");
const parts = handle.split("@");
const url = profileUrl || `https://${parts[1]}/@${parts[0]}`;
html = html.replace(
pattern,
`<a href="${url}" class="mention" rel="nofollow noopener" target="_blank">@${handle}</a>`,
);
}
return html;
}
// ---------------------------------------------------------------------------
// Plain JSON-LD (content negotiation on individual post URLs)
// ---------------------------------------------------------------------------
@@ -155,10 +199,27 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl, optio
}
const tags = buildPlainTags(properties, publicationUrl, object.tag);
// Add Mention tags + cc addressing + content linkification for @mentions
const resolvedMentions = options.mentions || [];
for (const { handle, actorUrl: mentionUrl } of resolvedMentions) {
if (mentionUrl) {
tags.push({ type: "Mention", href: mentionUrl, name: `@${handle}` });
if (!object.cc.includes(mentionUrl)) {
object.cc.push(mentionUrl);
}
}
}
if (tags.length > 0) {
object.tag = tags;
}
// Linkify @mentions in content (resolved get actor links, unresolved get profile links)
if (resolvedMentions.length > 0 && object.content) {
object.content = linkifyMentions(object.content, resolvedMentions);
}
return {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Create",
@@ -310,7 +371,7 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options =
noteOptions.attachments = fedifyAttachments;
}
// Tags: hashtags + Mention for reply addressing
// Tags: hashtags + Mention for reply addressing + @mentions
const fedifyTags = buildFedifyTags(properties, publicationUrl, postType);
if (replyToActorUrl) {
@@ -322,10 +383,46 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options =
);
}
// Add Mention tags + cc addressing for resolved @mentions
const resolvedMentions = options.mentions || [];
const ccUrls = [];
for (const { handle, actorUrl: mentionUrl } of resolvedMentions) {
if (mentionUrl) {
// Skip if same as replyToActorUrl (already added above)
const alreadyTagged = replyToActorUrl && mentionUrl === replyToActorUrl;
if (!alreadyTagged) {
fedifyTags.push(
new Mention({
href: new URL(mentionUrl),
name: `@${handle}`,
}),
);
}
ccUrls.push(new URL(mentionUrl));
}
}
// Merge mention actors into cc/ccs
if (ccUrls.length > 0) {
if (noteOptions.ccs) {
noteOptions.ccs = [...noteOptions.ccs, ...ccUrls];
} else if (noteOptions.cc) {
noteOptions.ccs = [noteOptions.cc, ...ccUrls];
delete noteOptions.cc;
} else {
noteOptions.ccs = ccUrls;
}
}
if (fedifyTags.length > 0) {
noteOptions.tags = fedifyTags;
}
// Linkify @mentions in content
if (resolvedMentions.length > 0 && noteOptions.content) {
noteOptions.content = linkifyMentions(noteOptions.content, resolvedMentions);
}
const object = isArticle
? new Article(noteOptions)
: new Note(noteOptions);

View File

@@ -145,7 +145,13 @@
"syndicateLabel": "Syndicate to",
"submitMicropub": "Post reply",
"cancel": "Cancel",
"errorEmpty": "Reply content cannot be empty"
"errorEmpty": "Reply content cannot be empty",
"visibilityLabel": "Visibility",
"visibilityPublic": "Public",
"visibilityUnlisted": "Unlisted",
"visibilityFollowers": "Followers only",
"cwLabel": "Content warning",
"cwPlaceholder": "Write your warning here…"
},
"notifications": {
"title": "Notifications",
@@ -316,6 +322,27 @@
"deleteSuccess": "Delete activity sent to followers",
"deleteButton": "Delete from fediverse"
},
"federationMgmt": {
"title": "Federation",
"collections": "Collection health",
"quickActions": "Quick actions",
"broadcastActor": "Broadcast actor update",
"debugDashboard": "Debug dashboard",
"objectLookup": "Object lookup",
"lookupPlaceholder": "URL or @user@domain handle…",
"lookup": "Look up",
"lookupLoading": "Resolving…",
"postActions": "Post federation",
"viewJson": "JSON",
"rebroadcast": "Re-broadcast Create activity",
"rebroadcastShort": "Re-send",
"broadcastDelete": "Broadcast Delete activity",
"deleteShort": "Delete",
"noPosts": "No posts found.",
"apJsonTitle": "ActivityStreams JSON-LD",
"recentActivity": "Recent activity",
"viewAllActivities": "View all activities →"
},
"reports": {
"sentReport": "filed a report",
"title": "Reports"

View File

@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "2.10.1",
"version": "2.12.2",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",

View File

@@ -37,6 +37,18 @@
<input type="hidden" name="sender-actor-url" value="{{ senderActorUrl }}">
{% endif %}
{# Content warning toggle + summary #}
<div class="ap-compose__cw">
<label class="ap-compose__cw-toggle">
<input type="checkbox" name="cw-enabled" id="cw-toggle"
onchange="document.getElementById('cw-text').style.display = this.checked ? 'block' : 'none'">
{{ __("activitypub.compose.cwLabel") }}
</label>
<input type="text" name="summary" id="cw-text" class="ap-compose__cw-input"
placeholder="{{ __('activitypub.compose.cwPlaceholder') }}"
style="display: none">
</div>
{# Content textarea #}
<div class="ap-compose__editor">
<textarea name="content" class="ap-compose__textarea"
@@ -45,6 +57,25 @@
required></textarea>
</div>
{# Visibility — hidden for direct messages #}
{% if not isDirect %}
<fieldset class="ap-compose__visibility">
<legend>{{ __("activitypub.compose.visibilityLabel") }}</legend>
<label class="ap-compose__visibility-option">
<input type="radio" name="visibility" value="public" checked>
{{ __("activitypub.compose.visibilityPublic") }}
</label>
<label class="ap-compose__visibility-option">
<input type="radio" name="visibility" value="unlisted">
{{ __("activitypub.compose.visibilityUnlisted") }}
</label>
<label class="ap-compose__visibility-option">
<input type="radio" name="visibility" value="followers">
{{ __("activitypub.compose.visibilityFollowers") }}
</label>
</fieldset>
{% endif %}
{# Syndication targets — hidden for direct messages #}
{% if syndicationTargets.length > 0 and not isDirect %}
<fieldset class="ap-compose__syndication">

View File

@@ -0,0 +1,245 @@
{% extends "layouts/ap-reader.njk" %}
{% from "card/macro.njk" import card with context %}
{% from "badge/macro.njk" import badge with context %}
{% from "prose/macro.njk" import prose with context %}
{% from "pagination/macro.njk" import pagination with context %}
{% block readercontent %}
<div x-data="federationMgmt()" data-mount-path="{{ mountPath }}" data-csrf-token="{{ csrfToken }}">
{# --- Collection Health --- #}
<section class="ap-federation__section">
<h2>{{ __("activitypub.federationMgmt.collections") }}</h2>
<div class="ap-federation__stats-grid">
{% for stat in collectionStats %}
<div class="ap-federation__stat-card">
<span class="ap-federation__stat-count">{{ stat.count }}</span>
<span class="ap-federation__stat-label">{{ stat.name | replace("ap_", "") }}</span>
</div>
{% endfor %}
</div>
</section>
{# --- Quick Actions --- #}
<section class="ap-federation__section">
<h2>{{ __("activitypub.federationMgmt.quickActions") }}</h2>
<div class="ap-federation__actions-row">
<button class="button" @click="broadcastActorUpdate()" :disabled="actionInProgress">
{{ __("activitypub.federationMgmt.broadcastActor") }}
</button>
{% if debugDashboardEnabled %}
<a href="{{ mountPath }}/__debug__/" class="button" target="_blank" rel="noopener">
{{ __("activitypub.federationMgmt.debugDashboard") }}
</a>
{% endif %}
</div>
<p x-show="actionResult" x-text="actionResult" class="ap-federation__result" x-cloak></p>
</section>
{# --- Object Lookup --- #}
<section class="ap-federation__section">
<h2>{{ __("activitypub.federationMgmt.objectLookup") }}</h2>
<form class="ap-federation__lookup-form" @submit.prevent="lookupObject()">
<input type="text" x-model="lookupQuery"
placeholder="{{ __('activitypub.federationMgmt.lookupPlaceholder') }}"
class="ap-federation__lookup-input">
<button type="submit" class="button" :disabled="lookupLoading">
<span x-show="!lookupLoading">{{ __("activitypub.federationMgmt.lookup") }}</span>
<span x-show="lookupLoading" x-cloak>{{ __("activitypub.federationMgmt.lookupLoading") }}</span>
</button>
</form>
<p x-show="lookupError" x-text="lookupError" class="ap-federation__error" x-cloak></p>
<pre x-show="lookupResult" x-text="lookupResult" class="ap-federation__json-view" x-cloak></pre>
</section>
{# --- Post Federation --- #}
<section class="ap-federation__section">
<h2>{{ __("activitypub.federationMgmt.postActions") }}</h2>
{% if posts.length > 0 %}
<div class="ap-federation__posts-list">
{% for post in posts %}
<div class="ap-federation__post-row">
<div class="ap-federation__post-info">
<a href="{{ post.url }}" class="ap-federation__post-title">{{ post.name }}</a>
<span class="ap-federation__post-meta">
{{ badge({ text: post.postType }) }}
{% if post.published %}
<time>{{ post.published | date("PP") }}</time>
{% endif %}
{% if post.deleted %}
{{ badge({ text: "deleted", color: "red" }) }}
{% endif %}
</span>
</div>
<div class="ap-federation__post-actions">
<button class="ap-federation__post-btn"
@click="viewApJson('{{ post.url }}')">
{{ __("activitypub.federationMgmt.viewJson") }}
</button>
<button class="ap-federation__post-btn"
@click="rebroadcast('{{ post.url }}')">
{{ __("activitypub.federationMgmt.rebroadcastShort") }}
</button>
<button class="ap-federation__post-btn ap-federation__post-btn--danger"
@click="broadcastDelete('{{ post.url }}')">
{{ __("activitypub.federationMgmt.deleteShort") }}
</button>
</div>
</div>
{% endfor %}
</div>
{{ pagination(cursor) if cursor }}
{% else %}
{{ prose({ text: __("activitypub.federationMgmt.noPosts") }) }}
{% endif %}
</section>
{# --- Recent Activity --- #}
<section class="ap-federation__section">
<h2>{{ __("activitypub.federationMgmt.recentActivity") }}</h2>
{% if recentActivities.length > 0 %}
{% for activity in recentActivities %}
{{ card({
title: activity.actorName or activity.actorUrl,
description: { text: activity.summary },
published: activity.receivedAt,
badges: [
{ text: activity.type },
{ text: __("activitypub.directionInbound") if activity.direction === "inbound" else __("activitypub.directionOutbound") }
]
}) }}
{% endfor %}
<p><a href="{{ mountPath }}/admin/activities">{{ __("activitypub.federationMgmt.viewAllActivities") }}</a></p>
{% else %}
{{ prose({ text: __("activitypub.noActivity") }) }}
{% endif %}
</section>
{# --- JSON Modal --- #}
<div class="ap-federation__modal-overlay" x-show="jsonModalOpen" x-cloak
@click.self="jsonModalOpen = false" @keydown.escape.window="jsonModalOpen = false">
<div class="ap-federation__modal">
<div class="ap-federation__modal-header">
<h3>{{ __("activitypub.federationMgmt.apJsonTitle") }}</h3>
<button class="ap-federation__modal-close" @click="jsonModalOpen = false">&times;</button>
</div>
<pre x-text="jsonModalData" class="ap-federation__json-view"></pre>
</div>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('federationMgmt', () => ({
actionInProgress: false,
actionResult: '',
lookupQuery: '',
lookupLoading: false,
lookupError: '',
lookupResult: '',
jsonModalOpen: false,
jsonModalData: '',
get mountPath() { return this.$root.dataset.mountPath; },
get csrfToken() { return this.$root.dataset.csrfToken; },
async broadcastActorUpdate() {
this.actionInProgress = true;
this.actionResult = '';
try {
const res = await fetch(this.mountPath + '/admin/federation/broadcast-actor', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken,
},
body: JSON.stringify({}),
});
const data = await res.json();
this.actionResult = data.success ? 'Actor update broadcast sent.' : (data.error || 'Failed');
} catch {
this.actionResult = 'Request failed';
}
this.actionInProgress = false;
setTimeout(() => { this.actionResult = ''; }, 5000);
},
async lookupObject() {
const q = this.lookupQuery.trim();
if (!q) return;
this.lookupLoading = true;
this.lookupError = '';
this.lookupResult = '';
try {
const res = await fetch(this.mountPath + '/admin/federation/lookup?q=' + encodeURIComponent(q));
const data = await res.json();
if (data.error) {
this.lookupError = data.error;
} else {
this.lookupResult = JSON.stringify(data, null, 2);
}
} catch {
this.lookupError = 'Request failed';
}
this.lookupLoading = false;
},
async viewApJson(url) {
try {
const res = await fetch(this.mountPath + '/admin/federation/ap-json?url=' + encodeURIComponent(url));
const data = await res.json();
if (data.error) {
this.jsonModalData = 'Error: ' + data.error;
} else {
this.jsonModalData = JSON.stringify(data, null, 2);
}
this.jsonModalOpen = true;
} catch {
this.jsonModalData = 'Request failed';
this.jsonModalOpen = true;
}
},
async rebroadcast(url) {
if (!confirm('Re-send this post to all followers?')) return;
try {
const res = await fetch(this.mountPath + '/admin/federation/rebroadcast', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken,
},
body: JSON.stringify({ url }),
});
const data = await res.json();
this.actionResult = data.success ? 'Post re-broadcast sent.' : (data.error || 'Failed');
} catch {
this.actionResult = 'Request failed';
}
setTimeout(() => { this.actionResult = ''; }, 5000);
},
async broadcastDelete(url) {
if (!confirm('Broadcast Delete for this post? Remote servers will remove it.')) return;
try {
const res = await fetch(this.mountPath + '/admin/federation/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken,
},
body: JSON.stringify({ url }),
});
const data = await res.json();
this.actionResult = data.success ? 'Delete broadcast sent.' : (data.error || 'Failed');
} catch {
this.actionResult = 'Request failed';
}
setTimeout(() => { this.actionResult = ''; }, 5000);
},
}));
});
</script>
{% endblock %}