@commonpub/layer 0.43.3 → 0.44.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/components/MirrorDetailModal.vue +228 -0
- package/components/MirrorRequestApproveModal.vue +163 -0
- package/components/RegistryDirectory.vue +132 -0
- package/composables/useFeatures.ts +6 -0
- package/error.vue +18 -3
- package/nuxt.config.ts +2 -0
- package/package.json +9 -9
- package/pages/admin/federation.vue +314 -58
- package/server/api/admin/federation/followers.get.ts +18 -0
- package/server/api/admin/federation/mirror-requests/[id]/approve.post.ts +20 -0
- package/server/api/admin/federation/mirror-requests/[id]/reject.post.ts +16 -0
- package/server/api/admin/federation/mirror-requests/index.get.ts +21 -0
- package/server/api/admin/federation/mirrors/[id]/backfill.post.ts +26 -2
- package/server/api/admin/federation/mirrors/index.post.ts +9 -3
- package/server/api/admin/federation/refederate.post.ts +35 -6
- package/server/api/admin/registry/instances/[id]/status.post.ts +18 -0
- package/server/api/admin/registry/instances.get.ts +19 -0
- package/server/api/registry/instances.get.ts +37 -0
- package/server/api/registry/ping.post.ts +44 -0
- package/server/middleware/auth.ts +9 -1
- package/server/plugins/registry-heartbeat.ts +67 -0
- package/server/routes/hubs/[slug]/inbox.ts +3 -2
- package/server/routes/inbox.ts +4 -2
- package/server/routes/users/[username]/inbox.ts +3 -2
- package/server/utils/inbox.ts +38 -0
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
definePageMeta({ layout: 'admin', middleware: 'auth' });
|
|
3
3
|
useSeoMeta({ title: `Federation — Admin — ${useSiteName()}` });
|
|
4
4
|
|
|
5
|
-
const activeTab = ref<'activity' | 'mirrors' | 'clients' | 'trusted' | 'tools'>('activity');
|
|
5
|
+
const activeTab = ref<'activity' | 'mirrors' | 'registry' | 'clients' | 'trusted' | 'tools'>('activity');
|
|
6
|
+
|
|
7
|
+
const featureFlags = useFeatures();
|
|
8
|
+
const actAsRegistry = computed(() => featureFlags.features.value.actAsRegistry);
|
|
9
|
+
const announceToRegistry = computed(() => featureFlags.features.value.announceToRegistry);
|
|
6
10
|
|
|
7
11
|
const { data: statsData, pending } = await useFetch('/api/admin/federation/stats', {
|
|
8
12
|
default: () => ({ inbound: 0, outbound: 0, pending: 0, failed: 0, followers: 0, following: 0 }),
|
|
@@ -57,31 +61,150 @@ async function removeTrusted(domain: string): Promise<void> {
|
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
const toast = useToast();
|
|
65
|
+
|
|
66
|
+
// Instances mirroring US (followers of our instance actor).
|
|
67
|
+
const { data: followersData } = await useFetch<Array<{ actorUri: string; domain: string; followedAt: string | null }>>(
|
|
68
|
+
'/api/admin/federation/followers',
|
|
69
|
+
{ default: () => [] },
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Consent-based mirror requests (Phase 3): incoming = others asking to mirror us; outgoing = us asking them.
|
|
73
|
+
type MirrorRequest = { id: string; direction: string; remoteDomain: string; remoteActorUri: string; status: string; createdAt: string; decidedAt: string | null };
|
|
74
|
+
const { data: requestsData, refresh: refreshRequests } = await useFetch<{ incoming: MirrorRequest[]; outgoing: MirrorRequest[] }>(
|
|
75
|
+
'/api/admin/federation/mirror-requests',
|
|
76
|
+
{ default: () => ({ incoming: [], outgoing: [] }) },
|
|
77
|
+
);
|
|
78
|
+
const pendingIncoming = computed(() => (requestsData.value?.incoming ?? []).filter((r) => r.status === 'pending'));
|
|
79
|
+
const decidedIncoming = computed(() => (requestsData.value?.incoming ?? []).filter((r) => r.status !== 'pending'));
|
|
80
|
+
const approvingRequest = ref<MirrorRequest | null>(null);
|
|
81
|
+
|
|
82
|
+
async function onRequestChanged(): Promise<void> {
|
|
83
|
+
await Promise.all([refreshRequests(), refreshMirrors()]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function rejectRequest(id: string): Promise<void> {
|
|
87
|
+
const url: string = `/api/admin/federation/mirror-requests/${id}/reject`;
|
|
88
|
+
try {
|
|
89
|
+
await $fetch(url, { method: 'POST' });
|
|
90
|
+
toast.success('Request rejected');
|
|
91
|
+
await refreshRequests();
|
|
92
|
+
} catch {
|
|
93
|
+
toast.error('Failed to reject request');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Registry directory (Phase 4) — only fetched when this instance acts as a registry.
|
|
98
|
+
type RegistryRow = { id: string; domain: string; actorUri: string; name: string | null; description: string | null; userCount: number; activeMonthCount: number; localPostCount: number; softwareName: string | null; softwareVersion: string | null; status: string; lastPingAt: string | null; online: boolean };
|
|
99
|
+
const registrySearch = ref('');
|
|
100
|
+
const { data: registryData, refresh: refreshRegistry } = await useFetch<{ instances: RegistryRow[]; total: number }>(
|
|
101
|
+
'/api/admin/registry/instances',
|
|
102
|
+
{
|
|
103
|
+
query: computed(() => ({ search: registrySearch.value || undefined, limit: 50 })),
|
|
104
|
+
default: () => ({ instances: [], total: 0 }),
|
|
105
|
+
immediate: actAsRegistry.value,
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
function onRegistrySearch(value: string): void {
|
|
110
|
+
registrySearch.value = value;
|
|
111
|
+
void refreshRegistry();
|
|
112
|
+
}
|
|
113
|
+
|
|
60
114
|
// Mirror creation
|
|
115
|
+
const FEDERATABLE_TYPES = ['project', 'blog', 'explainer'] as const;
|
|
116
|
+
// Bounded "how far back" choices for the optional history import on create.
|
|
117
|
+
const DEPTH_OPTIONS = [
|
|
118
|
+
{ label: 'None — forward only (default)', body: null as Record<string, number> | null },
|
|
119
|
+
{ label: 'Last 7 days', body: { sinceDays: 7 } },
|
|
120
|
+
{ label: 'Last 30 days', body: { sinceDays: 30 } },
|
|
121
|
+
{ label: 'Last 90 days', body: { sinceDays: 90 } },
|
|
122
|
+
{ label: 'Last 200 items', body: { maxItems: 200 } },
|
|
123
|
+
{ label: 'Everything (up to limit)', body: {} },
|
|
124
|
+
];
|
|
125
|
+
|
|
61
126
|
const newMirrorDomain = ref('');
|
|
62
127
|
const newMirrorActorUri = ref('');
|
|
128
|
+
const newMirrorDirection = ref<'pull' | 'push'>('pull');
|
|
129
|
+
const newMirrorTypes = ref<string[]>([]);
|
|
130
|
+
const newMirrorTags = ref('');
|
|
131
|
+
const newMirrorDepth = ref(0);
|
|
132
|
+
const showAdvanced = ref(false);
|
|
63
133
|
const mirrorCreating = ref(false);
|
|
64
134
|
|
|
135
|
+
function resetMirrorForm(): void {
|
|
136
|
+
newMirrorDomain.value = '';
|
|
137
|
+
newMirrorActorUri.value = '';
|
|
138
|
+
newMirrorTypes.value = [];
|
|
139
|
+
newMirrorTags.value = '';
|
|
140
|
+
newMirrorDepth.value = 0;
|
|
141
|
+
showAdvanced.value = false;
|
|
142
|
+
}
|
|
143
|
+
|
|
65
144
|
async function createMirror(): Promise<void> {
|
|
66
|
-
|
|
145
|
+
const domain = newMirrorDomain.value.trim().toLowerCase();
|
|
146
|
+
if (!domain) return;
|
|
67
147
|
mirrorCreating.value = true;
|
|
68
148
|
try {
|
|
69
|
-
|
|
149
|
+
// Push = consent-based request: ask them to mirror us. No filters/depth here — the approver
|
|
150
|
+
// chooses their own. The request flows to their admin; we track it under "Requests you've sent".
|
|
151
|
+
if (newMirrorDirection.value === 'push') {
|
|
152
|
+
await $fetch('/api/admin/federation/mirrors', {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
body: {
|
|
155
|
+
remoteDomain: domain,
|
|
156
|
+
remoteActorUri: newMirrorActorUri.value.trim() || `https://${domain}/actor`,
|
|
157
|
+
direction: 'push',
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
toast.success(`Request sent to ${domain} — they must approve before they mirror you`);
|
|
161
|
+
resetMirrorForm();
|
|
162
|
+
newMirrorDirection.value = 'pull';
|
|
163
|
+
await refreshRequests();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const tags = newMirrorTags.value.split(',').map((t) => t.trim().replace(/^#/, '')).filter(Boolean);
|
|
168
|
+
const created = await $fetch<{ id: string }>('/api/admin/federation/mirrors', {
|
|
70
169
|
method: 'POST',
|
|
71
170
|
body: {
|
|
72
|
-
remoteDomain:
|
|
73
|
-
remoteActorUri: newMirrorActorUri.value || `https://${
|
|
171
|
+
remoteDomain: domain,
|
|
172
|
+
remoteActorUri: newMirrorActorUri.value.trim() || `https://${domain}/actor`,
|
|
74
173
|
direction: 'pull',
|
|
174
|
+
filterContentTypes: newMirrorTypes.value.length ? newMirrorTypes.value : null,
|
|
175
|
+
filterTags: tags.length ? tags : null,
|
|
75
176
|
},
|
|
76
177
|
});
|
|
77
|
-
|
|
78
|
-
|
|
178
|
+
// Optional bounded history import — forward-only unless a depth is chosen. The mirror is
|
|
179
|
+
// already created at this point, so a backfill failure must NOT masquerade as create-failure.
|
|
180
|
+
const depth = DEPTH_OPTIONS[newMirrorDepth.value]!.body;
|
|
181
|
+
if (depth && created?.id) {
|
|
182
|
+
// string-typed URL avoids the typed-routes $fetch recursion (TS2321) on dynamic paths.
|
|
183
|
+
const backfillUrl: string = `/api/admin/federation/mirrors/${created.id}/backfill`;
|
|
184
|
+
try {
|
|
185
|
+
const r = await $fetch<{ processed: number }>(backfillUrl, { method: 'POST', body: depth });
|
|
186
|
+
toast.success(`Mirror added — imported ${r?.processed ?? 0} item(s)`);
|
|
187
|
+
} catch {
|
|
188
|
+
toast.error('Mirror added, but history import failed — use Backfill in its details to retry.');
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
toast.success('Mirror added — new posts will arrive as they publish');
|
|
192
|
+
}
|
|
193
|
+
resetMirrorForm();
|
|
79
194
|
await refreshMirrors();
|
|
195
|
+
} catch {
|
|
196
|
+
toast.error(newMirrorDirection.value === 'push' ? 'Failed to send request' : 'Failed to add mirror');
|
|
80
197
|
} finally {
|
|
81
198
|
mirrorCreating.value = false;
|
|
82
199
|
}
|
|
83
200
|
}
|
|
84
201
|
|
|
202
|
+
function toggleType(t: string): void {
|
|
203
|
+
const i = newMirrorTypes.value.indexOf(t);
|
|
204
|
+
if (i === -1) newMirrorTypes.value.push(t);
|
|
205
|
+
else newMirrorTypes.value.splice(i, 1);
|
|
206
|
+
}
|
|
207
|
+
|
|
85
208
|
async function toggleMirror(id: string, currentStatus: string): Promise<void> {
|
|
86
209
|
try {
|
|
87
210
|
await $fetch(`/api/admin/federation/mirrors/${id}`, {
|
|
@@ -90,32 +213,18 @@ async function toggleMirror(id: string, currentStatus: string): Promise<void> {
|
|
|
90
213
|
});
|
|
91
214
|
await refreshMirrors();
|
|
92
215
|
} catch {
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function deleteMirror(id: string): Promise<void> {
|
|
98
|
-
try {
|
|
99
|
-
await $fetch(`/api/admin/federation/mirrors/${id}`, { method: 'DELETE' });
|
|
100
|
-
await refreshMirrors();
|
|
101
|
-
} catch {
|
|
102
|
-
alert('Failed to delete mirror');
|
|
216
|
+
toast.error('Failed to update mirror');
|
|
103
217
|
}
|
|
104
218
|
}
|
|
105
219
|
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
const
|
|
220
|
+
// Mirror detail modal — per-mirror info + bounded re-backfill + delete.
|
|
221
|
+
type MirrorRow = { id: string; status: string; direction: string; remoteDomain: string; remoteActorUri: string; filterContentTypes: string[] | null; filterTags: string[] | null; contentCount: number; errorCount: number; lastError: string | null; lastSyncAt: string | null; backfillCursor?: string | null };
|
|
222
|
+
const selectedMirror = ref<MirrorRow | null>(null);
|
|
109
223
|
|
|
110
|
-
async function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const result = await $fetch<{ processed: number; errors: number; pages: number }>(`/api/admin/federation/mirrors/${id}/backfill`, { method: 'POST' });
|
|
115
|
-
backfillResult.value = result;
|
|
116
|
-
await refreshMirrors();
|
|
117
|
-
} finally {
|
|
118
|
-
backfilling.value = null;
|
|
224
|
+
async function onMirrorChanged(): Promise<void> {
|
|
225
|
+
await refreshMirrors();
|
|
226
|
+
if (selectedMirror.value) {
|
|
227
|
+
selectedMirror.value = (mirrorsData.value ?? []).find((m) => m.id === selectedMirror.value!.id) ?? null;
|
|
119
228
|
}
|
|
120
229
|
}
|
|
121
230
|
|
|
@@ -171,15 +280,19 @@ async function repairTypes(): Promise<void> {
|
|
|
171
280
|
}
|
|
172
281
|
}
|
|
173
282
|
|
|
174
|
-
// Tools: re-federate
|
|
283
|
+
// Tools: re-federate (bounded by default to avoid blasting every follower with thousands).
|
|
175
284
|
const refederating = ref(false);
|
|
285
|
+
const refederateScope = ref<'7' | '30' | 'all'>('30');
|
|
176
286
|
const refederateResult = ref<{ queued: number; content?: number; hubs?: number; hubsFound?: number; hubPosts?: number } | null>(null);
|
|
177
287
|
|
|
178
288
|
async function refederate(): Promise<void> {
|
|
179
289
|
refederating.value = true;
|
|
180
290
|
refederateResult.value = null;
|
|
181
291
|
try {
|
|
182
|
-
|
|
292
|
+
const body = refederateScope.value === 'all'
|
|
293
|
+
? { all: true }
|
|
294
|
+
: { sinceDays: Number(refederateScope.value) };
|
|
295
|
+
refederateResult.value = await ($fetch as Function)('/api/admin/federation/refederate', { method: 'POST', body });
|
|
183
296
|
} finally {
|
|
184
297
|
refederating.value = false;
|
|
185
298
|
}
|
|
@@ -228,6 +341,7 @@ async function refederate(): Promise<void> {
|
|
|
228
341
|
<div class="cpub-fed-tabs">
|
|
229
342
|
<button :class="{ active: activeTab === 'activity' }" @click="activeTab = 'activity'">Activity</button>
|
|
230
343
|
<button :class="{ active: activeTab === 'mirrors' }" @click="activeTab = 'mirrors'">Mirrors</button>
|
|
344
|
+
<button v-if="actAsRegistry" :class="{ active: activeTab === 'registry' }" @click="activeTab = 'registry'">Registry</button>
|
|
231
345
|
<button :class="{ active: activeTab === 'clients' }" @click="activeTab = 'clients'">OAuth Clients</button>
|
|
232
346
|
<button :class="{ active: activeTab === 'trusted' }" @click="activeTab = 'trusted'">Trusted Instances</button>
|
|
233
347
|
<button :class="{ active: activeTab === 'tools' }" @click="activeTab = 'tools'">Tools</button>
|
|
@@ -284,40 +398,131 @@ async function refederate(): Promise<void> {
|
|
|
284
398
|
|
|
285
399
|
<!-- Mirrors Tab -->
|
|
286
400
|
<div v-if="activeTab === 'mirrors'">
|
|
287
|
-
<
|
|
288
|
-
<
|
|
289
|
-
<
|
|
290
|
-
|
|
401
|
+
<p class="cpub-fed-explain">
|
|
402
|
+
A <strong>mirror</strong> pulls another instance's public content into your federated feed.
|
|
403
|
+
It's <strong>one-directional</strong> — you receive their posts; they receive nothing from
|
|
404
|
+
you and need do nothing. New posts arrive automatically once added; use <strong>Import
|
|
405
|
+
history</strong> to also pull older posts (bounded, so you don't ingest an entire large
|
|
406
|
+
instance at once).
|
|
407
|
+
</p>
|
|
408
|
+
|
|
409
|
+
<!-- Create form -->
|
|
410
|
+
<div class="cpub-fed-create">
|
|
411
|
+
<div class="cpub-fed-form" style="margin-bottom: 8px;">
|
|
412
|
+
<select v-model="newMirrorDirection" class="cpub-fed-input" style="flex:0 0 auto;width:auto;" aria-label="Direction">
|
|
413
|
+
<option value="pull">Mirror them (pull)</option>
|
|
414
|
+
<option value="push">Request they mirror you</option>
|
|
415
|
+
</select>
|
|
416
|
+
<input v-model="newMirrorDomain" placeholder="remote-instance.com" class="cpub-fed-input" @keydown.enter.prevent="createMirror" />
|
|
417
|
+
<select v-if="newMirrorDirection === 'pull'" v-model.number="newMirrorDepth" class="cpub-fed-input" style="flex:0 0 auto;width:auto;" aria-label="Import history depth">
|
|
418
|
+
<option v-for="(opt, i) in DEPTH_OPTIONS" :key="i" :value="i">{{ opt.label }}</option>
|
|
419
|
+
</select>
|
|
420
|
+
<button :disabled="mirrorCreating || !newMirrorDomain.trim()" class="cpub-fed-btn" @click="createMirror">
|
|
421
|
+
{{ mirrorCreating ? (newMirrorDirection === 'push' ? 'Sending…' : 'Adding…') : (newMirrorDirection === 'push' ? 'Send Request' : 'Add Mirror') }}
|
|
422
|
+
</button>
|
|
423
|
+
</div>
|
|
424
|
+
<p v-if="newMirrorDirection === 'push'" class="cpub-fed-info-text" style="margin: 0 0 8px;">
|
|
425
|
+
Sends a request asking <strong>{{ newMirrorDomain.trim() || 'the remote instance' }}</strong> to pull-mirror you.
|
|
426
|
+
Their admin must approve (CommonPub instances only). You'll see the status under <strong>Requests you've sent</strong>.
|
|
427
|
+
</p>
|
|
428
|
+
<button v-if="newMirrorDirection === 'pull'" type="button" class="cpub-fed-disclosure" :aria-expanded="showAdvanced" @click="showAdvanced = !showAdvanced">
|
|
429
|
+
<i class="fa-solid" :class="showAdvanced ? 'fa-chevron-down' : 'fa-chevron-right'"></i> Filters & advanced
|
|
291
430
|
</button>
|
|
431
|
+
<div v-if="showAdvanced" class="cpub-fed-advanced">
|
|
432
|
+
<span class="cpub-fed-adv-label">Content types <span class="cpub-fed-adv-faint">(none = all)</span></span>
|
|
433
|
+
<div class="cpub-fed-checks">
|
|
434
|
+
<label v-for="t in FEDERATABLE_TYPES" :key="t" class="cpub-fed-check">
|
|
435
|
+
<input type="checkbox" :checked="newMirrorTypes.includes(t)" @change="toggleType(t)" /> {{ t }}
|
|
436
|
+
</label>
|
|
437
|
+
</div>
|
|
438
|
+
<label class="cpub-fed-adv-label" for="cpub-fed-tags">Tags <span class="cpub-fed-adv-faint">(comma-separated, none = all)</span></label>
|
|
439
|
+
<input id="cpub-fed-tags" v-model="newMirrorTags" placeholder="arduino, 3dprinting" class="cpub-fed-input" style="width:100%;" />
|
|
440
|
+
<label class="cpub-fed-adv-label" for="cpub-fed-actor">Actor URI <span class="cpub-fed-adv-faint">(defaults to https://domain/actor)</span></label>
|
|
441
|
+
<input id="cpub-fed-actor" v-model="newMirrorActorUri" placeholder="https://remote-instance.com/actor" class="cpub-fed-input" style="width:100%;" />
|
|
442
|
+
</div>
|
|
292
443
|
</div>
|
|
293
444
|
|
|
445
|
+
<!-- Status legend -->
|
|
446
|
+
<div class="cpub-fed-legend">
|
|
447
|
+
<span><span class="cpub-fed-status active">active</span> receiving</span>
|
|
448
|
+
<span><span class="cpub-fed-status paused">paused</span> stopped, kept</span>
|
|
449
|
+
<span><span class="cpub-fed-status pending">pending</span> follow not yet accepted</span>
|
|
450
|
+
<span><span class="cpub-fed-status failed">failed</span> last sync errored</span>
|
|
451
|
+
</div>
|
|
452
|
+
|
|
453
|
+
<!-- Mirror list -->
|
|
294
454
|
<div class="cpub-fed-activity-list">
|
|
295
455
|
<div v-if="!mirrorsData?.length" class="cpub-fed-empty">No mirrors configured.</div>
|
|
296
456
|
<div v-for="m in mirrorsData" :key="m.id" class="cpub-fed-activity-row">
|
|
297
457
|
<span class="cpub-fed-status" :class="m.status">{{ m.status }}</span>
|
|
298
|
-
<span class="cpub-fed-
|
|
299
|
-
<
|
|
300
|
-
<span v-if="m.
|
|
301
|
-
<
|
|
302
|
-
|
|
303
|
-
</button>
|
|
304
|
-
<button
|
|
305
|
-
class="cpub-fed-btn-sm"
|
|
306
|
-
:disabled="backfilling === m.id"
|
|
307
|
-
@click="backfillMirror(m.id)"
|
|
308
|
-
>
|
|
309
|
-
{{ backfilling === m.id ? 'Backfilling...' : 'Backfill' }}
|
|
310
|
-
</button>
|
|
311
|
-
<button class="cpub-fed-btn-sm cpub-fed-btn-danger" @click="deleteMirror(m.id)">Delete</button>
|
|
458
|
+
<span class="cpub-fed-dir-arrow" title="pull (you receive their content)">↓</span>
|
|
459
|
+
<button class="cpub-fed-mirror-name" @click="selectedMirror = m">{{ m.remoteDomain }}</button>
|
|
460
|
+
<span class="cpub-fed-actor">{{ m.contentCount }} items<template v-if="m.filterContentTypes?.length"> · {{ m.filterContentTypes.join(', ') }}</template><template v-if="m.filterTags?.length"> · #{{ m.filterTags.join(' #') }}</template></span>
|
|
461
|
+
<span v-if="m.errorCount > 0" class="cpub-fed-error" :title="m.lastError || ''">{{ m.errorCount }} err</span>
|
|
462
|
+
<time v-if="m.lastSyncAt" class="cpub-fed-time">{{ new Date(m.lastSyncAt).toLocaleDateString() }}</time>
|
|
463
|
+
<button class="cpub-fed-btn-sm" @click="toggleMirror(m.id, m.status)">{{ m.status === 'active' ? 'Pause' : 'Resume' }}</button>
|
|
464
|
+
<button class="cpub-fed-btn-sm" @click="selectedMirror = m">Details</button>
|
|
312
465
|
</div>
|
|
313
466
|
</div>
|
|
314
467
|
|
|
315
|
-
<!--
|
|
316
|
-
<
|
|
317
|
-
|
|
468
|
+
<!-- Instances mirroring you -->
|
|
469
|
+
<h3 class="cpub-fed-subhead">Instances mirroring you</h3>
|
|
470
|
+
<p class="cpub-fed-info-text" style="margin-bottom: 8px;">Remote instances following your instance actor — they pull your public content. (One-directional: you don't pull them unless you add a mirror above.)</p>
|
|
471
|
+
<div class="cpub-fed-activity-list">
|
|
472
|
+
<div v-if="!followersData?.length" class="cpub-fed-empty">No instances are mirroring you yet.</div>
|
|
473
|
+
<div v-for="f in followersData" :key="f.actorUri" class="cpub-fed-activity-row">
|
|
474
|
+
<span class="cpub-fed-dir-arrow" title="they pull from you">↗</span>
|
|
475
|
+
<span class="cpub-fed-type">{{ f.domain }}</span>
|
|
476
|
+
<span class="cpub-fed-actor">{{ f.actorUri }}</span>
|
|
477
|
+
<time v-if="f.followedAt" class="cpub-fed-time">{{ new Date(f.followedAt).toLocaleDateString() }}</time>
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
<!-- Requests to mirror you (incoming) -->
|
|
482
|
+
<h3 class="cpub-fed-subhead">Requests to mirror you</h3>
|
|
483
|
+
<p class="cpub-fed-info-text" style="margin-bottom: 8px;">Other CommonPub instances asking you to let them pull-mirror your content. Approve to start mirroring them back (you choose depth + filters), or reject.</p>
|
|
484
|
+
<div class="cpub-fed-activity-list">
|
|
485
|
+
<div v-if="!pendingIncoming.length && !decidedIncoming.length" class="cpub-fed-empty">No incoming mirror requests.</div>
|
|
486
|
+
<div v-for="r in pendingIncoming" :key="r.id" class="cpub-fed-activity-row">
|
|
487
|
+
<span class="cpub-fed-status pending">pending</span>
|
|
488
|
+
<span class="cpub-fed-type">{{ r.remoteDomain }}</span>
|
|
489
|
+
<span class="cpub-fed-actor">{{ r.remoteActorUri }}</span>
|
|
490
|
+
<time class="cpub-fed-time">{{ new Date(r.createdAt).toLocaleDateString() }}</time>
|
|
491
|
+
<button class="cpub-fed-btn-sm" @click="approvingRequest = r">Review</button>
|
|
492
|
+
<button class="cpub-fed-btn-sm cpub-fed-btn-danger" @click="rejectRequest(r.id)">Reject</button>
|
|
493
|
+
</div>
|
|
494
|
+
<div v-for="r in decidedIncoming" :key="r.id" class="cpub-fed-activity-row">
|
|
495
|
+
<span class="cpub-fed-status" :class="r.status === 'approved' ? 'active' : 'failed'">{{ r.status }}</span>
|
|
496
|
+
<span class="cpub-fed-type">{{ r.remoteDomain }}</span>
|
|
497
|
+
<span class="cpub-fed-actor">{{ r.remoteActorUri }}</span>
|
|
498
|
+
<time v-if="r.decidedAt" class="cpub-fed-time">{{ new Date(r.decidedAt).toLocaleDateString() }}</time>
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
<!-- Requests you've sent (outgoing) -->
|
|
503
|
+
<h3 class="cpub-fed-subhead">Requests you've sent</h3>
|
|
504
|
+
<p class="cpub-fed-info-text" style="margin-bottom: 8px;">Instances you've asked to mirror you ("Request they mirror you" above). They start mirroring once their admin approves.</p>
|
|
505
|
+
<div class="cpub-fed-activity-list">
|
|
506
|
+
<div v-if="!requestsData?.outgoing?.length" class="cpub-fed-empty">No outgoing requests.</div>
|
|
507
|
+
<div v-for="r in requestsData?.outgoing ?? []" :key="r.id" class="cpub-fed-activity-row">
|
|
508
|
+
<span class="cpub-fed-status" :class="r.status === 'approved' ? 'active' : r.status === 'rejected' ? 'failed' : 'pending'">{{ r.status }}</span>
|
|
509
|
+
<span class="cpub-fed-dir-arrow" title="you asked them to mirror you">↑</span>
|
|
510
|
+
<span class="cpub-fed-type">{{ r.remoteDomain }}</span>
|
|
511
|
+
<time class="cpub-fed-time">{{ new Date(r.createdAt).toLocaleDateString() }}</time>
|
|
512
|
+
</div>
|
|
318
513
|
</div>
|
|
319
514
|
</div>
|
|
320
515
|
|
|
516
|
+
<!-- Registry Tab -->
|
|
517
|
+
<div v-if="activeTab === 'registry' && actAsRegistry">
|
|
518
|
+
<RegistryDirectory
|
|
519
|
+
:instances="registryData?.instances ?? []"
|
|
520
|
+
:announcing-to="announceToRegistry ? 'your configured registry' : null"
|
|
521
|
+
@changed="refreshMirrors"
|
|
522
|
+
@search="onRegistrySearch"
|
|
523
|
+
/>
|
|
524
|
+
</div>
|
|
525
|
+
|
|
321
526
|
<!-- OAuth Clients Tab -->
|
|
322
527
|
<div v-if="activeTab === 'clients'">
|
|
323
528
|
<div class="cpub-fed-activity-list">
|
|
@@ -397,13 +602,20 @@ async function refederate(): Promise<void> {
|
|
|
397
602
|
</div>
|
|
398
603
|
</div>
|
|
399
604
|
|
|
400
|
-
<!-- Re-federate
|
|
605
|
+
<!-- Re-federate Content + Hub Posts -->
|
|
401
606
|
<div class="cpub-fed-tool-card">
|
|
402
|
-
<h3 class="cpub-fed-tool-title"><i class="fa-solid fa-rotate"></i> Re-federate
|
|
403
|
-
<p class="cpub-fed-tool-desc">
|
|
404
|
-
<
|
|
405
|
-
|
|
406
|
-
|
|
607
|
+
<h3 class="cpub-fed-tool-title"><i class="fa-solid fa-rotate"></i> Re-federate</h3>
|
|
608
|
+
<p class="cpub-fed-tool-desc">Re-queue your published content (Create) and hub posts (Announce) for delivery to your current followers. Idempotent. <strong>Bounded by default</strong> so you don't blast every follower with thousands of activities — choose how far back.</p>
|
|
609
|
+
<div class="cpub-fed-form">
|
|
610
|
+
<select v-model="refederateScope" class="cpub-fed-input" style="flex:0 0 auto;width:auto;" aria-label="Re-federate scope">
|
|
611
|
+
<option value="7">Last 7 days</option>
|
|
612
|
+
<option value="30">Last 30 days</option>
|
|
613
|
+
<option value="all">Everything</option>
|
|
614
|
+
</select>
|
|
615
|
+
<button class="cpub-fed-btn" :disabled="refederating" @click="refederate">
|
|
616
|
+
{{ refederating ? 'Queuing…' : 'Re-federate' }}
|
|
617
|
+
</button>
|
|
618
|
+
</div>
|
|
407
619
|
<div v-if="refederateResult" class="cpub-fed-tool-result">
|
|
408
620
|
Queued {{ refederateResult.queued }} items for delivery.
|
|
409
621
|
<span v-if="refederateResult.content !== undefined" style="display: block; font-size: 12px; color: var(--text-faint); margin-top: 4px">
|
|
@@ -414,6 +626,20 @@ async function refederate(): Promise<void> {
|
|
|
414
626
|
</div>
|
|
415
627
|
</div>
|
|
416
628
|
</template>
|
|
629
|
+
|
|
630
|
+
<MirrorDetailModal
|
|
631
|
+
v-if="selectedMirror"
|
|
632
|
+
:mirror="selectedMirror"
|
|
633
|
+
@close="selectedMirror = null"
|
|
634
|
+
@changed="onMirrorChanged"
|
|
635
|
+
/>
|
|
636
|
+
|
|
637
|
+
<MirrorRequestApproveModal
|
|
638
|
+
v-if="approvingRequest"
|
|
639
|
+
:request="approvingRequest"
|
|
640
|
+
@close="approvingRequest = null"
|
|
641
|
+
@changed="onRequestChanged"
|
|
642
|
+
/>
|
|
417
643
|
</div>
|
|
418
644
|
</template>
|
|
419
645
|
|
|
@@ -501,6 +727,36 @@ async function refederate(): Promise<void> {
|
|
|
501
727
|
.cpub-fed-info-text { font-size: 0.75rem; color: var(--text-dim); margin-top: 12px; }
|
|
502
728
|
.cpub-fed-info-text code { font-family: var(--font-mono); background: var(--surface2); padding: 1px 4px; }
|
|
503
729
|
|
|
730
|
+
/* Mirrors tab — explainer, create form, legend, list extras */
|
|
731
|
+
.cpub-fed-explain { font-size: 0.8125rem; color: var(--text-dim); line-height: 1.6; margin-bottom: 16px; }
|
|
732
|
+
.cpub-fed-create { margin-bottom: 16px; }
|
|
733
|
+
.cpub-fed-disclosure {
|
|
734
|
+
background: none; border: none; cursor: pointer; padding: 2px 0;
|
|
735
|
+
font-family: var(--font-mono); font-size: 11px; font-weight: 600; text-transform: uppercase;
|
|
736
|
+
letter-spacing: 0.06em; color: var(--text-dim); display: flex; align-items: center; gap: 6px;
|
|
737
|
+
}
|
|
738
|
+
.cpub-fed-disclosure:hover { color: var(--accent); }
|
|
739
|
+
.cpub-fed-advanced {
|
|
740
|
+
margin-top: 10px; padding: 12px; border: var(--border-width-default) solid var(--border);
|
|
741
|
+
background: var(--surface2); display: flex; flex-direction: column; gap: 6px;
|
|
742
|
+
}
|
|
743
|
+
.cpub-fed-adv-label { font-family: var(--font-mono); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); margin-top: 6px; }
|
|
744
|
+
.cpub-fed-adv-faint { color: var(--text-faint); font-weight: 400; text-transform: none; letter-spacing: 0; }
|
|
745
|
+
.cpub-fed-checks { display: flex; gap: 12px; flex-wrap: wrap; }
|
|
746
|
+
.cpub-fed-check { display: flex; align-items: center; gap: 5px; font-size: 0.8125rem; font-family: var(--font-mono); cursor: pointer; }
|
|
747
|
+
.cpub-fed-legend { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 12px; font-size: 0.75rem; color: var(--text-dim); align-items: center; }
|
|
748
|
+
.cpub-fed-legend > span { display: flex; align-items: center; gap: 6px; }
|
|
749
|
+
.cpub-fed-dir-arrow { font-weight: 700; color: var(--accent); font-family: var(--font-mono); min-width: 12px; text-align: center; }
|
|
750
|
+
.cpub-fed-mirror-name {
|
|
751
|
+
background: none; border: none; cursor: pointer; padding: 0; text-align: left;
|
|
752
|
+
font-weight: 600; color: var(--text); min-width: 60px; font-family: var(--font-mono); font-size: 0.75rem;
|
|
753
|
+
}
|
|
754
|
+
.cpub-fed-mirror-name:hover { color: var(--accent); text-decoration: underline; }
|
|
755
|
+
.cpub-fed-subhead {
|
|
756
|
+
font-family: var(--font-mono); font-size: 0.8125rem; font-weight: 700; text-transform: uppercase;
|
|
757
|
+
letter-spacing: 0.04em; margin: 24px 0 4px;
|
|
758
|
+
}
|
|
759
|
+
|
|
504
760
|
.cpub-fed-result {
|
|
505
761
|
margin-top: 8px; padding: 10px 14px; font-size: 0.8125rem; font-family: var(--font-mono);
|
|
506
762
|
background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); color: var(--text);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { listInstanceFollowers } from '@commonpub/server';
|
|
2
|
+
import { extractDomain } from '../../../utils/inbox';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /api/admin/federation/followers
|
|
6
|
+
* Instances mirroring US — remote actors that follow our instance Service actor.
|
|
7
|
+
* Answers "who is mirroring me". Admin only.
|
|
8
|
+
*/
|
|
9
|
+
export default defineEventHandler(async (event) => {
|
|
10
|
+
requireFeature('federation');
|
|
11
|
+
requirePermission(event, 'federation.manage');
|
|
12
|
+
|
|
13
|
+
const config = useConfig();
|
|
14
|
+
const runtimeConfig = useRuntimeConfig();
|
|
15
|
+
const domain = extractDomain((runtimeConfig.public?.siteUrl as string) || `https://${config.instance.domain}`);
|
|
16
|
+
|
|
17
|
+
return listInstanceFollowers(useDB(), domain);
|
|
18
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { approveMirrorRequest } from '@commonpub/server';
|
|
2
|
+
import { approveMirrorRequestSchema } from '@commonpub/schema';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* POST /api/admin/federation/mirror-requests/[id]/approve
|
|
6
|
+
* Approve an incoming mirror request: create a pull mirror of the requester using the approver's
|
|
7
|
+
* own bounded depth + filters, then Accept the request. Body (all optional):
|
|
8
|
+
* { sinceDays?, maxItems?, filterContentTypes?, filterTags? } — absent depth = forward-only.
|
|
9
|
+
* Admin only.
|
|
10
|
+
*/
|
|
11
|
+
export default defineEventHandler(async (event) => {
|
|
12
|
+
requireFeature('federation');
|
|
13
|
+
requirePermission(event, 'federation.manage');
|
|
14
|
+
|
|
15
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
16
|
+
const body = await parseBody(event, approveMirrorRequestSchema.optional()).catch(() => ({}));
|
|
17
|
+
const config = useConfig();
|
|
18
|
+
|
|
19
|
+
return approveMirrorRequest(useDB(), id, config.instance.domain, body ?? {});
|
|
20
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { rejectMirrorRequest } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* POST /api/admin/federation/mirror-requests/[id]/reject
|
|
5
|
+
* Reject an incoming mirror request: send Reject(Offer) to the requester; create no mirror.
|
|
6
|
+
* Admin only.
|
|
7
|
+
*/
|
|
8
|
+
export default defineEventHandler(async (event) => {
|
|
9
|
+
requireFeature('federation');
|
|
10
|
+
requirePermission(event, 'federation.manage');
|
|
11
|
+
|
|
12
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
13
|
+
const config = useConfig();
|
|
14
|
+
|
|
15
|
+
return rejectMirrorRequest(useDB(), id, config.instance.domain);
|
|
16
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { listMirrorRequests } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/admin/federation/mirror-requests
|
|
5
|
+
* Consent-based mirror requests (Phase 3), grouped by direction:
|
|
6
|
+
* - `incoming` — instances asking US to mirror them (approve/reject in the admin UI)
|
|
7
|
+
* - `outgoing` — instances WE asked to mirror us (track approval status)
|
|
8
|
+
* Admin only.
|
|
9
|
+
*/
|
|
10
|
+
export default defineEventHandler(async (event) => {
|
|
11
|
+
requireFeature('federation');
|
|
12
|
+
requirePermission(event, 'federation.manage');
|
|
13
|
+
const db = useDB();
|
|
14
|
+
|
|
15
|
+
const [incoming, outgoing] = await Promise.all([
|
|
16
|
+
listMirrorRequests(db, 'incoming'),
|
|
17
|
+
listMirrorRequests(db, 'outgoing'),
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
return { incoming, outgoing };
|
|
21
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getMirror, backfillFromOutbox } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
2
3
|
/** Extract clean domain from URL */
|
|
3
4
|
function extractDomain(url: string): string {
|
|
4
5
|
try { return new URL(url).hostname; }
|
|
@@ -8,7 +9,11 @@ function extractDomain(url: string): string {
|
|
|
8
9
|
/**
|
|
9
10
|
* POST /api/admin/federation/mirrors/[id]/backfill
|
|
10
11
|
* Crawl the remote instance's outbox to import historical content.
|
|
11
|
-
*
|
|
12
|
+
*
|
|
13
|
+
* Bounded by operator choice so a mirror of a large instance can't pull thousands at once:
|
|
14
|
+
* - `sinceDays` — only import items published within the last N days (maps to backfill `since`)
|
|
15
|
+
* - `maxItems` — hard cap on items pulled this run
|
|
16
|
+
* Both optional; `mirrorMaxItems` from federation config remains the ceiling. Admin only.
|
|
12
17
|
*/
|
|
13
18
|
export default defineEventHandler(async (event) => {
|
|
14
19
|
requirePermission(event, 'federation.manage');
|
|
@@ -19,6 +24,10 @@ export default defineEventHandler(async (event) => {
|
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
const { id: mirrorId } = parseParams(event, { id: 'uuid' });
|
|
27
|
+
const body = await parseBody(event, z.object({
|
|
28
|
+
sinceDays: z.number().int().positive().max(3650).optional(),
|
|
29
|
+
maxItems: z.number().int().positive().max(10000).optional(),
|
|
30
|
+
}).optional()).catch(() => ({} as { sinceDays?: number; maxItems?: number }));
|
|
22
31
|
const db = useDB();
|
|
23
32
|
|
|
24
33
|
const mirror = await getMirror(db, mirrorId);
|
|
@@ -29,7 +38,22 @@ export default defineEventHandler(async (event) => {
|
|
|
29
38
|
const runtimeConfig = useRuntimeConfig();
|
|
30
39
|
const domain = extractDomain((runtimeConfig.public?.siteUrl as string) || `https://${config.instance.domain}`);
|
|
31
40
|
|
|
32
|
-
const
|
|
41
|
+
const ceiling = config.federation?.mirrorMaxItems;
|
|
42
|
+
const requested = body?.maxItems;
|
|
43
|
+
const maxItems = ceiling != null
|
|
44
|
+
? Math.min(requested ?? ceiling, ceiling)
|
|
45
|
+
: requested;
|
|
46
|
+
const since = body?.sinceDays != null
|
|
47
|
+
? new Date(Date.now() - body.sinceDays * 24 * 60 * 60 * 1000)
|
|
48
|
+
: undefined;
|
|
49
|
+
|
|
50
|
+
// Manual admin backfill crawls fresh from the top each run (no cursor resume) so a
|
|
51
|
+
// depth-picked "last N days" run isn't skewed by a stale cursor from a prior full crawl.
|
|
52
|
+
// processInboxActivity upserts by objectUri, so re-crawling is idempotent.
|
|
53
|
+
const result = await backfillFromOutbox(db, mirror.remoteActorUri, domain, {
|
|
54
|
+
...(maxItems != null ? { maxItems } : {}),
|
|
55
|
+
...(since ? { since } : {}),
|
|
56
|
+
});
|
|
33
57
|
|
|
34
58
|
return {
|
|
35
59
|
mirrorId: mirror.id,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createMirror } from '@commonpub/server';
|
|
1
|
+
import { createMirror, requestMirror } from '@commonpub/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
|
|
4
4
|
const createMirrorSchema = z.object({
|
|
@@ -14,13 +14,19 @@ export default defineEventHandler(async (event) => {
|
|
|
14
14
|
requirePermission(event, 'federation.manage');
|
|
15
15
|
const db = useDB();
|
|
16
16
|
const input = await parseBody(event, createMirrorSchema);
|
|
17
|
-
|
|
18
17
|
const config = useConfig();
|
|
18
|
+
|
|
19
|
+
// Push = consent-based mirror request (Phase 3): ask them to pull-mirror us. No filters here —
|
|
20
|
+
// the approver chooses their own depth/filters. Pull = a normal subscription to their content.
|
|
21
|
+
if (input.direction === 'push') {
|
|
22
|
+
return requestMirror(db, input.remoteDomain, input.remoteActorUri, config.instance.domain);
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
return createMirror(
|
|
20
26
|
db,
|
|
21
27
|
input.remoteDomain,
|
|
22
28
|
input.remoteActorUri,
|
|
23
|
-
|
|
29
|
+
'pull',
|
|
24
30
|
config.instance.domain,
|
|
25
31
|
{
|
|
26
32
|
contentTypes: input.filterContentTypes ?? undefined,
|