@rmdes/indiekit-endpoint-activitypub 2.11.2 → 2.12.1
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 +218 -0
- package/index.js +17 -0
- package/lib/controllers/federation-mgmt.js +310 -0
- package/locales/en.json +21 -0
- package/package.json +1 -1
- package/views/activitypub-federation-mgmt.njk +245 -0
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
|
}
|
|
@@ -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.
|
|
3
|
+
"version": "2.12.1",
|
|
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,245 @@
|
|
|
1
|
+
{% extends "layouts/ap-reader.njk" %}
|
|
2
|
+
|
|
3
|
+
{% from "card/macro.njk" import card with context %}
|
|
4
|
+
{% from "badge/macro.njk" import badge with context %}
|
|
5
|
+
{% from "prose/macro.njk" import prose with context %}
|
|
6
|
+
{% from "pagination/macro.njk" import pagination with context %}
|
|
7
|
+
|
|
8
|
+
{% block readercontent %}
|
|
9
|
+
<div x-data="federationMgmt()" data-mount-path="{{ mountPath }}" data-csrf-token="{{ csrfToken }}">
|
|
10
|
+
|
|
11
|
+
{# --- Collection Health --- #}
|
|
12
|
+
<section class="ap-federation__section">
|
|
13
|
+
<h2>{{ __("activitypub.federationMgmt.collections") }}</h2>
|
|
14
|
+
<div class="ap-federation__stats-grid">
|
|
15
|
+
{% for stat in collectionStats %}
|
|
16
|
+
<div class="ap-federation__stat-card">
|
|
17
|
+
<span class="ap-federation__stat-count">{{ stat.count }}</span>
|
|
18
|
+
<span class="ap-federation__stat-label">{{ stat.name | replace("ap_", "") }}</span>
|
|
19
|
+
</div>
|
|
20
|
+
{% endfor %}
|
|
21
|
+
</div>
|
|
22
|
+
</section>
|
|
23
|
+
|
|
24
|
+
{# --- Quick Actions --- #}
|
|
25
|
+
<section class="ap-federation__section">
|
|
26
|
+
<h2>{{ __("activitypub.federationMgmt.quickActions") }}</h2>
|
|
27
|
+
<div class="ap-federation__actions-row">
|
|
28
|
+
<button class="button" @click="broadcastActorUpdate()" :disabled="actionInProgress">
|
|
29
|
+
{{ __("activitypub.federationMgmt.broadcastActor") }}
|
|
30
|
+
</button>
|
|
31
|
+
{% if debugDashboardEnabled %}
|
|
32
|
+
<a href="{{ mountPath }}/__debug__/" class="button" target="_blank" rel="noopener">
|
|
33
|
+
{{ __("activitypub.federationMgmt.debugDashboard") }}
|
|
34
|
+
</a>
|
|
35
|
+
{% endif %}
|
|
36
|
+
</div>
|
|
37
|
+
<p x-show="actionResult" x-text="actionResult" class="ap-federation__result" x-cloak></p>
|
|
38
|
+
</section>
|
|
39
|
+
|
|
40
|
+
{# --- Object Lookup --- #}
|
|
41
|
+
<section class="ap-federation__section">
|
|
42
|
+
<h2>{{ __("activitypub.federationMgmt.objectLookup") }}</h2>
|
|
43
|
+
<form class="ap-federation__lookup-form" @submit.prevent="lookupObject()">
|
|
44
|
+
<input type="text" x-model="lookupQuery"
|
|
45
|
+
placeholder="{{ __('activitypub.federationMgmt.lookupPlaceholder') }}"
|
|
46
|
+
class="ap-federation__lookup-input">
|
|
47
|
+
<button type="submit" class="button" :disabled="lookupLoading">
|
|
48
|
+
<span x-show="!lookupLoading">{{ __("activitypub.federationMgmt.lookup") }}</span>
|
|
49
|
+
<span x-show="lookupLoading" x-cloak>{{ __("activitypub.federationMgmt.lookupLoading") }}</span>
|
|
50
|
+
</button>
|
|
51
|
+
</form>
|
|
52
|
+
<p x-show="lookupError" x-text="lookupError" class="ap-federation__error" x-cloak></p>
|
|
53
|
+
<pre x-show="lookupResult" x-text="lookupResult" class="ap-federation__json-view" x-cloak></pre>
|
|
54
|
+
</section>
|
|
55
|
+
|
|
56
|
+
{# --- Post Federation --- #}
|
|
57
|
+
<section class="ap-federation__section">
|
|
58
|
+
<h2>{{ __("activitypub.federationMgmt.postActions") }}</h2>
|
|
59
|
+
{% if posts.length > 0 %}
|
|
60
|
+
<div class="ap-federation__posts-list">
|
|
61
|
+
{% for post in posts %}
|
|
62
|
+
<div class="ap-federation__post-row">
|
|
63
|
+
<div class="ap-federation__post-info">
|
|
64
|
+
<a href="{{ post.url }}" class="ap-federation__post-title">{{ post.name }}</a>
|
|
65
|
+
<span class="ap-federation__post-meta">
|
|
66
|
+
{{ badge({ text: post.postType }) }}
|
|
67
|
+
{% if post.published %}
|
|
68
|
+
<time>{{ post.published | date("PP") }}</time>
|
|
69
|
+
{% endif %}
|
|
70
|
+
{% if post.deleted %}
|
|
71
|
+
{{ badge({ text: "deleted", color: "red" }) }}
|
|
72
|
+
{% endif %}
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="ap-federation__post-actions">
|
|
76
|
+
<button class="ap-federation__post-btn"
|
|
77
|
+
@click="viewApJson('{{ post.url }}')">
|
|
78
|
+
{{ __("activitypub.federationMgmt.viewJson") }}
|
|
79
|
+
</button>
|
|
80
|
+
<button class="ap-federation__post-btn"
|
|
81
|
+
@click="rebroadcast('{{ post.url }}')">
|
|
82
|
+
{{ __("activitypub.federationMgmt.rebroadcastShort") }}
|
|
83
|
+
</button>
|
|
84
|
+
<button class="ap-federation__post-btn ap-federation__post-btn--danger"
|
|
85
|
+
@click="broadcastDelete('{{ post.url }}')">
|
|
86
|
+
{{ __("activitypub.federationMgmt.deleteShort") }}
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
{% endfor %}
|
|
91
|
+
</div>
|
|
92
|
+
{{ pagination(cursor) if cursor }}
|
|
93
|
+
{% else %}
|
|
94
|
+
{{ prose({ text: __("activitypub.federationMgmt.noPosts") }) }}
|
|
95
|
+
{% endif %}
|
|
96
|
+
</section>
|
|
97
|
+
|
|
98
|
+
{# --- Recent Activity --- #}
|
|
99
|
+
<section class="ap-federation__section">
|
|
100
|
+
<h2>{{ __("activitypub.federationMgmt.recentActivity") }}</h2>
|
|
101
|
+
{% if recentActivities.length > 0 %}
|
|
102
|
+
{% for activity in recentActivities %}
|
|
103
|
+
{{ card({
|
|
104
|
+
title: activity.actorName or activity.actorUrl,
|
|
105
|
+
description: { text: activity.summary },
|
|
106
|
+
published: activity.receivedAt,
|
|
107
|
+
badges: [
|
|
108
|
+
{ text: activity.type },
|
|
109
|
+
{ text: __("activitypub.directionInbound") if activity.direction === "inbound" else __("activitypub.directionOutbound") }
|
|
110
|
+
]
|
|
111
|
+
}) }}
|
|
112
|
+
{% endfor %}
|
|
113
|
+
<p><a href="{{ mountPath }}/admin/activities">{{ __("activitypub.federationMgmt.viewAllActivities") }}</a></p>
|
|
114
|
+
{% else %}
|
|
115
|
+
{{ prose({ text: __("activitypub.noActivity") }) }}
|
|
116
|
+
{% endif %}
|
|
117
|
+
</section>
|
|
118
|
+
|
|
119
|
+
{# --- JSON Modal --- #}
|
|
120
|
+
<div class="ap-federation__modal-overlay" x-show="jsonModalOpen" x-cloak
|
|
121
|
+
@click.self="jsonModalOpen = false" @keydown.escape.window="jsonModalOpen = false">
|
|
122
|
+
<div class="ap-federation__modal">
|
|
123
|
+
<div class="ap-federation__modal-header">
|
|
124
|
+
<h3>{{ __("activitypub.federationMgmt.apJsonTitle") }}</h3>
|
|
125
|
+
<button class="ap-federation__modal-close" @click="jsonModalOpen = false">×</button>
|
|
126
|
+
</div>
|
|
127
|
+
<pre x-text="jsonModalData" class="ap-federation__json-view"></pre>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<script>
|
|
134
|
+
document.addEventListener('alpine:init', () => {
|
|
135
|
+
Alpine.data('federationMgmt', () => ({
|
|
136
|
+
actionInProgress: false,
|
|
137
|
+
actionResult: '',
|
|
138
|
+
lookupQuery: '',
|
|
139
|
+
lookupLoading: false,
|
|
140
|
+
lookupError: '',
|
|
141
|
+
lookupResult: '',
|
|
142
|
+
jsonModalOpen: false,
|
|
143
|
+
jsonModalData: '',
|
|
144
|
+
|
|
145
|
+
get mountPath() { return this.$root.dataset.mountPath; },
|
|
146
|
+
get csrfToken() { return this.$root.dataset.csrfToken; },
|
|
147
|
+
|
|
148
|
+
async broadcastActorUpdate() {
|
|
149
|
+
this.actionInProgress = true;
|
|
150
|
+
this.actionResult = '';
|
|
151
|
+
try {
|
|
152
|
+
const res = await fetch(this.mountPath + '/admin/federation/broadcast-actor', {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: {
|
|
155
|
+
'Content-Type': 'application/json',
|
|
156
|
+
'X-CSRF-Token': this.csrfToken,
|
|
157
|
+
},
|
|
158
|
+
body: JSON.stringify({}),
|
|
159
|
+
});
|
|
160
|
+
const data = await res.json();
|
|
161
|
+
this.actionResult = data.success ? 'Actor update broadcast sent.' : (data.error || 'Failed');
|
|
162
|
+
} catch {
|
|
163
|
+
this.actionResult = 'Request failed';
|
|
164
|
+
}
|
|
165
|
+
this.actionInProgress = false;
|
|
166
|
+
setTimeout(() => { this.actionResult = ''; }, 5000);
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async lookupObject() {
|
|
170
|
+
const q = this.lookupQuery.trim();
|
|
171
|
+
if (!q) return;
|
|
172
|
+
this.lookupLoading = true;
|
|
173
|
+
this.lookupError = '';
|
|
174
|
+
this.lookupResult = '';
|
|
175
|
+
try {
|
|
176
|
+
const res = await fetch(this.mountPath + '/admin/federation/lookup?q=' + encodeURIComponent(q));
|
|
177
|
+
const data = await res.json();
|
|
178
|
+
if (data.error) {
|
|
179
|
+
this.lookupError = data.error;
|
|
180
|
+
} else {
|
|
181
|
+
this.lookupResult = JSON.stringify(data, null, 2);
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
this.lookupError = 'Request failed';
|
|
185
|
+
}
|
|
186
|
+
this.lookupLoading = false;
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async viewApJson(url) {
|
|
190
|
+
try {
|
|
191
|
+
const res = await fetch(this.mountPath + '/admin/federation/ap-json?url=' + encodeURIComponent(url));
|
|
192
|
+
const data = await res.json();
|
|
193
|
+
if (data.error) {
|
|
194
|
+
this.jsonModalData = 'Error: ' + data.error;
|
|
195
|
+
} else {
|
|
196
|
+
this.jsonModalData = JSON.stringify(data, null, 2);
|
|
197
|
+
}
|
|
198
|
+
this.jsonModalOpen = true;
|
|
199
|
+
} catch {
|
|
200
|
+
this.jsonModalData = 'Request failed';
|
|
201
|
+
this.jsonModalOpen = true;
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
async rebroadcast(url) {
|
|
206
|
+
if (!confirm('Re-send this post to all followers?')) return;
|
|
207
|
+
try {
|
|
208
|
+
const res = await fetch(this.mountPath + '/admin/federation/rebroadcast', {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
headers: {
|
|
211
|
+
'Content-Type': 'application/json',
|
|
212
|
+
'X-CSRF-Token': this.csrfToken,
|
|
213
|
+
},
|
|
214
|
+
body: JSON.stringify({ url }),
|
|
215
|
+
});
|
|
216
|
+
const data = await res.json();
|
|
217
|
+
this.actionResult = data.success ? 'Post re-broadcast sent.' : (data.error || 'Failed');
|
|
218
|
+
} catch {
|
|
219
|
+
this.actionResult = 'Request failed';
|
|
220
|
+
}
|
|
221
|
+
setTimeout(() => { this.actionResult = ''; }, 5000);
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
async broadcastDelete(url) {
|
|
225
|
+
if (!confirm('Broadcast Delete for this post? Remote servers will remove it.')) return;
|
|
226
|
+
try {
|
|
227
|
+
const res = await fetch(this.mountPath + '/admin/federation/delete', {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: {
|
|
230
|
+
'Content-Type': 'application/json',
|
|
231
|
+
'X-CSRF-Token': this.csrfToken,
|
|
232
|
+
},
|
|
233
|
+
body: JSON.stringify({ url }),
|
|
234
|
+
});
|
|
235
|
+
const data = await res.json();
|
|
236
|
+
this.actionResult = data.success ? 'Delete broadcast sent.' : (data.error || 'Failed');
|
|
237
|
+
} catch {
|
|
238
|
+
this.actionResult = 'Request failed';
|
|
239
|
+
}
|
|
240
|
+
setTimeout(() => { this.actionResult = ''; }, 5000);
|
|
241
|
+
},
|
|
242
|
+
}));
|
|
243
|
+
});
|
|
244
|
+
</script>
|
|
245
|
+
{% endblock %}
|