@rmdes/indiekit-endpoint-activitypub 2.11.1 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/reader.css CHANGED
@@ -3190,3 +3190,221 @@
3190
3190
  }
3191
3191
  }
3192
3192
 
3193
+ /* ==========================================================================
3194
+ Federation Management
3195
+ ========================================================================== */
3196
+
3197
+ .ap-federation__section {
3198
+ margin-block-end: var(--space-l);
3199
+ }
3200
+
3201
+ .ap-federation__section h2 {
3202
+ margin-block-end: var(--space-s);
3203
+ }
3204
+
3205
+ .ap-federation__stats-grid {
3206
+ display: grid;
3207
+ grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
3208
+ gap: var(--space-s);
3209
+ }
3210
+
3211
+ .ap-federation__stat-card {
3212
+ display: flex;
3213
+ flex-direction: column;
3214
+ align-items: center;
3215
+ gap: var(--space-xs);
3216
+ padding: var(--space-s);
3217
+ background: var(--color-offset);
3218
+ border-radius: var(--border-radius-small);
3219
+ text-align: center;
3220
+ }
3221
+
3222
+ .ap-federation__stat-count {
3223
+ font-size: var(--font-size-xl);
3224
+ font-weight: 600;
3225
+ color: var(--color-on-background);
3226
+ }
3227
+
3228
+ .ap-federation__stat-label {
3229
+ font-size: var(--font-size-s);
3230
+ color: var(--color-on-offset);
3231
+ word-break: break-word;
3232
+ }
3233
+
3234
+ .ap-federation__actions-row {
3235
+ display: flex;
3236
+ flex-wrap: wrap;
3237
+ gap: var(--space-s);
3238
+ align-items: center;
3239
+ }
3240
+
3241
+ .ap-federation__result {
3242
+ margin-block-start: var(--space-xs);
3243
+ color: var(--color-green50);
3244
+ font-size: var(--font-size-s);
3245
+ }
3246
+
3247
+ .ap-federation__error {
3248
+ margin-block-start: var(--space-xs);
3249
+ color: var(--color-red45);
3250
+ font-size: var(--font-size-s);
3251
+ }
3252
+
3253
+ .ap-federation__lookup-form {
3254
+ display: flex;
3255
+ gap: var(--space-s);
3256
+ }
3257
+
3258
+ .ap-federation__lookup-input {
3259
+ flex: 1;
3260
+ min-width: 0;
3261
+ padding: 0.5rem 0.75rem;
3262
+ border: var(--border-width-thin) solid var(--color-outline);
3263
+ border-radius: var(--border-radius-small);
3264
+ font: inherit;
3265
+ color: var(--color-on-background);
3266
+ background: var(--color-background);
3267
+ }
3268
+
3269
+ .ap-federation__json-view {
3270
+ margin-block-start: var(--space-s);
3271
+ padding: var(--space-m);
3272
+ background: var(--color-offset);
3273
+ border-radius: var(--border-radius-small);
3274
+ font-family: monospace;
3275
+ font-size: var(--font-size-s);
3276
+ color: var(--color-on-background);
3277
+ max-height: 24rem;
3278
+ overflow: auto;
3279
+ white-space: pre-wrap;
3280
+ word-break: break-word;
3281
+ }
3282
+
3283
+ .ap-federation__posts-list {
3284
+ display: flex;
3285
+ flex-direction: column;
3286
+ gap: var(--space-xs);
3287
+ }
3288
+
3289
+ .ap-federation__post-row {
3290
+ display: flex;
3291
+ justify-content: space-between;
3292
+ align-items: center;
3293
+ gap: var(--space-m);
3294
+ padding: var(--space-s);
3295
+ background: var(--color-offset);
3296
+ border-radius: var(--border-radius-small);
3297
+ }
3298
+
3299
+ .ap-federation__post-info {
3300
+ display: flex;
3301
+ flex-direction: column;
3302
+ gap: var(--space-xs);
3303
+ min-width: 0;
3304
+ }
3305
+
3306
+ .ap-federation__post-title {
3307
+ font-weight: 500;
3308
+ white-space: nowrap;
3309
+ overflow: hidden;
3310
+ text-overflow: ellipsis;
3311
+ }
3312
+
3313
+ .ap-federation__post-meta {
3314
+ display: flex;
3315
+ align-items: center;
3316
+ gap: var(--space-xs);
3317
+ font-size: var(--font-size-s);
3318
+ color: var(--color-on-offset);
3319
+ }
3320
+
3321
+ .ap-federation__post-actions {
3322
+ display: flex;
3323
+ gap: var(--space-xs);
3324
+ flex-shrink: 0;
3325
+ }
3326
+
3327
+ .ap-federation__post-btn {
3328
+ padding: var(--space-xs) var(--space-s);
3329
+ font-size: var(--font-size-s);
3330
+ border: var(--border-width-thin) solid var(--color-outline);
3331
+ border-radius: var(--border-radius-small);
3332
+ background: var(--color-background);
3333
+ color: var(--color-on-background);
3334
+ cursor: pointer;
3335
+ }
3336
+
3337
+ .ap-federation__post-btn:hover {
3338
+ background: var(--color-offset);
3339
+ }
3340
+
3341
+ .ap-federation__post-btn--danger {
3342
+ color: var(--color-red45);
3343
+ border-color: var(--color-red45);
3344
+ }
3345
+
3346
+ .ap-federation__post-btn--danger:hover {
3347
+ background: color-mix(in srgb, var(--color-red45) 10%, transparent);
3348
+ }
3349
+
3350
+ .ap-federation__modal-overlay {
3351
+ position: fixed;
3352
+ inset: 0;
3353
+ z-index: 1000;
3354
+ display: flex;
3355
+ align-items: center;
3356
+ justify-content: center;
3357
+ background: hsl(var(--tint-neutral) 10% / 0.5);
3358
+ }
3359
+
3360
+ .ap-federation__modal {
3361
+ width: min(90vw, 48rem);
3362
+ max-height: 80vh;
3363
+ display: flex;
3364
+ flex-direction: column;
3365
+ background: var(--color-background);
3366
+ border-radius: var(--border-radius-small);
3367
+ box-shadow: 0 4px 24px hsl(var(--tint-neutral) 10% / 0.2);
3368
+ }
3369
+
3370
+ .ap-federation__modal-header {
3371
+ display: flex;
3372
+ justify-content: space-between;
3373
+ align-items: center;
3374
+ padding: var(--space-s) var(--space-m);
3375
+ border-block-end: var(--border-width-thin) solid var(--color-outline);
3376
+ }
3377
+
3378
+ .ap-federation__modal-header h3 {
3379
+ margin: 0;
3380
+ font-size: var(--font-size-m);
3381
+ }
3382
+
3383
+ .ap-federation__modal-close {
3384
+ font-size: var(--font-size-xl);
3385
+ line-height: 1;
3386
+ padding: 0 var(--space-xs);
3387
+ border: none;
3388
+ background: none;
3389
+ color: var(--color-on-offset);
3390
+ cursor: pointer;
3391
+ }
3392
+
3393
+ .ap-federation__modal .ap-federation__json-view {
3394
+ margin: 0;
3395
+ border-radius: 0 0 var(--border-radius-small) var(--border-radius-small);
3396
+ flex: 1;
3397
+ overflow: auto;
3398
+ }
3399
+
3400
+ @media (max-width: 40rem) {
3401
+ .ap-federation__post-row {
3402
+ flex-direction: column;
3403
+ align-items: flex-start;
3404
+ }
3405
+
3406
+ .ap-federation__lookup-form {
3407
+ flex-direction: column;
3408
+ }
3409
+ }
3410
+
package/index.js CHANGED
@@ -99,6 +99,13 @@ import { logActivity } from "./lib/activity-log.js";
99
99
  import { scheduleCleanup } from "./lib/timeline-cleanup.js";
