@commonpub/layer 0.43.3 → 0.45.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 +7 -7
- 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
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
interface MirrorView {
|
|
3
|
+
id: string;
|
|
4
|
+
remoteDomain: string;
|
|
5
|
+
remoteActorUri: string;
|
|
6
|
+
status: string;
|
|
7
|
+
direction: string;
|
|
8
|
+
filterContentTypes: string[] | null;
|
|
9
|
+
filterTags: string[] | null;
|
|
10
|
+
contentCount: number;
|
|
11
|
+
errorCount: number;
|
|
12
|
+
lastError: string | null;
|
|
13
|
+
lastSyncAt: string | null;
|
|
14
|
+
backfillCursor?: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{ mirror: MirrorView }>();
|
|
18
|
+
const emit = defineEmits<{ close: []; changed: [] }>();
|
|
19
|
+
|
|
20
|
+
const toast = useToast();
|
|
21
|
+
|
|
22
|
+
// Activate the focus trap once mounted (parent gates with v-if).
|
|
23
|
+
const contentRef = ref<HTMLElement | null>(null);
|
|
24
|
+
const visible = ref(false);
|
|
25
|
+
onMounted(() => { visible.value = true; });
|
|
26
|
+
useFocusTrap(contentRef, () => visible.value, () => emit('close'));
|
|
27
|
+
|
|
28
|
+
// Bounded "how far back" choices for re-backfill — mirrors the create form.
|
|
29
|
+
const DEPTH_OPTIONS: Array<{ label: string; body: Record<string, number> }> = [
|
|
30
|
+
{ label: 'Last 7 days', body: { sinceDays: 7 } },
|
|
31
|
+
{ label: 'Last 30 days', body: { sinceDays: 30 } },
|
|
32
|
+
{ label: 'Last 90 days', body: { sinceDays: 90 } },
|
|
33
|
+
{ label: 'Last 200 items', body: { maxItems: 200 } },
|
|
34
|
+
{ label: 'Everything (up to limit)', body: {} },
|
|
35
|
+
];
|
|
36
|
+
const depthIndex = ref(1); // default last 30 days
|
|
37
|
+
|
|
38
|
+
const busy = ref<'' | 'toggle' | 'backfill' | 'delete'>('');
|
|
39
|
+
const confirmingDelete = ref(false);
|
|
40
|
+
const backfillResult = ref<{ processed: number; errors: number; pages: number; complete: boolean } | null>(null);
|
|
41
|
+
|
|
42
|
+
const isPull = computed(() => props.mirror.direction !== 'push');
|
|
43
|
+
|
|
44
|
+
// `$fetch` typed-routes inference recurses over the full route union on dynamic template URLs
|
|
45
|
+
// (TS2321 "excessive stack depth"); a string-typed const forces the plain string overload.
|
|
46
|
+
async function toggle(): Promise<void> {
|
|
47
|
+
busy.value = 'toggle';
|
|
48
|
+
const url: string = `/api/admin/federation/mirrors/${props.mirror.id}`;
|
|
49
|
+
try {
|
|
50
|
+
await $fetch(url, { method: 'PUT', body: { action: props.mirror.status === 'active' ? 'pause' : 'resume' } });
|
|
51
|
+
toast.success(props.mirror.status === 'active' ? 'Mirror paused' : 'Mirror resumed');
|
|
52
|
+
emit('changed');
|
|
53
|
+
} catch {
|
|
54
|
+
toast.error('Failed to update mirror');
|
|
55
|
+
} finally {
|
|
56
|
+
busy.value = '';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function backfill(): Promise<void> {
|
|
61
|
+
busy.value = 'backfill';
|
|
62
|
+
backfillResult.value = null;
|
|
63
|
+
const url: string = `/api/admin/federation/mirrors/${props.mirror.id}/backfill`;
|
|
64
|
+
try {
|
|
65
|
+
backfillResult.value = await $fetch<{ processed: number; errors: number; pages: number; complete: boolean }>(url, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
body: DEPTH_OPTIONS[depthIndex.value]!.body,
|
|
68
|
+
});
|
|
69
|
+
toast.success(`Imported ${backfillResult.value?.processed ?? 0} item(s)`);
|
|
70
|
+
emit('changed');
|
|
71
|
+
} catch {
|
|
72
|
+
toast.error('Backfill failed');
|
|
73
|
+
} finally {
|
|
74
|
+
busy.value = '';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function remove(): Promise<void> {
|
|
79
|
+
busy.value = 'delete';
|
|
80
|
+
const url: string = `/api/admin/federation/mirrors/${props.mirror.id}`;
|
|
81
|
+
try {
|
|
82
|
+
await $fetch(url, { method: 'DELETE' });
|
|
83
|
+
toast.success('Mirror deleted');
|
|
84
|
+
emit('changed');
|
|
85
|
+
emit('close');
|
|
86
|
+
} catch {
|
|
87
|
+
toast.error('Failed to delete mirror');
|
|
88
|
+
} finally {
|
|
89
|
+
busy.value = '';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<template>
|
|
95
|
+
<div class="cpub-modal-backdrop" @click.self="emit('close')">
|
|
96
|
+
<div ref="contentRef" class="cpub-modal-content" role="dialog" aria-modal="true" aria-labelledby="cpub-mirror-modal-title">
|
|
97
|
+
<div class="cpub-modal-header">
|
|
98
|
+
<h3 id="cpub-mirror-modal-title" class="cpub-modal-title">{{ mirror.remoteDomain }}</h3>
|
|
99
|
+
<button class="cpub-modal-close" aria-label="Close" @click="emit('close')"><i class="fa-solid fa-xmark"></i></button>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<p class="cpub-mm-sub">
|
|
103
|
+
<span class="cpub-mm-dir">{{ isPull ? '↓ Pull (you receive their content)' : '↑ Push request' }}</span>
|
|
104
|
+
— one-directional: this instance receives content from {{ mirror.remoteDomain }}; they receive nothing from you.
|
|
105
|
+
</p>
|
|
106
|
+
|
|
107
|
+
<!-- Facts -->
|
|
108
|
+
<dl class="cpub-mm-facts">
|
|
109
|
+
<div><dt>Status</dt><dd><span class="cpub-fed-status" :class="mirror.status">{{ mirror.status }}</span></dd></div>
|
|
110
|
+
<div><dt>Items imported</dt><dd>{{ mirror.contentCount }}</dd></div>
|
|
111
|
+
<div><dt>Errors</dt><dd>{{ mirror.errorCount }}</dd></div>
|
|
112
|
+
<div><dt>Last sync</dt><dd>{{ mirror.lastSyncAt ? new Date(mirror.lastSyncAt).toLocaleString() : 'never' }}</dd></div>
|
|
113
|
+
<div><dt>Actor</dt><dd class="cpub-mm-mono">{{ mirror.remoteActorUri }}</dd></div>
|
|
114
|
+
<div>
|
|
115
|
+
<dt>Content types</dt>
|
|
116
|
+
<dd>
|
|
117
|
+
<template v-if="mirror.filterContentTypes && mirror.filterContentTypes.length">
|
|
118
|
+
<span v-for="t in mirror.filterContentTypes" :key="t" class="cpub-mm-chip">{{ t }}</span>
|
|
119
|
+
</template>
|
|
120
|
+
<span v-else class="cpub-mm-faint">all types</span>
|
|
121
|
+
</dd>
|
|
122
|
+
</div>
|
|
123
|
+
<div>
|
|
124
|
+
<dt>Tags</dt>
|
|
125
|
+
<dd>
|
|
126
|
+
<template v-if="mirror.filterTags && mirror.filterTags.length">
|
|
127
|
+
<span v-for="t in mirror.filterTags" :key="t" class="cpub-mm-chip">#{{ t }}</span>
|
|
128
|
+
</template>
|
|
129
|
+
<span v-else class="cpub-mm-faint">all tags</span>
|
|
130
|
+
</dd>
|
|
131
|
+
</div>
|
|
132
|
+
</dl>
|
|
133
|
+
|
|
134
|
+
<div v-if="mirror.lastError" class="cpub-mm-error">
|
|
135
|
+
<strong>Last error:</strong> {{ mirror.lastError }}
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<!-- Re-backfill with bounded depth -->
|
|
139
|
+
<div class="cpub-mm-section">
|
|
140
|
+
<label class="cpub-mm-label" for="cpub-mm-depth">Import history</label>
|
|
141
|
+
<div class="cpub-mm-row">
|
|
142
|
+
<select id="cpub-mm-depth" v-model.number="depthIndex" class="cpub-fed-input">
|
|
143
|
+
<option v-for="(opt, i) in DEPTH_OPTIONS" :key="i" :value="i">{{ opt.label }}</option>
|
|
144
|
+
</select>
|
|
145
|
+
<button class="cpub-fed-btn" :disabled="busy === 'backfill'" @click="backfill">
|
|
146
|
+
{{ busy === 'backfill' ? 'Importing…' : 'Backfill' }}
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
<p class="cpub-mm-hint">Crawls {{ mirror.remoteDomain }}'s outbox newest-first and stops at the chosen depth — bounded so you don't pull an entire large instance at once.</p>
|
|
150
|
+
<div v-if="backfillResult" class="cpub-fed-result">
|
|
151
|
+
Imported {{ backfillResult.processed }} item(s), {{ backfillResult.errors }} error(s), {{ backfillResult.pages }} page(s){{ backfillResult.complete ? ' — complete.' : ' — more available (run again).' }}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<!-- Actions -->
|
|
156
|
+
<div class="cpub-modal-actions">
|
|
157
|
+
<button class="cpub-fed-btn" :disabled="busy === 'toggle'" @click="toggle">
|
|
158
|
+
{{ mirror.status === 'active' ? 'Pause' : 'Resume' }}
|
|
159
|
+
</button>
|
|
160
|
+
<template v-if="!confirmingDelete">
|
|
161
|
+
<button class="cpub-fed-btn-sm cpub-fed-btn-danger" @click="confirmingDelete = true">Delete mirror</button>
|
|
162
|
+
</template>
|
|
163
|
+
<template v-else>
|
|
164
|
+
<span class="cpub-mm-confirm">Delete and hide its content?</span>
|
|
165
|
+
<button class="cpub-fed-btn-sm cpub-fed-btn-danger" :disabled="busy === 'delete'" @click="remove">
|
|
166
|
+
{{ busy === 'delete' ? 'Deleting…' : 'Confirm delete' }}
|
|
167
|
+
</button>
|
|
168
|
+
<button class="cpub-fed-btn-sm" @click="confirmingDelete = false">Cancel</button>
|
|
169
|
+
</template>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</template>
|
|
174
|
+
|
|
175
|
+
<style scoped>
|
|
176
|
+
.cpub-modal-backdrop {
|
|
177
|
+
position: fixed; inset: 0; background: var(--color-surface-scrim, rgba(0,0,0,0.5));
|
|
178
|
+
z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px;
|
|
179
|
+
}
|
|
180
|
+
.cpub-modal-content {
|
|
181
|
+
background: var(--surface); border: var(--border-width-default) solid var(--border);
|
|
182
|
+
box-shadow: var(--shadow-lg); padding: 24px; max-width: 520px; width: 92vw;
|
|
183
|
+
max-height: 90vh; overflow-y: auto;
|
|
184
|
+
}
|
|
185
|
+
.cpub-modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
|
186
|
+
.cpub-modal-title { font-size: 16px; font-weight: 700; font-family: var(--font-mono); }
|
|
187
|
+
.cpub-modal-close { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 14px; padding: 4px; }
|
|
188
|
+
.cpub-modal-close:hover { color: var(--text); }
|
|
189
|
+
|
|
190
|
+
.cpub-mm-sub { font-size: 0.8125rem; color: var(--text-dim); line-height: 1.5; margin-bottom: 16px; }
|
|
191
|
+
.cpub-mm-dir { font-weight: 700; color: var(--accent); font-family: var(--font-mono); }
|
|
192
|
+
|
|
193
|
+
.cpub-mm-facts { display: grid; grid-template-columns: 1fr; gap: 0; margin-bottom: 16px; border: var(--border-width-default) solid var(--border); }
|
|
194
|
+
.cpub-mm-facts > div { display: flex; gap: 12px; padding: 8px 12px; border-bottom: var(--border-width-default) solid var(--border); font-size: 0.8125rem; }
|
|
195
|
+
.cpub-mm-facts > div:last-child { border-bottom: none; }
|
|
196
|
+
.cpub-mm-facts dt { flex: 0 0 110px; color: var(--text-dim); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; padding-top: 2px; }
|
|
197
|
+
.cpub-mm-facts dd { flex: 1; min-width: 0; margin: 0; word-break: break-word; }
|
|
198
|
+
.cpub-mm-mono { font-family: var(--font-mono); font-size: 0.75rem; }
|
|
199
|
+
.cpub-mm-faint { color: var(--text-faint); }
|
|
200
|
+
.cpub-mm-chip { display: inline-block; font-family: var(--font-mono); font-size: 10px; padding: 1px 6px; margin: 0 4px 4px 0; border: var(--border-width-default) solid var(--accent-border); background: var(--accent-bg); color: var(--accent); }
|
|
201
|
+
|
|
202
|
+
.cpub-mm-error { font-size: 0.75rem; color: var(--red); background: var(--surface2); padding: 8px 12px; margin-bottom: 16px; border: var(--border-width-default) solid var(--red); word-break: break-word; }
|
|
203
|
+
|
|
204
|
+
.cpub-mm-section { margin-bottom: 16px; }
|
|
205
|
+
.cpub-mm-label { display: block; font-family: var(--font-mono); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 6px; }
|
|
206
|
+
.cpub-mm-row { display: flex; gap: 8px; }
|
|
207
|
+
.cpub-mm-hint { font-size: 0.75rem; color: var(--text-faint); margin-top: 6px; line-height: 1.4; }
|
|
208
|
+
|
|
209
|
+
.cpub-modal-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border-top: var(--border-width-default) solid var(--border); padding-top: 16px; }
|
|
210
|
+
.cpub-mm-confirm { font-size: 0.8125rem; color: var(--red); font-family: var(--font-mono); }
|
|
211
|
+
|
|
212
|
+
/* Shared federation control styles (also defined on the page; repeated here for the modal's scope). */
|
|
213
|
+
.cpub-fed-input { flex: 1; padding: 8px 12px; font-family: var(--font-mono); font-size: 0.8125rem; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); }
|
|
214
|
+
.cpub-fed-btn { padding: 8px 16px; font-family: var(--font-mono); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; cursor: pointer; background: var(--accent); color: var(--color-text-inverse); border: var(--border-width-default) solid var(--accent); box-shadow: var(--shadow-sm); }
|
|
215
|
+
.cpub-fed-btn:hover { box-shadow: none; transform: translate(2px, 2px); }
|
|
216
|
+
.cpub-fed-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
|
|
217
|
+
.cpub-fed-btn-sm { padding: 4px 10px; font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; cursor: pointer; background: transparent; border: var(--border-width-default) solid var(--border); color: var(--text-dim); }
|
|
218
|
+
.cpub-fed-btn-sm:hover { border-color: var(--accent); color: var(--accent); }
|
|
219
|
+
.cpub-fed-btn-sm:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
220
|
+
.cpub-fed-btn-danger { color: var(--red); border-color: var(--red); }
|
|
221
|
+
.cpub-fed-btn-danger:hover { border-color: var(--red); color: var(--red); background: var(--surface2); }
|
|
222
|
+
.cpub-fed-status { font-size: 10px; font-weight: 600; padding: 2px 6px; text-transform: uppercase; font-family: var(--font-mono); border: var(--border-width-default) solid var(--border); }
|
|
223
|
+
.cpub-fed-status.active { color: var(--accent); border-color: var(--accent-border); background: var(--accent-bg); }
|
|
224
|
+
.cpub-fed-status.pending { color: var(--text-dim); }
|
|
225
|
+
.cpub-fed-status.paused { color: var(--text-dim); background: var(--surface2); }
|
|
226
|
+
.cpub-fed-status.failed { color: var(--red); border-color: var(--red); }
|
|
227
|
+
.cpub-fed-result { margin-top: 8px; padding: 10px 14px; font-size: 0.8125rem; font-family: var(--font-mono); background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); color: var(--text); }
|
|
228
|
+
</style>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
interface RequestView {
|
|
3
|
+
id: string;
|
|
4
|
+
remoteDomain: string;
|
|
5
|
+
remoteActorUri: string;
|
|
6
|
+
createdAt: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{ request: RequestView }>();
|
|
10
|
+
const emit = defineEmits<{ close: []; changed: [] }>();
|
|
11
|
+
|
|
12
|
+
const toast = useToast();
|
|
13
|
+
|
|
14
|
+
// Activate the focus trap once mounted (parent gates with v-if).
|
|
15
|
+
const contentRef = ref<HTMLElement | null>(null);
|
|
16
|
+
const visible = ref(false);
|
|
17
|
+
onMounted(() => { visible.value = true; });
|
|
18
|
+
useFocusTrap(contentRef, () => visible.value, () => emit('close'));
|
|
19
|
+
|
|
20
|
+
// Same bounded depth choices as the create form — what history to pull when we approve.
|
|
21
|
+
const DEPTH_OPTIONS: Array<{ label: string; body: Record<string, number> | null }> = [
|
|
22
|
+
{ label: 'None — forward only (default)', body: null },
|
|
23
|
+
{ label: 'Last 7 days', body: { sinceDays: 7 } },
|
|
24
|
+
{ label: 'Last 30 days', body: { sinceDays: 30 } },
|
|
25
|
+
{ label: 'Last 90 days', body: { sinceDays: 90 } },
|
|
26
|
+
{ label: 'Last 200 items', body: { maxItems: 200 } },
|
|
27
|
+
{ label: 'Everything (up to limit)', body: {} },
|
|
28
|
+
];
|
|
29
|
+
const depthIndex = ref(0); // default forward-only
|
|
30
|
+
|
|
31
|
+
const FEDERATABLE_TYPES = ['project', 'blog', 'explainer'] as const;
|
|
32
|
+
const types = ref<string[]>([]);
|
|
33
|
+
const tagsRaw = ref('');
|
|
34
|
+
|
|
35
|
+
function toggleType(t: string): void {
|
|
36
|
+
const i = types.value.indexOf(t);
|
|
37
|
+
if (i === -1) types.value.push(t);
|
|
38
|
+
else types.value.splice(i, 1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const busy = ref<'' | 'approve' | 'reject'>('');
|
|
42
|
+
|
|
43
|
+
async function approve(): Promise<void> {
|
|
44
|
+
busy.value = 'approve';
|
|
45
|
+
const tags = tagsRaw.value.split(',').map((t) => t.trim().replace(/^#/, '')).filter(Boolean);
|
|
46
|
+
const depth = DEPTH_OPTIONS[depthIndex.value]!.body;
|
|
47
|
+
const body: Record<string, unknown> = { ...(depth ?? {}) };
|
|
48
|
+
if (types.value.length) body.filterContentTypes = types.value;
|
|
49
|
+
if (tags.length) body.filterTags = tags;
|
|
50
|
+
const url: string = `/api/admin/federation/mirror-requests/${props.request.id}/approve`;
|
|
51
|
+
try {
|
|
52
|
+
await $fetch(url, { method: 'POST', body });
|
|
53
|
+
toast.success(`Approved — now mirroring ${props.request.remoteDomain}`);
|
|
54
|
+
emit('changed');
|
|
55
|
+
emit('close');
|
|
56
|
+
} catch {
|
|
57
|
+
toast.error('Failed to approve request');
|
|
58
|
+
} finally {
|
|
59
|
+
busy.value = '';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function reject(): Promise<void> {
|
|
64
|
+
busy.value = 'reject';
|
|
65
|
+
const url: string = `/api/admin/federation/mirror-requests/${props.request.id}/reject`;
|
|
66
|
+
try {
|
|
67
|
+
await $fetch(url, { method: 'POST' });
|
|
68
|
+
toast.success('Request rejected');
|
|
69
|
+
emit('changed');
|
|
70
|
+
emit('close');
|
|
71
|
+
} catch {
|
|
72
|
+
toast.error('Failed to reject request');
|
|
73
|
+
} finally {
|
|
74
|
+
busy.value = '';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<template>
|
|
80
|
+
<div class="cpub-modal-backdrop" @click.self="emit('close')">
|
|
81
|
+
<div ref="contentRef" class="cpub-modal-content" role="dialog" aria-modal="true" aria-labelledby="cpub-mr-modal-title">
|
|
82
|
+
<div class="cpub-modal-header">
|
|
83
|
+
<h3 id="cpub-mr-modal-title" class="cpub-modal-title">Approve mirror request</h3>
|
|
84
|
+
<button class="cpub-modal-close" aria-label="Close" @click="emit('close')"><i class="fa-solid fa-xmark"></i></button>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<p class="cpub-mr-sub">
|
|
88
|
+
<strong>{{ request.remoteDomain }}</strong> asked you to mirror your instance. Approving creates a
|
|
89
|
+
<strong>pull mirror</strong> of them — you'll receive their public content, with the depth and
|
|
90
|
+
filters you choose below. (One-directional: they still receive nothing from you.)
|
|
91
|
+
</p>
|
|
92
|
+
|
|
93
|
+
<!-- History depth -->
|
|
94
|
+
<div class="cpub-mr-section">
|
|
95
|
+
<label class="cpub-mr-label" for="cpub-mr-depth">Import history</label>
|
|
96
|
+
<select id="cpub-mr-depth" v-model.number="depthIndex" class="cpub-fed-input" style="width:100%;">
|
|
97
|
+
<option v-for="(opt, i) in DEPTH_OPTIONS" :key="i" :value="i">{{ opt.label }}</option>
|
|
98
|
+
</select>
|
|
99
|
+
<p class="cpub-mr-hint">Bounded so you don't pull an entire large instance at once. New posts arrive automatically regardless.</p>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<!-- Filters -->
|
|
103
|
+
<div class="cpub-mr-section">
|
|
104
|
+
<span class="cpub-mr-label">Content types <span class="cpub-mr-faint">(none = all)</span></span>
|
|
105
|
+
<div class="cpub-mr-checks">
|
|
106
|
+
<label v-for="t in FEDERATABLE_TYPES" :key="t" class="cpub-mr-check">
|
|
107
|
+
<input type="checkbox" :checked="types.includes(t)" @change="toggleType(t)" /> {{ t }}
|
|
108
|
+
</label>
|
|
109
|
+
</div>
|
|
110
|
+
<label class="cpub-mr-label" for="cpub-mr-tags">Tags <span class="cpub-mr-faint">(comma-separated, none = all)</span></label>
|
|
111
|
+
<input id="cpub-mr-tags" v-model="tagsRaw" placeholder="arduino, 3dprinting" class="cpub-fed-input" style="width:100%;" />
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<!-- Actions -->
|
|
115
|
+
<div class="cpub-modal-actions">
|
|
116
|
+
<button class="cpub-fed-btn" :disabled="busy === 'approve'" @click="approve">
|
|
117
|
+
{{ busy === 'approve' ? 'Approving…' : 'Approve & mirror' }}
|
|
118
|
+
</button>
|
|
119
|
+
<button class="cpub-fed-btn-sm cpub-fed-btn-danger" :disabled="busy === 'reject'" @click="reject">
|
|
120
|
+
{{ busy === 'reject' ? 'Rejecting…' : 'Reject' }}
|
|
121
|
+
</button>
|
|
122
|
+
<button class="cpub-fed-btn-sm" @click="emit('close')">Cancel</button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</template>
|
|
127
|
+
|
|
128
|
+
<style scoped>
|
|
129
|
+
.cpub-modal-backdrop {
|
|
130
|
+
position: fixed; inset: 0; background: var(--color-surface-scrim, rgba(0,0,0,0.5));
|
|
131
|
+
z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px;
|
|
132
|
+
}
|
|
133
|
+
.cpub-modal-content {
|
|
134
|
+
background: var(--surface); border: var(--border-width-default) solid var(--border);
|
|
135
|
+
box-shadow: var(--shadow-lg); padding: 24px; max-width: 520px; width: 92vw;
|
|
136
|
+
max-height: 90vh; overflow-y: auto;
|
|
137
|
+
}
|
|
138
|
+
.cpub-modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
|
139
|
+
.cpub-modal-title { font-size: 16px; font-weight: 700; font-family: var(--font-mono); }
|
|
140
|
+
.cpub-modal-close { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 14px; padding: 4px; }
|
|
141
|
+
.cpub-modal-close:hover { color: var(--text); }
|
|
142
|
+
|
|
143
|
+
.cpub-mr-sub { font-size: 0.8125rem; color: var(--text-dim); line-height: 1.5; margin-bottom: 16px; }
|
|
144
|
+
.cpub-mr-section { margin-bottom: 16px; }
|
|
145
|
+
.cpub-mr-label { display: block; font-family: var(--font-mono); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 6px; }
|
|
146
|
+
.cpub-mr-faint { color: var(--text-faint); text-transform: none; letter-spacing: 0; font-weight: 400; }
|
|
147
|
+
.cpub-mr-hint { font-size: 0.75rem; color: var(--text-faint); margin-top: 6px; line-height: 1.4; }
|
|
148
|
+
.cpub-mr-checks { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
149
|
+
.cpub-mr-check { display: flex; align-items: center; gap: 4px; font-size: 0.8125rem; color: var(--text); cursor: pointer; }
|
|
150
|
+
|
|
151
|
+
.cpub-modal-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border-top: var(--border-width-default) solid var(--border); padding-top: 16px; }
|
|
152
|
+
|
|
153
|
+
/* Shared federation control styles (repeated for the modal's scope). */
|
|
154
|
+
.cpub-fed-input { padding: 8px 12px; font-family: var(--font-mono); font-size: 0.8125rem; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); }
|
|
155
|
+
.cpub-fed-btn { padding: 8px 16px; font-family: var(--font-mono); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; cursor: pointer; background: var(--accent); color: var(--color-text-inverse); border: var(--border-width-default) solid var(--accent); box-shadow: var(--shadow-sm); }
|
|
156
|
+
.cpub-fed-btn:hover { box-shadow: none; transform: translate(2px, 2px); }
|
|
157
|
+
.cpub-fed-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
|
|
158
|
+
.cpub-fed-btn-sm { padding: 4px 10px; font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; cursor: pointer; background: transparent; border: var(--border-width-default) solid var(--border); color: var(--text-dim); }
|
|
159
|
+
.cpub-fed-btn-sm:hover { border-color: var(--accent); color: var(--accent); }
|
|
160
|
+
.cpub-fed-btn-sm:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
161
|
+
.cpub-fed-btn-danger { color: var(--red); border-color: var(--red); }
|
|
162
|
+
.cpub-fed-btn-danger:hover { border-color: var(--red); color: var(--red); background: var(--surface2); }
|
|
163
|
+
</style>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
interface RegistryRow {
|
|
3
|
+
id: string;
|
|
4
|
+
domain: string;
|
|
5
|
+
actorUri: string;
|
|
6
|
+
name: string | null;
|
|
7
|
+
description: string | null;
|
|
8
|
+
userCount: number;
|
|
9
|
+
activeMonthCount: number;
|
|
10
|
+
localPostCount: number;
|
|
11
|
+
softwareName: string | null;
|
|
12
|
+
softwareVersion: string | null;
|
|
13
|
+
status: string;
|
|
14
|
+
lastPingAt: string | null;
|
|
15
|
+
online: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
defineProps<{ instances: RegistryRow[]; announcingTo?: string | null }>();
|
|
19
|
+
const emit = defineEmits<{ changed: []; search: [value: string] }>();
|
|
20
|
+
|
|
21
|
+
const toast = useToast();
|
|
22
|
+
const searchTerm = ref('');
|
|
23
|
+
const busyId = ref<string | null>(null);
|
|
24
|
+
|
|
25
|
+
function onSearch(): void {
|
|
26
|
+
emit('search', searchTerm.value.trim());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function mirror(row: RegistryRow, direction: 'pull' | 'push'): Promise<void> {
|
|
30
|
+
busyId.value = row.id;
|
|
31
|
+
try {
|
|
32
|
+
await $fetch('/api/admin/federation/mirrors', {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
body: { remoteDomain: row.domain, remoteActorUri: row.actorUri, direction },
|
|
35
|
+
});
|
|
36
|
+
toast.success(direction === 'pull'
|
|
37
|
+
? `Mirroring ${row.domain} — their posts will arrive`
|
|
38
|
+
: `Requested ${row.domain} to mirror you — awaiting their approval`);
|
|
39
|
+
emit('changed');
|
|
40
|
+
} catch {
|
|
41
|
+
toast.error(direction === 'pull' ? 'Failed to add mirror' : 'Failed to send request');
|
|
42
|
+
} finally {
|
|
43
|
+
busyId.value = null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function setStatus(row: RegistryRow, status: 'active' | 'hidden' | 'blocked'): Promise<void> {
|
|
48
|
+
busyId.value = row.id;
|
|
49
|
+
const url: string = `/api/admin/registry/instances/${row.id}/status`;
|
|
50
|
+
try {
|
|
51
|
+
await $fetch(url, { method: 'POST', body: { status } });
|
|
52
|
+
toast.success(status === 'active' ? 'Instance shown' : status === 'hidden' ? 'Instance hidden' : 'Instance blocked');
|
|
53
|
+
emit('changed');
|
|
54
|
+
} catch {
|
|
55
|
+
toast.error('Failed to update instance');
|
|
56
|
+
} finally {
|
|
57
|
+
busyId.value = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<template>
|
|
63
|
+
<div>
|
|
64
|
+
<p class="cpub-fed-explain">
|
|
65
|
+
The <strong>registry</strong> lists CommonPub instances that announce themselves here. Mirror
|
|
66
|
+
one to pull its content, or request it to mirror you (CommonPub-to-CommonPub). Hide or block
|
|
67
|
+
an entry to curate the public directory.
|
|
68
|
+
</p>
|
|
69
|
+
<p v-if="announcingTo" class="cpub-fed-info-text" style="margin-bottom: 12px;">
|
|
70
|
+
This instance is announcing itself to <strong>{{ announcingTo }}</strong>.
|
|
71
|
+
</p>
|
|
72
|
+
|
|
73
|
+
<form class="cpub-fed-form" style="margin-bottom: 12px;" @submit.prevent="onSearch">
|
|
74
|
+
<input
|
|
75
|
+
v-model="searchTerm"
|
|
76
|
+
type="search"
|
|
77
|
+
placeholder="Search by name or domain"
|
|
78
|
+
class="cpub-fed-input"
|
|
79
|
+
aria-label="Search instances"
|
|
80
|
+
/>
|
|
81
|
+
<button type="submit" class="cpub-fed-btn">Search</button>
|
|
82
|
+
</form>
|
|
83
|
+
|
|
84
|
+
<div class="cpub-fed-activity-list">
|
|
85
|
+
<div v-if="!instances.length" class="cpub-fed-empty">No instances registered yet.</div>
|
|
86
|
+
<div v-for="i in instances" :key="i.id" class="cpub-fed-activity-row">
|
|
87
|
+
<span class="cpub-reg-dot" :class="{ online: i.online }" :title="i.online ? 'online' : 'offline'" aria-hidden="true"></span>
|
|
88
|
+
<span class="cpub-fed-type">{{ i.name || i.domain }}</span>
|
|
89
|
+
<span class="cpub-fed-actor">
|
|
90
|
+
{{ i.domain }} · {{ i.userCount }} users · {{ i.localPostCount }} posts<template v-if="i.softwareName"> · {{ i.softwareName }} {{ i.softwareVersion }}</template>
|
|
91
|
+
</span>
|
|
92
|
+
<span v-if="i.status !== 'active'" class="cpub-fed-status" :class="i.status === 'blocked' ? 'failed' : 'paused'">{{ i.status }}</span>
|
|
93
|
+
<button class="cpub-fed-btn-sm" :disabled="busyId === i.id" @click="mirror(i, 'pull')">Mirror</button>
|
|
94
|
+
<button class="cpub-fed-btn-sm" :disabled="busyId === i.id" @click="mirror(i, 'push')">Request mirror</button>
|
|
95
|
+
<button v-if="i.status === 'active'" class="cpub-fed-btn-sm" :disabled="busyId === i.id" @click="setStatus(i, 'hidden')">Hide</button>
|
|
96
|
+
<button v-else-if="i.status === 'hidden'" class="cpub-fed-btn-sm" :disabled="busyId === i.id" @click="setStatus(i, 'active')">Unhide</button>
|
|
97
|
+
<button class="cpub-fed-btn-sm cpub-fed-btn-danger" :disabled="busyId === i.id" @click="setStatus(i, i.status === 'blocked' ? 'active' : 'blocked')">
|
|
98
|
+
{{ i.status === 'blocked' ? 'Unblock' : 'Block' }}
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</template>
|
|
104
|
+
|
|
105
|
+
<style scoped>
|
|
106
|
+
.cpub-reg-dot {
|
|
107
|
+
display: inline-block; width: 8px; height: 8px; flex: 0 0 auto;
|
|
108
|
+
background: var(--text-faint); border: var(--border-width-default) solid var(--border);
|
|
109
|
+
}
|
|
110
|
+
.cpub-reg-dot.online { background: var(--accent); border-color: var(--accent); }
|
|
111
|
+
|
|
112
|
+
/* Shared federation control styles (repeated for this component's scope). */
|
|
113
|
+
.cpub-fed-explain { font-size: 0.8125rem; color: var(--text-dim); line-height: 1.6; margin-bottom: 12px; }
|
|
114
|
+
.cpub-fed-info-text { font-size: 0.8125rem; color: var(--text-dim); }
|
|
115
|
+
.cpub-fed-form { display: flex; gap: 8px; }
|
|
116
|
+
.cpub-fed-input { flex: 1; padding: 8px 12px; font-family: var(--font-mono); font-size: 0.8125rem; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); }
|
|
117
|
+
.cpub-fed-btn { padding: 8px 16px; font-family: var(--font-mono); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; cursor: pointer; background: var(--accent); color: var(--color-text-inverse); border: var(--border-width-default) solid var(--accent); box-shadow: var(--shadow-sm); }
|
|
118
|
+
.cpub-fed-btn:hover { box-shadow: none; transform: translate(2px, 2px); }
|
|
119
|
+
.cpub-fed-btn-sm { padding: 4px 10px; font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; cursor: pointer; background: transparent; border: var(--border-width-default) solid var(--border); color: var(--text-dim); }
|
|
120
|
+
.cpub-fed-btn-sm:hover { border-color: var(--accent); color: var(--accent); }
|
|
121
|
+
.cpub-fed-btn-sm:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
122
|
+
.cpub-fed-btn-danger { color: var(--red); border-color: var(--red); }
|
|
123
|
+
.cpub-fed-btn-danger:hover { border-color: var(--red); color: var(--red); background: var(--surface2); }
|
|
124
|
+
.cpub-fed-activity-list { display: flex; flex-direction: column; }
|
|
125
|
+
.cpub-fed-activity-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 8px 0; border-bottom: var(--border-width-default) solid var(--border); font-size: 0.8125rem; }
|
|
126
|
+
.cpub-fed-type { font-weight: 600; color: var(--text); }
|
|
127
|
+
.cpub-fed-actor { color: var(--text-dim); font-size: 0.75rem; flex: 1; min-width: 0; }
|
|
128
|
+
.cpub-fed-empty { color: var(--text-faint); padding: 16px 0; font-size: 0.8125rem; }
|
|
129
|
+
.cpub-fed-status { font-size: 10px; font-weight: 600; padding: 2px 6px; text-transform: uppercase; font-family: var(--font-mono); border: var(--border-width-default) solid var(--border); }
|
|
130
|
+
.cpub-fed-status.paused { color: var(--text-dim); background: var(--surface2); }
|
|
131
|
+
.cpub-fed-status.failed { color: var(--red); border-color: var(--red); }
|
|
132
|
+
</style>
|
|
@@ -40,6 +40,10 @@ export interface FeatureFlags {
|
|
|
40
40
|
* OFF. See docs/plans/rbac.md.
|
|
41
41
|
*/
|
|
42
42
|
rbac: boolean;
|
|
43
|
+
/** Act as an instance registry/directory (Phase 4). Default OFF. */
|
|
44
|
+
actAsRegistry: boolean;
|
|
45
|
+
/** Announce this instance to a registry (Phase 4). Default ON (discoverable). */
|
|
46
|
+
announceToRegistry: boolean;
|
|
43
47
|
/**
|
|
44
48
|
* Cross-instance delegated authorization. All sub-flags default false.
|
|
45
49
|
* Mirrors `@commonpub/config`'s `IdentityFeatures`. Phase 1b+ — see
|
|
@@ -62,6 +66,8 @@ export const DEFAULT_FLAGS: FeatureFlags = {
|
|
|
62
66
|
publicApi: false, contentImport: true,
|
|
63
67
|
layoutEngine: false,
|
|
64
68
|
rbac: false,
|
|
69
|
+
actAsRegistry: false,
|
|
70
|
+
announceToRegistry: true,
|
|
65
71
|
identity: {
|
|
66
72
|
linkRemoteAccounts: false,
|
|
67
73
|
signInWithRemote: false,
|
package/error.vue
CHANGED
|
@@ -9,11 +9,26 @@ const props = defineProps<{
|
|
|
9
9
|
|
|
10
10
|
useSeoMeta({ title: `${props.error.statusCode} — CommonPub` });
|
|
11
11
|
|
|
12
|
-
// Error pages render outside app.vue's NuxtLayout tree during SSR,
|
|
13
|
-
//
|
|
12
|
+
// Error pages render outside app.vue's NuxtLayout tree during SSR, so the theme
|
|
13
|
+
// plugin's useHead doesn't propagate here. Re-apply BOTH the data-theme attribute
|
|
14
|
+
// AND the custom-theme inline token CSS — otherwise a DB-stored custom theme renders
|
|
15
|
+
// with base tokens on error pages (the plugin sets these useState keys during SSR).
|
|
14
16
|
const themeId = useState<string>('cpub-theme', () => 'base');
|
|
17
|
+
const themeInlineCss = useState<string>('cpub-theme-inline-css', () => '');
|
|
18
|
+
const themeHead: Parameters<typeof useHead>[0] = {};
|
|
15
19
|
if (themeId.value && themeId.value !== 'base') {
|
|
16
|
-
|
|
20
|
+
themeHead.htmlAttrs = { 'data-theme': themeId.value };
|
|
21
|
+
}
|
|
22
|
+
if (themeInlineCss.value) {
|
|
23
|
+
themeHead.style = [{
|
|
24
|
+
key: 'cpub-theme-inline',
|
|
25
|
+
id: 'cpub-theme-inline',
|
|
26
|
+
innerHTML: themeInlineCss.value,
|
|
27
|
+
tagPosition: 'head',
|
|
28
|
+
}];
|
|
29
|
+
}
|
|
30
|
+
if (Object.keys(themeHead).length > 0) {
|
|
31
|
+
useHead(themeHead);
|
|
17
32
|
}
|
|
18
33
|
|
|
19
34
|
const isNotFound = computed(() => props.error.statusCode === 404);
|
package/nuxt.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -53,16 +53,16 @@
|
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
-
"@commonpub/auth": "0.
|
|
57
|
-
"@commonpub/config": "0.
|
|
56
|
+
"@commonpub/auth": "0.8.0",
|
|
57
|
+
"@commonpub/config": "0.18.0",
|
|
58
58
|
"@commonpub/docs": "0.6.3",
|
|
59
|
-
"@commonpub/protocol": "0.12.0",
|
|
60
59
|
"@commonpub/explainer": "0.7.15",
|
|
61
60
|
"@commonpub/editor": "0.7.11",
|
|
62
|
-
"@commonpub/
|
|
61
|
+
"@commonpub/protocol": "0.13.0",
|
|
62
|
+
"@commonpub/server": "2.73.0",
|
|
63
|
+
"@commonpub/ui": "0.9.2",
|
|
63
64
|
"@commonpub/learning": "0.5.2",
|
|
64
|
-
"@commonpub/
|
|
65
|
-
"@commonpub/ui": "0.9.2"
|
|
65
|
+
"@commonpub/schema": "0.26.0"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@testing-library/jest-dom": "^6.9.1",
|