100
100
  import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
101
101
  import { deleteFederationController } from "./lib/controllers/federation-delete.js";
102
+ import {
103
+ federationMgmtController,
104
+ rebroadcastController,
105
+ viewApJsonController,
106
+ broadcastActorUpdateController,
107
+ lookupObjectController,
108
+ } from "./lib/controllers/federation-mgmt.js";
102
109
 
103
110
  const defaults = {
104
111
  mountPath: "/activitypub",
@@ -169,6 +176,11 @@ export default class ActivityPubEndpoint {
169
176
  text: "activitypub.myProfile.title",
170
177
  requiresDatabase: true,
171
178
  },
179
+ {
180
+ href: `${this.options.mountPath}/admin/federation`,
181
+ text: "activitypub.federationMgmt.title",
182
+ requiresDatabase: true,
183
+ },
172
184
  ];
173
185
  }
174
186
 
@@ -313,6 +325,11 @@ export default class ActivityPubEndpoint {
313
325
  router.post("/admin/refollow/resume", refollowResumeController(mp, this));
314
326
  router.get("/admin/refollow/status", refollowStatusController(mp));
315
327
  router.post("/admin/federation/delete", deleteFederationController(mp, this));
328
+ router.get("/admin/federation", federationMgmtController(mp, this));
329
+ router.post("/admin/federation/rebroadcast", rebroadcastController(mp, this));
330
+ router.get("/admin/federation/ap-json", viewApJsonController(mp, this));
331
+ router.post("/admin/federation/broadcast-actor", broadcastActorUpdateController(mp, this));
332
+ router.get("/admin/federation/lookup", lookupObjectController(mp, this));
316
333
 
317
334
  return router;
318
335
  }
@@ -326,6 +343,38 @@ export default class ActivityPubEndpoint {
326
343
  const router = express.Router(); // eslint-disable-line new-cap
327
344
  const self = this;
328
345
 
346
+ // Intercept Micropub delete actions to broadcast Delete to fediverse.
347
+ // Wraps res.json to detect successful delete responses, then fires
348
+ // broadcastDelete asynchronously so remote servers remove the post.
349
+ router.use((req, res, next) => {
350
+ if (req.method !== "POST") return next();
351
+ if (!req.path.endsWith("/micropub")) return next();
352
+
353
+ const action = req.query?.action || req.body?.action;
354
+ if (action !== "delete") return next();
355
+
356
+ const postUrl = req.query?.url || req.body?.url;
357
+ if (!postUrl) return next();
358
+
359
+ const originalJson = res.json.bind(res);
360
+ res.json = function (body) {
361
+ // Fire broadcastDelete after successful delete (status 200)
362
+ if (res.statusCode === 200 && body?.success === "delete") {
363
+ console.info(
364
+ `[ActivityPub] Micropub delete detected for ${postUrl}, broadcasting Delete to followers`,
365
+ );
366
+ self.broadcastDelete(postUrl).catch((error) => {
367
+ console.warn(
368
+ `[ActivityPub] broadcastDelete after Micropub delete failed: ${error.message}`,
369
+ );
370
+ });
371
+ }
372
+ return originalJson(body);
373
+ };
374
+
375
+ return next();
376
+ });
377
+
329
378
  // Let Fedify handle NodeInfo data (/nodeinfo/2.1)
330
379
  // Only pass GET/HEAD requests — POST/PUT/DELETE must not go through
331
380
  // Fedify here, because fromExpressRequest() consumes the body stream,
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Federation Management controllers — admin page for inspecting and managing
3
+ * the relationship between local content and the fediverse.
4
+ */
5
+
6
+ import { getToken, validateToken } from "../csrf.js";
7
+ import { jf2ToActivityStreams } from "../jf2-to-as2.js";
8
+
9
+ const PAGE_SIZE = 20;
10
+
11
+ const AP_COLLECTIONS = [
12
+ "ap_followers",
13
+ "ap_following",
14
+ "ap_activities",
15
+ "ap_keys",
16
+ "ap_kv",
17
+ "ap_profile",
18
+ "ap_featured",
19
+ "ap_featured_tags",
20
+ "ap_timeline",
21
+ "ap_notifications",
22
+ "ap_muted",
23
+ "ap_blocked",
24
+ "ap_interactions",
25
+ "ap_followed_tags",
26
+ "ap_messages",
27
+ "ap_explore_tabs",
28
+ "ap_reports",
29
+ ];
30
+
31
+ /**
32
+ * GET /admin/federation — main federation management page.
33
+ */
34
+ export function federationMgmtController(mountPath, plugin) {
35
+ return async (request, response, next) => {
36
+ try {
37
+ const { application } = request.app.locals;
38
+ const collections = application?.collections;
39
+
40
+ // Parallel: collection stats + posts + recent activities
41
+ const [collectionStats, postsResult, recentActivities] =
42
+ await Promise.all([
43
+ getCollectionStats(collections),
44
+ getPaginatedPosts(collections, request.query.page),
45
+ getRecentActivities(collections),
46
+ ]);
47
+
48
+ const csrfToken = getToken(request.session);
49
+ const actorUrl = plugin._getActorUrl?.() || "";
50
+
51
+ response.render("activitypub-federation-mgmt", {
52
+ title: response.locals.__("activitypub.federationMgmt.title"),
53
+ parent: {
54
+ href: mountPath,
55
+ text: response.locals.__("activitypub.title"),
56
+ },
57
+ collectionStats,
58
+ posts: postsResult.posts,
59
+ cursor: postsResult.cursor,
60
+ recentActivities,
61
+ csrfToken,
62
+ mountPath,
63
+ publicationUrl: plugin._publicationUrl,
64
+ actorUrl,
65
+ debugDashboardEnabled: plugin.options.debugDashboard,
66
+ });
67
+ } catch (error) {
68
+ next(error);
69
+ }
70
+ };
71
+ }
72
+
73
+ /**
74
+ * POST /admin/federation/rebroadcast — re-send a Create activity for a post.
75
+ */
76
+ export function rebroadcastController(mountPath, plugin) {
77
+ return async (request, response, next) => {
78
+ try {
79
+ if (!validateToken(request)) {
80
+ return response
81
+ .status(403)
82
+ .json({ success: false, error: "Invalid CSRF token" });
83
+ }
84
+
85
+ const { url } = request.body;
86
+ if (!url) {
87
+ return response
88
+ .status(400)
89
+ .json({ success: false, error: "Missing post URL" });
90
+ }
91
+
92
+ if (!plugin._federation) {
93
+ return response
94
+ .status(503)
95
+ .json({ success: false, error: "Federation not initialized" });
96
+ }
97
+
98
+ const { application } = request.app.locals;
99
+ const postsCol = application?.collections?.get("posts");
100
+ if (!postsCol) {
101
+ return response
102
+ .status(500)
103
+ .json({ success: false, error: "Posts collection not available" });
104
+ }
105
+
106
+ const post = await postsCol.findOne({ "properties.url": url });
107
+ if (!post) {
108
+ return response
109
+ .status(404)
110
+ .json({ success: false, error: "Post not found" });
111
+ }
112
+
113
+ // Reuse the full syndication pipeline (mention resolution, visibility,
114
+ // addressing, delivery) via the syndicator
115
+ await plugin.syndicator.syndicate(post.properties);
116
+
117
+ return response.json({ success: true, url });
118
+ } catch (error) {
119
+ next(error);
120
+ }
121
+ };
122
+ }
123
+
124
+ /**
125
+ * GET /admin/federation/ap-json — view ActivityStreams JSON for a post.
126
+ */
127
+ export function viewApJsonController(mountPath, plugin) {
128
+ return async (request, response, next) => {
129
+ try {
130
+ const { url } = request.query;
131
+ if (!url) {
132
+ return response
133
+ .status(400)
134
+ .json({ error: "Missing url query parameter" });
135
+ }
136
+
137
+ const { application } = request.app.locals;
138
+ const postsCol = application?.collections?.get("posts");
139
+ if (!postsCol) {
140
+ return response
141
+ .status(500)
142
+ .json({ error: "Posts collection not available" });
143
+ }
144
+
145
+ const post = await postsCol.findOne({ "properties.url": url });
146
+ if (!post) {
147
+ return response.status(404).json({ error: "Post not found" });
148
+ }
149
+
150
+ const actorUrl = plugin._getActorUrl?.() || "";
151
+ const as2 = jf2ToActivityStreams(
152
+ post.properties,
153
+ actorUrl,
154
+ plugin._publicationUrl,
155
+ );
156
+
157
+ return response.json(as2);
158
+ } catch (error) {
159
+ next(error);
160
+ }
161
+ };
162
+ }
163
+
164
+ /**
165
+ * POST /admin/federation/broadcast-actor — broadcast an Update(Person)
166
+ * activity to all followers via Fedify.
167
+ */
168
+ export function broadcastActorUpdateController(mountPath, plugin) {
169
+ return async (request, response, next) => {
170
+ try {
171
+ if (!validateToken(request)) {
172
+ return response
173
+ .status(403)
174
+ .json({ success: false, error: "Invalid CSRF token" });
175
+ }
176
+
177
+ if (!plugin._federation) {
178
+ return response
179
+ .status(503)
180
+ .json({ success: false, error: "Federation not initialized" });
181
+ }
182
+
183
+ await plugin.broadcastActorUpdate();
184
+
185
+ return response.json({ success: true });
186
+ } catch (error) {
187
+ next(error);
188
+ }
189
+ };
190
+ }
191
+
192
+ /**
193
+ * GET /admin/federation/lookup — resolve a URL or @user@domain handle
194
+ * via Fedify's lookupObject (authenticated document loader).
195
+ */
196
+ export function lookupObjectController(mountPath, plugin) {
197
+ return async (request, response, next) => {
198
+ try {
199
+ const query = (request.query.q || "").trim();
200
+ if (!query) {
201
+ return response
202
+ .status(400)
203
+ .json({ error: "Missing q query parameter" });
204
+ }
205
+
206
+ if (!plugin._federation) {
207
+ return response
208
+ .status(503)
209
+ .json({ error: "Federation not initialized" });
210
+ }
211
+
212
+ const handle = plugin.options.actor.handle;
213
+ const ctx = plugin._federation.createContext(
214
+ new URL(plugin._publicationUrl),
215
+ { handle, publicationUrl: plugin._publicationUrl },
216
+ );
217
+
218
+ const documentLoader = await ctx.getDocumentLoader({
219
+ identifier: handle,
220
+ });
221
+
222
+ const object = await ctx.lookupObject(query, { documentLoader });
223
+
224
+ if (!object) {
225
+ return response
226
+ .status(404)
227
+ .json({ error: "Could not resolve object" });
228
+ }
229
+
230
+ const jsonLd = await object.toJsonLd();
231
+ return response.json(jsonLd);
232
+ } catch (error) {
233
+ return response
234
+ .status(500)
235
+ .json({ error: error.message || "Lookup failed" });
236
+ }
237
+ };
238
+ }
239
+
240
+ // --- Helpers ---
241
+
242
+ async function getCollectionStats(collections) {
243
+ if (!collections) return [];
244
+
245
+ const stats = await Promise.all(
246
+ AP_COLLECTIONS.map(async (name) => {
247
+ const col = collections.get(name);
248
+ const count = col ? await col.countDocuments() : 0;
249
+ return { name, count };
250
+ }),
251
+ );
252
+
253
+ return stats;
254
+ }
255
+
256
+ async function getPaginatedPosts(collections, pageParam) {
257
+ const postsCol = collections?.get("posts");
258
+ if (!postsCol) return { posts: [], cursor: null };
259
+
260
+ const page = Math.max(1, Number.parseInt(pageParam, 10) || 1);
261
+ const totalCount = await postsCol.countDocuments();
262
+ const totalPages = Math.ceil(totalCount / PAGE_SIZE);
263
+
264
+ const rawPosts = await postsCol
265
+ .find()
266
+ .sort({ "properties.published": -1 })
267
+ .skip((page - 1) * PAGE_SIZE)
268
+ .limit(PAGE_SIZE)
269
+ .toArray();
270
+
271
+ const posts = rawPosts.map((post) => {
272
+ const props = post.properties || {};
273
+ const url = props.url || "";
274
+ const content = props.content?.text || props.content?.html || "";
275
+ const name =
276
+ props.name || (content ? content.slice(0, 80) : url.split("/").pop());
277
+ return {
278
+ url,
279
+ name,
280
+ postType: props["post-type"] || "unknown",
281
+ published: props.published || null,
282
+ syndication: props.syndication || [],
283
+ deleted: props.deleted || false,
284
+ };
285
+ });
286
+
287
+ const cursor = buildCursor(page, totalPages, "admin/federation");
288
+
289
+ return { posts, cursor };
290
+ }
291
+
292
+ async function getRecentActivities(collections) {
293
+ const col = collections?.get("ap_activities");
294
+ if (!col) return [];
295
+
296
+ return col.find().sort({ receivedAt: -1 }).limit(5).toArray();
297
+ }
298
+
299
+ function buildCursor(page, totalPages, basePath) {
300
+ if (totalPages <= 1) return null;
301
+
302
+ return {
303
+ previous:
304
+ page > 1 ? { href: `${basePath}?page=${page - 1}` } : undefined,
305
+ next:
306
+ page < totalPages
307
+ ? { href: `${basePath}?page=${page + 1}` }
308
+ : undefined,
309
+ };
310
+ }
package/locales/en.json CHANGED
@@ -322,6 +322,27 @@
322
322
  "deleteSuccess": "Delete activity sent to followers",
323
323
  "deleteButton": "Delete from fediverse"
324
324
  },
325
+ "federationMgmt": {
326
+ "title": "Federation",
327
+ "collections": "Collection health",
328
+ "quickActions": "Quick actions",
329
+ "broadcastActor": "Broadcast actor update",
330
+ "debugDashboard": "Debug dashboard",
331
+ "objectLookup": "Object lookup",
332
+ "lookupPlaceholder": "URL or @user@domain handle…",
333
+ "lookup": "Look up",
334
+ "lookupLoading": "Resolving…",
335
+ "postActions": "Post federation",
336
+ "viewJson": "JSON",
337
+ "rebroadcast": "Re-broadcast Create activity",
338
+ "rebroadcastShort": "Re-send",
339
+ "broadcastDelete": "Broadcast Delete activity",
340
+ "deleteShort": "Delete",
341
+ "noPosts": "No posts found.",
342
+ "apJsonTitle": "ActivityStreams JSON-LD",
343
+ "recentActivity": "Recent activity",
344
+ "viewAllActivities": "View all activities →"
345
+ },
325
346
  "reports": {
326
347
  "sentReport": "filed a report",
327
348
  "title": "Reports"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.11.1",
3
+ "version": "2.12.0",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -0,0 +1,248 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% from "heading/macro.njk" import heading with context %}
4
+ {% from "card/macro.njk" import card with context %}
5
+ {% from "badge/macro.njk" import badge with context %}
6
+ {% from "prose/macro.njk" import prose with context %}
7
+ {% from "pagination/macro.njk" import pagination with context %}
8
+
9
+ {% block content %}
10
+ <link rel="stylesheet" href="{{ mountPath }}/assets/reader.css">
11
+
12
+ <div x-data="federationMgmt()" data-mount-path="{{ mountPath }}" data-csrf-token="{{ csrfToken }}">
13
+
14
+ {# --- Collection Health --- #}
15
+ <section class="ap-federation__section">
16
+ <h2>{{ __("activitypub.federationMgmt.collections") }}</h2>
17
+ <div class="ap-federation__stats-grid">
18
+ {% for stat in collectionStats %}
19
+ <div class="ap-federation__stat-card">
20
+ <span class="ap-federation__stat-count">{{ stat.count }}</span>
21
+ <span class="ap-federation__stat-label">{{ stat.name | replace("ap_", "") }}</span>
22
+ </div>
23
+ {% endfor %}
24
+ </div>
25
+ </section>
26
+
27
+ {# --- Quick Actions --- #}
28
+ <section class="ap-federation__section">
29
+ <h2>{{ __("activitypub.federationMgmt.quickActions") }}</h2>
30
+ <div class="ap-federation__actions-row">
31
+ <button class="button" @click="broadcastActorUpdate()" :disabled="actionInProgress">
32
+ {{ __("activitypub.federationMgmt.broadcastActor") }}
33
+ </button>
34
+ {% if debugDashboardEnabled %}
35
+ <a href="{{ mountPath }}/__debug__/" class="button" target="_blank" rel="noopener">
36
+ {{ __("activitypub.federationMgmt.debugDashboard") }}
37
+ </a>
38
+ {% endif %}
39
+ </div>
40
+ <p x-show="actionResult" x-text="actionResult" class="ap-federation__result" x-cloak></p>
41
+ </section>
42
+
43
+ {# --- Object Lookup --- #}
44
+ <section class="ap-federation__section">
45
+ <h2>{{ __("activitypub.federationMgmt.objectLookup") }}</h2>
46
+ <form class="ap-federation__lookup-form" @submit.prevent="lookupObject()">
47
+ <input type="text" x-model="lookupQuery"
48
+ placeholder="{{ __('activitypub.federationMgmt.lookupPlaceholder') }}"
49
+ class="ap-federation__lookup-input">
50
+ <button type="submit" class="button" :disabled="lookupLoading">
51
+ <span x-show="!lookupLoading">{{ __("activitypub.federationMgmt.lookup") }}</span>
52
+ <span x-show="lookupLoading" x-cloak>{{ __("activitypub.federationMgmt.lookupLoading") }}</span>
53
+ </button>
54
+ </form>
55
+ <p x-show="lookupError" x-text="lookupError" class="ap-federation__error" x-cloak></p>
56
+ <pre x-show="lookupResult" x-text="lookupResult" class="ap-federation__json-view" x-cloak></pre>
57
+ </section>
58
+
59
+ {# --- Post Federation --- #}
60
+ <section class="ap-federation__section">
61
+ <h2>{{ __("activitypub.federationMgmt.postActions") }}</h2>
62
+ {% if posts.length > 0 %}
63
+ <div class="ap-federation__posts-list">
64
+ {% for post in posts %}
65
+ <div class="ap-federation__post-row">
66
+ <div class="ap-federation__post-info">
67
+ <a href="{{ post.url }}" class="ap-federation__post-title">{{ post.name }}</a>
68
+ <span class="ap-federation__post-meta">
69
+ {{ badge({ text: post.postType }) }}
70
+ {% if post.published %}
71
+ <time>{{ post.published | date("PP") }}</time>
72
+ {% endif %}
73
+ {% if post.deleted %}
74
+ {{ badge({ text: "deleted", color: "red" }) }}
75
+ {% endif %}
76
+ </span>
77
+ </div>
78
+ <div class="ap-federation__post-actions">
79
+ <button class="ap-federation__post-btn"
80
+ @click="viewApJson('{{ post.url }}')">
81
+ {{ __("activitypub.federationMgmt.viewJson") }}
82
+ </button>
83
+ <button class="ap-federation__post-btn"
84
+ @click="rebroadcast('{{ post.url }}')">
85
+ {{ __("activitypub.federationMgmt.rebroadcastShort") }}
86
+ </button>
87
+ <button class="ap-federation__post-btn ap-federation__post-btn--danger"
88
+ @click="broadcastDelete('{{ post.url }}')">
89
+ {{ __("activitypub.federationMgmt.deleteShort") }}
90
+ </button>
91
+ </div>
92
+ </div>
93
+ {% endfor %}
94
+ </div>
95
+ {{ pagination(cursor) if cursor }}
96
+ {% else %}
97
+ {{ prose({ text: __("activitypub.federationMgmt.noPosts") }) }}
98
+ {% endif %}
99
+ </section>
100
+
101
+ {# --- Recent Activity --- #}
102
+ <section class="ap-federation__section">
103
+ <h2>{{ __("activitypub.federationMgmt.recentActivity") }}</h2>
104
+ {% if recentActivities.length > 0 %}
105
+ {% for activity in recentActivities %}
106
+ {{ card({
107
+ title: activity.actorName or activity.actorUrl,
108
+ description: { text: activity.summary },
109
+ published: activity.receivedAt,
110
+ badges: [
111
+ { text: activity.type },
112
+ { text: __("activitypub.directionInbound") if activity.direction === "inbound" else __("activitypub.directionOutbound") }
113
+ ]
114
+ }) }}
115
+ {% endfor %}
116
+ <p><a href="{{ mountPath }}/admin/activities">{{ __("activitypub.federationMgmt.viewAllActivities") }}</a></p>
117
+ {% else %}
118
+ {{ prose({ text: __("activitypub.noActivity") }) }}
119
+ {% endif %}
120
+ </section>
121
+
122
+ {# --- JSON Modal --- #}
123
+ <div class="ap-federation__modal-overlay" x-show="jsonModalOpen" x-cloak
124
+ @click.self="jsonModalOpen = false" @keydown.escape.window="jsonModalOpen = false">
125
+ <div class="ap-federation__modal">
126
+ <div class="ap-federation__modal-header">
127
+ <h3>{{ __("activitypub.federationMgmt.apJsonTitle") }}</h3>
128
+ <button class="ap-federation__modal-close" @click="jsonModalOpen = false">&times;</button>
129
+ </div>
130
+ <pre x-text="jsonModalData" class="ap-federation__json-view"></pre>
131
+ </div>
132
+ </div>
133
+
134
+ </div>
135
+
136
+ <script>
137
+ document.addEventListener('alpine:init', () => {
138
+ Alpine.data('federationMgmt', () => ({
139
+ actionInProgress: false,
140
+ actionResult: '',
141
+ lookupQuery: '',
142
+ lookupLoading: false,
143
+ lookupError: '',
144
+ lookupResult: '',
145
+ jsonModalOpen: false,
146
+ jsonModalData: '',
147
+
148
+ get mountPath() { return this.$root.dataset.mountPath; },
149
+ get csrfToken() { return this.$root.dataset.csrfToken; },
150
+
151
+ async broadcastActorUpdate() {
152
+ this.actionInProgress = true;
153
+ this.actionResult = '';
154
+ try {
155
+ const res = await fetch(this.mountPath + '/admin/federation/broadcast-actor', {
156
+ method: 'POST',
157
+ headers: {
158
+ 'Content-Type': 'application/json',
159
+ 'X-CSRF-Token': this.csrfToken,
160
+ },
161
+ body: JSON.stringify({}),
162
+ });
163
+ const data = await res.json();
164
+ this.actionResult = data.success ? 'Actor update broadcast sent.' : (data.error || 'Failed');
165
+ } catch {
166
+ this.actionResult = 'Request failed';
167
+ }
168
+ this.actionInProgress = false;
169
+ setTimeout(() => { this.actionResult = ''; }, 5000);
170
+ },
171
+
172
+ async lookupObject() {
173
+ const q = this.lookupQuery.trim();
174
+ if (!q) return;
175
+ this.lookupLoading = true;
176
+ this.lookupError = '';
177
+ this.lookupResult = '';
178
+ try {
179
+ const res = await fetch(this.mountPath + '/admin/federation/lookup?q=' + encodeURIComponent(q));
180
+ const data = await res.json();
181
+ if (data.error) {
182
+ this.lookupError = data.error;
183
+ } else {
184
+ this.lookupResult = JSON.stringify(data, null, 2);
185
+ }
186
+ } catch {
187
+ this.lookupError = 'Request failed';
188
+ }
189
+ this.lookupLoading = false;
190
+ },
191
+
192
+ async viewApJson(url) {
193
+ try {
194
+ const res = await fetch(this.mountPath + '/admin/federation/ap-json?url=' + encodeURIComponent(url));
195
+ const data = await res.json();
196
+ if (data.error) {
197
+ this.jsonModalData = 'Error: ' + data.error;
198
+ } else {
199
+ this.jsonModalData = JSON.stringify(data, null, 2);
200
+ }
201
+ this.jsonModalOpen = true;
202
+ } catch {
203
+ this.jsonModalData = 'Request failed';
204
+ this.jsonModalOpen = true;
205
+ }
206
+ },
207
+
208
+ async rebroadcast(url) {
209
+ if (!confirm('Re-send this post to all followers?')) return;
210
+ try {
211
+ const res = await fetch(this.mountPath + '/admin/federation/rebroadcast', {
212
+ method: 'POST',
213
+ headers: {
214
+ 'Content-Type': 'application/json',
215
+ 'X-CSRF-Token': this.csrfToken,
216
+ },
217
+ body: JSON.stringify({ url }),
218
+ });
219
+ const data = await res.json();
220
+ this.actionResult = data.success ? 'Post re-broadcast sent.' : (data.error || 'Failed');
221
+ } catch {
222
+ this.actionResult = 'Request failed';
223
+ }
224
+ setTimeout(() => { this.actionResult = ''; }, 5000);
225
+ },
226
+
227
+ async broadcastDelete(url) {
228
+ if (!confirm('Broadcast Delete for this post? Remote servers will remove it.')) return;
229
+ try {
230
+ const res = await fetch(this.mountPath + '/admin/federation/delete', {
231
+ method: 'POST',
232
+ headers: {
233
+ 'Content-Type': 'application/json',
234
+ 'X-CSRF-Token': this.csrfToken,
235
+ },
236
+ body: JSON.stringify({ url }),
237
+ });
238
+ const data = await res.json();
239
+ this.actionResult = data.success ? 'Delete broadcast sent.' : (data.error || 'Failed');
240
+ } catch {
241
+ this.actionResult = 'Request failed';
242
+ }
243
+ setTimeout(() => { this.actionResult = ''; }, 5000);
244
+ },
245
+ }));
246
+ });
247
+ </script>
248
+ {% endblock %}