@commonpub/layer 0.15.8 → 0.16.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/composables/useFeatures.ts +3 -0
- package/layouts/admin.vue +1 -0
- package/package.json +5 -5
- package/pages/admin/api-keys.vue +363 -0
- package/server/api/admin/api-keys/[id].delete.ts +11 -0
- package/server/api/admin/api-keys/index.get.ts +10 -0
- package/server/api/admin/api-keys/index.post.ts +22 -0
- package/server/api/public/v1/content/[slug].get.ts +35 -0
- package/server/api/public/v1/content/index.get.ts +58 -0
- package/server/api/public/v1/hubs/[slug].get.ts +16 -0
- package/server/api/public/v1/hubs/index.get.ts +45 -0
- package/server/api/public/v1/instance.get.ts +62 -0
- package/server/api/public/v1/users/[username].get.ts +38 -0
- package/server/api/public/v1/users/index.get.ts +60 -0
- package/server/middleware/public-api-auth.ts +93 -0
- package/server/utils/requireScope.ts +19 -0
|
@@ -16,6 +16,7 @@ export interface FeatureFlags {
|
|
|
16
16
|
federation: boolean;
|
|
17
17
|
admin: boolean;
|
|
18
18
|
emailNotifications: boolean;
|
|
19
|
+
publicApi: boolean;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
let hydrated = false;
|
|
@@ -29,6 +30,7 @@ export const DEFAULT_FLAGS: FeatureFlags = {
|
|
|
29
30
|
content: true, social: true, hubs: true, docs: true, video: true,
|
|
30
31
|
contests: false, events: false, learning: true, explainers: true,
|
|
31
32
|
editorial: true, federation: false, admin: false, emailNotifications: false,
|
|
33
|
+
publicApi: false,
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
/** Build the initial flags by merging the layer's runtime config over defaults. */
|
|
@@ -77,5 +79,6 @@ export function useFeatures() {
|
|
|
77
79
|
federation: computed(() => flags.value.federation),
|
|
78
80
|
admin: computed(() => flags.value.admin),
|
|
79
81
|
emailNotifications: computed(() => flags.value.emailNotifications),
|
|
82
|
+
publicApi: computed(() => flags.value.publicApi),
|
|
80
83
|
};
|
|
81
84
|
}
|
package/layouts/admin.vue
CHANGED
|
@@ -37,6 +37,7 @@ const sidebarOpen = ref(false);
|
|
|
37
37
|
<NuxtLink to="/admin/navigation" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-bars"></i> Navigation</NuxtLink>
|
|
38
38
|
<NuxtLink to="/admin/features" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-toggle-on"></i> Features</NuxtLink>
|
|
39
39
|
<NuxtLink to="/admin/federation" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-globe"></i> Federation</NuxtLink>
|
|
40
|
+
<NuxtLink to="/admin/api-keys" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-key"></i> API Keys</NuxtLink>
|
|
40
41
|
<NuxtLink to="/admin/settings" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-gear"></i> Settings</NuxtLink>
|
|
41
42
|
</nav>
|
|
42
43
|
</aside>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@aws-sdk/client-s3": "^3.1010.0",
|
|
31
31
|
"@commonpub/explainer": "^0.7.12",
|
|
32
|
-
"@commonpub/schema": "^0.
|
|
33
|
-
"@commonpub/server": "^2.
|
|
32
|
+
"@commonpub/schema": "^0.14.1",
|
|
33
|
+
"@commonpub/server": "^2.44.2",
|
|
34
34
|
"@tiptap/core": "^2.11.0",
|
|
35
35
|
"@tiptap/extension-bold": "^2.11.0",
|
|
36
36
|
"@tiptap/extension-bullet-list": "^2.11.0",
|
|
@@ -53,12 +53,12 @@
|
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
-
"@commonpub/config": "0.10.0",
|
|
57
56
|
"@commonpub/auth": "0.5.1",
|
|
57
|
+
"@commonpub/config": "0.11.0",
|
|
58
58
|
"@commonpub/editor": "0.7.9",
|
|
59
59
|
"@commonpub/learning": "0.5.0",
|
|
60
|
-
"@commonpub/protocol": "0.9.9",
|
|
61
60
|
"@commonpub/docs": "0.6.2",
|
|
61
|
+
"@commonpub/protocol": "0.9.9",
|
|
62
62
|
"@commonpub/ui": "0.8.5"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { AdminApiKeyView } from '@commonpub/server';
|
|
3
|
+
import { PUBLIC_API_SCOPES } from '@commonpub/schema';
|
|
4
|
+
|
|
5
|
+
definePageMeta({ layout: 'admin', middleware: 'auth' });
|
|
6
|
+
|
|
7
|
+
useSeoMeta({ title: `API Keys — Admin — ${useSiteName()}` });
|
|
8
|
+
|
|
9
|
+
interface KeyListResponse {
|
|
10
|
+
items: AdminApiKeyView[];
|
|
11
|
+
total: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CreateResponse {
|
|
15
|
+
key: AdminApiKeyView;
|
|
16
|
+
token: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const includeRevoked = ref(false);
|
|
20
|
+
const listUrl = computed(() => `/api/admin/api-keys${includeRevoked.value ? '?includeRevoked=true' : ''}`);
|
|
21
|
+
const { data, pending, refresh, error: listError } = await useFetch<KeyListResponse>(listUrl);
|
|
22
|
+
|
|
23
|
+
// Create-form state
|
|
24
|
+
const showCreate = ref(false);
|
|
25
|
+
const form = reactive({
|
|
26
|
+
name: '',
|
|
27
|
+
description: '',
|
|
28
|
+
scopes: [] as string[],
|
|
29
|
+
expiresAt: '',
|
|
30
|
+
rateLimitPerMinute: 60,
|
|
31
|
+
allowedOrigins: '',
|
|
32
|
+
});
|
|
33
|
+
const creating = ref(false);
|
|
34
|
+
const createError = ref('');
|
|
35
|
+
const createdKey = ref<CreateResponse | null>(null);
|
|
36
|
+
const copied = ref(false);
|
|
37
|
+
|
|
38
|
+
const availableScopes = PUBLIC_API_SCOPES.filter((s) => s !== 'read:*');
|
|
39
|
+
|
|
40
|
+
function toggleScope(scope: string): void {
|
|
41
|
+
const i = form.scopes.indexOf(scope);
|
|
42
|
+
if (i >= 0) form.scopes.splice(i, 1);
|
|
43
|
+
else form.scopes.push(scope);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resetForm(): void {
|
|
47
|
+
form.name = '';
|
|
48
|
+
form.description = '';
|
|
49
|
+
form.scopes = [];
|
|
50
|
+
form.expiresAt = '';
|
|
51
|
+
form.rateLimitPerMinute = 60;
|
|
52
|
+
form.allowedOrigins = '';
|
|
53
|
+
createError.value = '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function submitCreate(): Promise<void> {
|
|
57
|
+
createError.value = '';
|
|
58
|
+
if (!form.name.trim()) {
|
|
59
|
+
createError.value = 'Name is required';
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (form.scopes.length === 0) {
|
|
63
|
+
createError.value = 'Select at least one scope';
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
creating.value = true;
|
|
67
|
+
try {
|
|
68
|
+
const origins = form.allowedOrigins
|
|
69
|
+
.split(/[\s,]+/)
|
|
70
|
+
.map((o) => o.trim())
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
const body = {
|
|
73
|
+
name: form.name.trim(),
|
|
74
|
+
description: form.description.trim() || null,
|
|
75
|
+
scopes: form.scopes,
|
|
76
|
+
expiresAt: form.expiresAt || null,
|
|
77
|
+
rateLimitPerMinute: form.rateLimitPerMinute,
|
|
78
|
+
allowedOrigins: origins.length ? origins : null,
|
|
79
|
+
};
|
|
80
|
+
const result = await $fetch<CreateResponse>('/api/admin/api-keys', { method: 'POST', body });
|
|
81
|
+
createdKey.value = result;
|
|
82
|
+
showCreate.value = false;
|
|
83
|
+
resetForm();
|
|
84
|
+
await refresh();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
const e = err as { statusMessage?: string; data?: { message?: string } };
|
|
87
|
+
createError.value = e.data?.message || e.statusMessage || 'Failed to create key';
|
|
88
|
+
} finally {
|
|
89
|
+
creating.value = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function revoke(id: string, name: string): Promise<void> {
|
|
94
|
+
if (!confirm(`Revoke API key "${name}"? This cannot be undone.`)) return;
|
|
95
|
+
try {
|
|
96
|
+
await $fetch(`/api/admin/api-keys/${id}`, { method: 'DELETE' });
|
|
97
|
+
await refresh();
|
|
98
|
+
} catch {
|
|
99
|
+
// toast would go here
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function copyToken(): Promise<void> {
|
|
104
|
+
if (!createdKey.value) return;
|
|
105
|
+
try {
|
|
106
|
+
await navigator.clipboard.writeText(createdKey.value.token);
|
|
107
|
+
copied.value = true;
|
|
108
|
+
setTimeout(() => { copied.value = false; }, 2000);
|
|
109
|
+
} catch {
|
|
110
|
+
// clipboard may be blocked; user can still select the text
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function dismissCreated(): void {
|
|
115
|
+
createdKey.value = null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function fmtDate(iso: string | null): string {
|
|
119
|
+
if (!iso) return '—';
|
|
120
|
+
return new Date(iso).toLocaleString();
|
|
121
|
+
}
|
|
122
|
+
</script>
|
|
123
|
+
|
|
124
|
+
<template>
|
|
125
|
+
<div class="cpub-admin-keys">
|
|
126
|
+
<header class="cpub-admin-head">
|
|
127
|
+
<div>
|
|
128
|
+
<h1 class="cpub-admin-title">API Keys</h1>
|
|
129
|
+
<p class="cpub-admin-sub">
|
|
130
|
+
Bearer tokens for <code>/api/public/v1/*</code>. Each key is scoped and rate-limited;
|
|
131
|
+
the raw token is shown once at creation.
|
|
132
|
+
</p>
|
|
133
|
+
</div>
|
|
134
|
+
<div class="cpub-admin-actions">
|
|
135
|
+
<label class="cpub-checkbox-label">
|
|
136
|
+
<input type="checkbox" v-model="includeRevoked" @change="refresh()" />
|
|
137
|
+
Show revoked
|
|
138
|
+
</label>
|
|
139
|
+
<button class="cpub-btn cpub-btn-primary" @click="showCreate = true">
|
|
140
|
+
<i class="fa-solid fa-plus"></i> New key
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
</header>
|
|
144
|
+
|
|
145
|
+
<!-- One-time token reveal -->
|
|
146
|
+
<div v-if="createdKey" class="cpub-key-reveal" role="alert">
|
|
147
|
+
<div class="cpub-key-reveal-head">
|
|
148
|
+
<strong>Key created — copy it now.</strong>
|
|
149
|
+
<button class="cpub-btn-link" aria-label="Close" @click="dismissCreated">
|
|
150
|
+
<i class="fa-solid fa-xmark"></i>
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
<p class="cpub-key-reveal-warn">
|
|
154
|
+
This is the only time the full token will be displayed. Store it somewhere safe before
|
|
155
|
+
leaving this page — the server only keeps a hash.
|
|
156
|
+
</p>
|
|
157
|
+
<div class="cpub-key-reveal-value">
|
|
158
|
+
<code>{{ createdKey.token }}</code>
|
|
159
|
+
<button class="cpub-btn" @click="copyToken" :aria-label="copied ? 'Copied' : 'Copy to clipboard'">
|
|
160
|
+
<i :class="copied ? 'fa-solid fa-check' : 'fa-regular fa-copy'"></i>
|
|
161
|
+
{{ copied ? 'Copied' : 'Copy' }}
|
|
162
|
+
</button>
|
|
163
|
+
</div>
|
|
164
|
+
<div class="cpub-key-reveal-meta">
|
|
165
|
+
<span>Name: <strong>{{ createdKey.key.name }}</strong></span>
|
|
166
|
+
<span>Prefix: <code>{{ createdKey.key.prefix }}</code></span>
|
|
167
|
+
<span>Scopes: <code>{{ createdKey.key.scopes.join(', ') }}</code></span>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<!-- Create form -->
|
|
172
|
+
<div v-if="showCreate" class="cpub-key-form" role="dialog" aria-label="Create API key">
|
|
173
|
+
<div class="cpub-key-form-head">
|
|
174
|
+
<h2>New API Key</h2>
|
|
175
|
+
<button class="cpub-btn-link" aria-label="Cancel" @click="showCreate = false; resetForm()">
|
|
176
|
+
<i class="fa-solid fa-xmark"></i>
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
<div class="cpub-form-row">
|
|
180
|
+
<label for="key-name">Name</label>
|
|
181
|
+
<input id="key-name" v-model="form.name" class="cpub-input" placeholder="e.g. Analytics dashboard" required />
|
|
182
|
+
</div>
|
|
183
|
+
<div class="cpub-form-row">
|
|
184
|
+
<label for="key-desc">Description (optional)</label>
|
|
185
|
+
<textarea id="key-desc" v-model="form.description" class="cpub-input" rows="2"
|
|
186
|
+
placeholder="What is this key used for?" />
|
|
187
|
+
</div>
|
|
188
|
+
<div class="cpub-form-row">
|
|
189
|
+
<label>Scopes</label>
|
|
190
|
+
<div class="cpub-scope-grid">
|
|
191
|
+
<label v-for="scope in availableScopes" :key="scope" class="cpub-scope-chip">
|
|
192
|
+
<input type="checkbox" :checked="form.scopes.includes(scope)" @change="toggleScope(scope)" />
|
|
193
|
+
<code>{{ scope }}</code>
|
|
194
|
+
</label>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
<div class="cpub-form-row cpub-form-grid">
|
|
198
|
+
<div>
|
|
199
|
+
<label for="key-expires">Expires (optional)</label>
|
|
200
|
+
<input id="key-expires" v-model="form.expiresAt" type="datetime-local" class="cpub-input" />
|
|
201
|
+
</div>
|
|
202
|
+
<div>
|
|
203
|
+
<label for="key-rate">Rate limit (req/min)</label>
|
|
204
|
+
<input id="key-rate" v-model.number="form.rateLimitPerMinute" type="number" min="1" max="10000" class="cpub-input" />
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="cpub-form-row">
|
|
208
|
+
<label for="key-origins">Allowed CORS origins (comma or whitespace separated, optional)</label>
|
|
209
|
+
<input id="key-origins" v-model="form.allowedOrigins" class="cpub-input" placeholder="https://app.example.com" />
|
|
210
|
+
<small>Leave blank for server-to-server only (default, recommended).</small>
|
|
211
|
+
</div>
|
|
212
|
+
<p v-if="createError" class="cpub-form-error" role="alert">{{ createError }}</p>
|
|
213
|
+
<div class="cpub-form-actions">
|
|
214
|
+
<button class="cpub-btn" @click="showCreate = false; resetForm()" :disabled="creating">Cancel</button>
|
|
215
|
+
<button class="cpub-btn cpub-btn-primary" @click="submitCreate" :disabled="creating">
|
|
216
|
+
{{ creating ? 'Creating...' : 'Create key' }}
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<!-- List -->
|
|
222
|
+
<div v-if="pending" class="cpub-loading">Loading keys...</div>
|
|
223
|
+
<p v-else-if="listError" class="cpub-form-error">Failed to load keys.</p>
|
|
224
|
+
<p v-else-if="!data?.items?.length" class="cpub-empty">
|
|
225
|
+
No API keys yet. Create one to start consuming <code>/api/public/v1/*</code>.
|
|
226
|
+
</p>
|
|
227
|
+
<table v-else class="cpub-key-table" aria-label="API keys">
|
|
228
|
+
<thead>
|
|
229
|
+
<tr>
|
|
230
|
+
<th scope="col">Name</th>
|
|
231
|
+
<th scope="col">Prefix</th>
|
|
232
|
+
<th scope="col">Scopes</th>
|
|
233
|
+
<th scope="col">Last used</th>
|
|
234
|
+
<th scope="col">Created</th>
|
|
235
|
+
<th scope="col">Status</th>
|
|
236
|
+
<th scope="col"><span class="cpub-sr-only">Actions</span></th>
|
|
237
|
+
</tr>
|
|
238
|
+
</thead>
|
|
239
|
+
<tbody>
|
|
240
|
+
<tr v-for="k in data.items" :key="k.id" :class="{ 'cpub-key-revoked': !!k.revokedAt }">
|
|
241
|
+
<td>
|
|
242
|
+
<strong>{{ k.name }}</strong>
|
|
243
|
+
<div v-if="k.description" class="cpub-key-desc">{{ k.description }}</div>
|
|
244
|
+
</td>
|
|
245
|
+
<td><code>{{ k.prefix }}...</code></td>
|
|
246
|
+
<td>
|
|
247
|
+
<span v-for="s in k.scopes" :key="s" class="cpub-scope-tag">{{ s }}</span>
|
|
248
|
+
</td>
|
|
249
|
+
<td>{{ fmtDate(k.lastUsedAt) }}</td>
|
|
250
|
+
<td>{{ fmtDate(k.createdAt) }}</td>
|
|
251
|
+
<td>
|
|
252
|
+
<span v-if="k.revokedAt" class="cpub-key-badge cpub-key-badge-red">Revoked</span>
|
|
253
|
+
<span v-else-if="k.expiresAt && new Date(k.expiresAt) < new Date()" class="cpub-key-badge cpub-key-badge-yellow">Expired</span>
|
|
254
|
+
<span v-else class="cpub-key-badge cpub-key-badge-green">Active</span>
|
|
255
|
+
</td>
|
|
256
|
+
<td>
|
|
257
|
+
<button
|
|
258
|
+
v-if="!k.revokedAt"
|
|
259
|
+
class="cpub-btn-link cpub-btn-danger"
|
|
260
|
+
@click="revoke(k.id, k.name)"
|
|
261
|
+
:aria-label="`Revoke ${k.name}`"
|
|
262
|
+
>
|
|
263
|
+
Revoke
|
|
264
|
+
</button>
|
|
265
|
+
</td>
|
|
266
|
+
</tr>
|
|
267
|
+
</tbody>
|
|
268
|
+
</table>
|
|
269
|
+
</div>
|
|
270
|
+
</template>
|
|
271
|
+
|
|
272
|
+
<style scoped>
|
|
273
|
+
.cpub-admin-keys { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
|
274
|
+
|
|
275
|
+
.cpub-admin-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 24px; }
|
|
276
|
+
.cpub-admin-title { font-size: 22px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
|
|
277
|
+
.cpub-admin-sub { font-size: 13px; color: var(--text-dim); max-width: 620px; }
|
|
278
|
+
.cpub-admin-sub code { font-family: var(--font-mono); font-size: 12px; background: var(--surface2); padding: 1px 6px; }
|
|
279
|
+
|
|
280
|
+
.cpub-admin-actions { display: flex; gap: 10px; align-items: center; }
|
|
281
|
+
.cpub-checkbox-label { display: flex; gap: 6px; align-items: center; font-size: 12px; color: var(--text-dim); cursor: pointer; }
|
|
282
|
+
|
|
283
|
+
.cpub-key-reveal {
|
|
284
|
+
background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border);
|
|
285
|
+
padding: 16px 18px; margin-bottom: 20px;
|
|
286
|
+
}
|
|
287
|
+
.cpub-key-reveal-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
|
288
|
+
.cpub-key-reveal-warn { font-size: 12px; color: var(--text-dim); margin-bottom: 10px; }
|
|
289
|
+
.cpub-key-reveal-value {
|
|
290
|
+
display: flex; gap: 10px; align-items: center;
|
|
291
|
+
background: var(--surface); border: var(--border-width-default) solid var(--border);
|
|
292
|
+
padding: 10px; font-family: var(--font-mono); font-size: 13px;
|
|
293
|
+
}
|
|
294
|
+
.cpub-key-reveal-value code { flex: 1; overflow-wrap: break-word; word-break: break-all; }
|
|
295
|
+
.cpub-key-reveal-meta { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 12px; font-size: 12px; color: var(--text-dim); }
|
|
296
|
+
.cpub-key-reveal-meta code { font-family: var(--font-mono); }
|
|
297
|
+
|
|
298
|
+
.cpub-key-form {
|
|
299
|
+
background: var(--surface); border: var(--border-width-default) solid var(--border);
|
|
300
|
+
padding: 20px; margin-bottom: 24px; box-shadow: var(--shadow-md);
|
|
301
|
+
}
|
|
302
|
+
.cpub-key-form-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
|
303
|
+
.cpub-key-form-head h2 { font-size: 16px; font-weight: 600; color: var(--text); }
|
|
304
|
+
.cpub-form-row { margin-bottom: 14px; }
|
|
305
|
+
.cpub-form-row label { display: block; font-size: 12px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
|
|
306
|
+
.cpub-form-row small { font-size: 11px; color: var(--text-faint); display: block; margin-top: 2px; }
|
|
307
|
+
.cpub-input {
|
|
308
|
+
width: 100%; padding: 8px 10px; font-size: 13px;
|
|
309
|
+
background: var(--surface); color: var(--text);
|
|
310
|
+
border: var(--border-width-default) solid var(--border); font-family: inherit;
|
|
311
|
+
}
|
|
312
|
+
.cpub-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
313
|
+
.cpub-form-error { color: var(--red); font-size: 12px; margin: 8px 0; }
|
|
314
|
+
.cpub-form-actions { display: flex; justify-content: flex-end; gap: 10px; }
|
|
315
|
+
|
|
316
|
+
.cpub-scope-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 6px; }
|
|
317
|
+
.cpub-scope-chip {
|
|
318
|
+
display: flex; align-items: center; gap: 6px; padding: 6px 10px;
|
|
319
|
+
background: var(--surface2); border: var(--border-width-default) solid var(--border);
|
|
320
|
+
font-size: 12px; cursor: pointer;
|
|
321
|
+
}
|
|
322
|
+
.cpub-scope-chip code { font-family: var(--font-mono); font-size: 11px; color: var(--text); }
|
|
323
|
+
|
|
324
|
+
.cpub-btn {
|
|
325
|
+
padding: 6px 14px; font-size: 12px; font-weight: 500;
|
|
326
|
+
background: var(--surface); color: var(--text);
|
|
327
|
+
border: var(--border-width-default) solid var(--border);
|
|
328
|
+
cursor: pointer; font-family: inherit;
|
|
329
|
+
}
|
|
330
|
+
.cpub-btn:hover { box-shadow: var(--shadow-sm); }
|
|
331
|
+
.cpub-btn-primary { background: var(--accent); color: var(--color-text-inverse); border-color: var(--accent); }
|
|
332
|
+
.cpub-btn-link { background: none; border: none; color: var(--text-dim); cursor: pointer; padding: 4px 8px; font-size: 12px; }
|
|
333
|
+
.cpub-btn-link:hover { color: var(--text); }
|
|
334
|
+
.cpub-btn-danger { color: var(--red); }
|
|
335
|
+
|
|
336
|
+
.cpub-key-table {
|
|
337
|
+
width: 100%; border-collapse: collapse;
|
|
338
|
+
background: var(--surface); border: var(--border-width-default) solid var(--border);
|
|
339
|
+
}
|
|
340
|
+
.cpub-key-table th, .cpub-key-table td {
|
|
341
|
+
padding: 10px 12px; text-align: left; font-size: 12px;
|
|
342
|
+
border-bottom: var(--border-width-default) solid var(--border2);
|
|
343
|
+
}
|
|
344
|
+
.cpub-key-table th { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); background: var(--surface2); }
|
|
345
|
+
.cpub-key-desc { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
|
|
346
|
+
.cpub-key-revoked { opacity: 0.5; }
|
|
347
|
+
|
|
348
|
+
.cpub-scope-tag {
|
|
349
|
+
display: inline-block; padding: 2px 6px; margin: 1px;
|
|
350
|
+
background: var(--surface2); border: var(--border-width-default) solid var(--border);
|
|
351
|
+
font-family: var(--font-mono); font-size: 10px;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.cpub-key-badge { font-family: var(--font-mono); font-size: 10px; padding: 2px 8px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
355
|
+
.cpub-key-badge-green { background: var(--green-bg); color: var(--green); border: var(--border-width-default) solid var(--green); }
|
|
356
|
+
.cpub-key-badge-yellow { background: var(--yellow-bg); color: var(--yellow); border: var(--border-width-default) solid var(--yellow); }
|
|
357
|
+
.cpub-key-badge-red { background: var(--red-bg); color: var(--red); border: var(--border-width-default) solid var(--red); }
|
|
358
|
+
|
|
359
|
+
.cpub-loading { padding: 40px; text-align: center; color: var(--text-dim); }
|
|
360
|
+
.cpub-empty { padding: 40px; text-align: center; color: var(--text-dim); background: var(--surface); border: var(--border-width-default) solid var(--border); }
|
|
361
|
+
.cpub-empty code { font-family: var(--font-mono); background: var(--surface2); padding: 1px 6px; }
|
|
362
|
+
.cpub-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
|
363
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { revokeApiKey } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
const user = requireAdmin(event);
|
|
5
|
+
const id = getRouterParam(event, 'id');
|
|
6
|
+
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing id' });
|
|
7
|
+
const db = useDB();
|
|
8
|
+
const result = await revokeApiKey(db, id, user.id);
|
|
9
|
+
if (!result) throw createError({ statusCode: 404, statusMessage: 'Key not found or already revoked' });
|
|
10
|
+
return result;
|
|
11
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { listApiKeys } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
requireAdmin(event);
|
|
5
|
+
const query = getQuery(event);
|
|
6
|
+
const includeRevoked = query.includeRevoked === 'true' || query.includeRevoked === '1';
|
|
7
|
+
const db = useDB();
|
|
8
|
+
const items = await listApiKeys(db, { includeRevoked });
|
|
9
|
+
return { items, total: items.length };
|
|
10
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createApiKey } from '@commonpub/server';
|
|
2
|
+
import { createApiKeySchema } from '@commonpub/schema';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* POST /api/admin/api-keys
|
|
6
|
+
*
|
|
7
|
+
* Creates a new public API key. The full token is returned ONCE in the
|
|
8
|
+
* response body — the UI displays it with a "copy now, you won't see it
|
|
9
|
+
* again" warning. Server-side we only keep the SHA-256 hash.
|
|
10
|
+
*/
|
|
11
|
+
export default defineEventHandler(async (event) => {
|
|
12
|
+
const user = requireAdmin(event);
|
|
13
|
+
const body = await readBody(event);
|
|
14
|
+
const parsed = createApiKeySchema.safeParse(body);
|
|
15
|
+
if (!parsed.success) {
|
|
16
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid input', data: parsed.error.flatten() });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const db = useDB();
|
|
20
|
+
const result = await createApiKey(db, user.id, parsed.data);
|
|
21
|
+
return result;
|
|
22
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getContentBySlug, toPublicContentDetail, type PublicContentRow } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const querySchema = z.object({
|
|
5
|
+
author: z.string().max(128).optional(),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export default defineEventHandler(async (event) => {
|
|
9
|
+
requireApiScope(event, 'read:content');
|
|
10
|
+
const slug = getRouterParam(event, 'slug');
|
|
11
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
12
|
+
|
|
13
|
+
const parsed = querySchema.safeParse(getQuery(event));
|
|
14
|
+
if (!parsed.success) {
|
|
15
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters' });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const db = useDB();
|
|
19
|
+
const config = useConfig();
|
|
20
|
+
|
|
21
|
+
// Pass undefined requester — getContentBySlug returns null for any row where
|
|
22
|
+
// status !== 'published' and authorId !== requesterId, and it already
|
|
23
|
+
// excludes deleted rows via its own WHERE clause. So the only thing left
|
|
24
|
+
// to guard is visibility: public API only returns public-visibility content.
|
|
25
|
+
const content = await getContentBySlug(db, slug, undefined, parsed.data.author);
|
|
26
|
+
if (!content || content.status !== 'published') {
|
|
27
|
+
throw createError({ statusCode: 404, statusMessage: 'Content not found' });
|
|
28
|
+
}
|
|
29
|
+
const visibility = (content as { visibility?: string | null }).visibility;
|
|
30
|
+
if (visibility && visibility !== 'public') {
|
|
31
|
+
throw createError({ statusCode: 404, statusMessage: 'Content not found' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return toPublicContentDetail(content as unknown as PublicContentRow, config.instance.domain);
|
|
35
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { listContent, toPublicContentSummary, type PublicContentRow } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const querySchema = z.object({
|
|
5
|
+
type: z.enum(['project', 'blog', 'explainer']).optional(),
|
|
6
|
+
tag: z.string().max(80).optional(),
|
|
7
|
+
authorId: z.string().uuid().optional(),
|
|
8
|
+
categoryId: z.string().uuid().optional(),
|
|
9
|
+
difficulty: z.enum(['beginner', 'intermediate', 'advanced']).optional(),
|
|
10
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
11
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
12
|
+
sort: z.enum(['recent', 'popular', 'featured']).default('recent'),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export default defineEventHandler(async (event) => {
|
|
16
|
+
requireApiScope(event, 'read:content');
|
|
17
|
+
const db = useDB();
|
|
18
|
+
const config = useConfig();
|
|
19
|
+
const rawQuery = getQuery(event);
|
|
20
|
+
const parsed = querySchema.safeParse(rawQuery);
|
|
21
|
+
if (!parsed.success) {
|
|
22
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters', data: parsed.error.flatten() });
|
|
23
|
+
}
|
|
24
|
+
const filters = parsed.data;
|
|
25
|
+
|
|
26
|
+
// Hard-forced: only published + public + non-deleted content.
|
|
27
|
+
// These overrides mirror the internal /api/content hardening (session 127)
|
|
28
|
+
// but live here too so a future internal-API change can't accidentally
|
|
29
|
+
// relax the public-surface guarantees.
|
|
30
|
+
const result = await listContent(db, {
|
|
31
|
+
status: 'published',
|
|
32
|
+
visibility: 'public',
|
|
33
|
+
type: filters.type,
|
|
34
|
+
tag: filters.tag,
|
|
35
|
+
authorId: filters.authorId,
|
|
36
|
+
categoryId: filters.categoryId,
|
|
37
|
+
difficulty: filters.difficulty,
|
|
38
|
+
limit: filters.limit,
|
|
39
|
+
offset: filters.offset,
|
|
40
|
+
sort: filters.sort,
|
|
41
|
+
}, {
|
|
42
|
+
includeFederated: config.features.seamlessFederation,
|
|
43
|
+
allowedContentTypes: config.instance.contentTypes,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// listContent already filters out deletedAt / non-published; the forced
|
|
47
|
+
// status+visibility above belt-and-suspenders that. Serialize via
|
|
48
|
+
// allow-list so any new internal column stays internal.
|
|
49
|
+
const domain = config.instance.domain;
|
|
50
|
+
const items = result.items.map((row) => toPublicContentSummary(row as unknown as PublicContentRow, domain));
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
items,
|
|
54
|
+
total: result.total,
|
|
55
|
+
limit: filters.limit,
|
|
56
|
+
offset: filters.offset,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { getHubBySlug, toPublicHub, type PublicHubRow } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
requireApiScope(event, 'read:hubs');
|
|
5
|
+
const slug = getRouterParam(event, 'slug');
|
|
6
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
7
|
+
|
|
8
|
+
const db = useDB();
|
|
9
|
+
const config = useConfig();
|
|
10
|
+
const hub = await getHubBySlug(db, slug);
|
|
11
|
+
if (!hub || (hub as { deletedAt?: Date | null }).deletedAt) {
|
|
12
|
+
throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return toPublicHub(hub as unknown as PublicHubRow, config.instance.domain);
|
|
16
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { listHubs, toPublicHub, type PublicHubRow } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const querySchema = z.object({
|
|
5
|
+
type: z.enum(['community', 'product', 'company']).optional(),
|
|
6
|
+
search: z.string().max(80).optional(),
|
|
7
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
8
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export default defineEventHandler(async (event) => {
|
|
12
|
+
requireApiScope(event, 'read:hubs');
|
|
13
|
+
const db = useDB();
|
|
14
|
+
const config = useConfig();
|
|
15
|
+
|
|
16
|
+
const parsed = querySchema.safeParse(getQuery(event));
|
|
17
|
+
if (!parsed.success) {
|
|
18
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters', data: parsed.error.flatten() });
|
|
19
|
+
}
|
|
20
|
+
const filters = parsed.data;
|
|
21
|
+
|
|
22
|
+
// listHubs doesn't accept hubType as a filter today, so we over-fetch and
|
|
23
|
+
// narrow here. At current hub volume this is fine; if it grows the
|
|
24
|
+
// internal filter signature should be widened.
|
|
25
|
+
const result = await listHubs(db, {
|
|
26
|
+
search: filters.search,
|
|
27
|
+
limit: filters.limit,
|
|
28
|
+
offset: filters.offset,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const domain = config.instance.domain;
|
|
32
|
+
let items = (result.items as unknown as PublicHubRow[])
|
|
33
|
+
.filter((row) => row.deletedAt === null)
|
|
34
|
+
.map((row) => toPublicHub(row, domain));
|
|
35
|
+
if (filters.type) {
|
|
36
|
+
items = items.filter((h) => h.hubType === filters.type);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
items,
|
|
41
|
+
total: result.total,
|
|
42
|
+
limit: filters.limit,
|
|
43
|
+
offset: filters.offset,
|
|
44
|
+
};
|
|
45
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { PublicInstance } from '@commonpub/server';
|
|
2
|
+
import { contentItems, hubs, users } from '@commonpub/schema';
|
|
3
|
+
import { and, eq, isNull, sql } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
requireApiScope(event, 'read:instance');
|
|
7
|
+
const db = useDB();
|
|
8
|
+
const config = useConfig();
|
|
9
|
+
|
|
10
|
+
// Cheap aggregate counts. If these become hot we can swap to a cached
|
|
11
|
+
// materialized view — but at commonpub/deveco volume a per-request count
|
|
12
|
+
// is fine.
|
|
13
|
+
const [[userStats], [contentStats], [hubStats]] = await Promise.all([
|
|
14
|
+
db.select({
|
|
15
|
+
total: sql<number>`count(*)::int`,
|
|
16
|
+
activeMonth: sql<number>`count(*) FILTER (WHERE ${users.createdAt} > NOW() - INTERVAL '30 days')::int`,
|
|
17
|
+
}).from(users).where(and(isNull(users.deletedAt), eq(users.status, 'active'))),
|
|
18
|
+
db.select({ total: sql<number>`count(*)::int` })
|
|
19
|
+
.from(contentItems)
|
|
20
|
+
.where(and(eq(contentItems.status, 'published'), isNull(contentItems.deletedAt))),
|
|
21
|
+
db.select({ total: sql<number>`count(*)::int` })
|
|
22
|
+
.from(hubs)
|
|
23
|
+
.where(isNull(hubs.deletedAt)),
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const domain = config.instance.domain;
|
|
27
|
+
const features = config.features as unknown as Record<string, boolean>;
|
|
28
|
+
|
|
29
|
+
const response: PublicInstance = {
|
|
30
|
+
name: config.instance.name,
|
|
31
|
+
description: config.instance.description ?? null,
|
|
32
|
+
domain,
|
|
33
|
+
software: {
|
|
34
|
+
name: 'commonpub',
|
|
35
|
+
version: '1.0.0',
|
|
36
|
+
},
|
|
37
|
+
users: {
|
|
38
|
+
total: userStats?.total ?? 0,
|
|
39
|
+
activeMonth: userStats?.activeMonth ?? 0,
|
|
40
|
+
},
|
|
41
|
+
content: { total: contentStats?.total ?? 0 },
|
|
42
|
+
hubs: { total: hubStats?.total ?? 0 },
|
|
43
|
+
features: {
|
|
44
|
+
content: !!features.content,
|
|
45
|
+
hubs: !!features.hubs,
|
|
46
|
+
docs: !!features.docs,
|
|
47
|
+
video: !!features.video,
|
|
48
|
+
contests: !!features.contests,
|
|
49
|
+
events: !!features.events,
|
|
50
|
+
learning: !!features.learning,
|
|
51
|
+
explainers: !!features.explainers,
|
|
52
|
+
federation: !!features.federation,
|
|
53
|
+
},
|
|
54
|
+
openRegistrations: !!config.auth?.emailPassword,
|
|
55
|
+
links: {
|
|
56
|
+
nodeinfo: `https://${domain}/nodeinfo/2.1`,
|
|
57
|
+
webfinger: `https://${domain}/.well-known/webfinger`,
|
|
58
|
+
api: `https://${domain}/api/public/v1`,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
return response;
|
|
62
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { isPublicUser, toPublicUser, type PublicUserRow } from '@commonpub/server';
|
|
2
|
+
import { users } from '@commonpub/schema';
|
|
3
|
+
import { and, eq, isNull } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
requireApiScope(event, 'read:users');
|
|
7
|
+
const username = getRouterParam(event, 'username');
|
|
8
|
+
if (!username) throw createError({ statusCode: 400, statusMessage: 'Missing username' });
|
|
9
|
+
|
|
10
|
+
const db = useDB();
|
|
11
|
+
const [row] = await db
|
|
12
|
+
.select({
|
|
13
|
+
id: users.id,
|
|
14
|
+
username: users.username,
|
|
15
|
+
displayName: users.displayName,
|
|
16
|
+
headline: users.headline,
|
|
17
|
+
bio: users.bio,
|
|
18
|
+
avatarUrl: users.avatarUrl,
|
|
19
|
+
bannerUrl: users.bannerUrl,
|
|
20
|
+
pronouns: users.pronouns,
|
|
21
|
+
location: users.location,
|
|
22
|
+
website: users.website,
|
|
23
|
+
skills: users.skills,
|
|
24
|
+
socialLinks: users.socialLinks,
|
|
25
|
+
profileVisibility: users.profileVisibility,
|
|
26
|
+
createdAt: users.createdAt,
|
|
27
|
+
deletedAt: users.deletedAt,
|
|
28
|
+
})
|
|
29
|
+
.from(users)
|
|
30
|
+
.where(and(eq(users.username, username), isNull(users.deletedAt), eq(users.status, 'active')))
|
|
31
|
+
.limit(1);
|
|
32
|
+
|
|
33
|
+
if (!row || !isPublicUser(row as PublicUserRow)) {
|
|
34
|
+
throw createError({ statusCode: 404, statusMessage: 'User not found' });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return toPublicUser(row as PublicUserRow);
|
|
38
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { isPublicUser, toPublicUser, type PublicUserRow } from '@commonpub/server';
|
|
2
|
+
import { users } from '@commonpub/schema';
|
|
3
|
+
import { and, desc, eq, ilike, isNull, or, sql } from 'drizzle-orm';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
const querySchema = z.object({
|
|
7
|
+
q: z.string().max(80).optional(),
|
|
8
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
9
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const PUBLIC_FIELDS = {
|
|
13
|
+
id: users.id,
|
|
14
|
+
username: users.username,
|
|
15
|
+
displayName: users.displayName,
|
|
16
|
+
headline: users.headline,
|
|
17
|
+
bio: users.bio,
|
|
18
|
+
avatarUrl: users.avatarUrl,
|
|
19
|
+
bannerUrl: users.bannerUrl,
|
|
20
|
+
pronouns: users.pronouns,
|
|
21
|
+
location: users.location,
|
|
22
|
+
website: users.website,
|
|
23
|
+
skills: users.skills,
|
|
24
|
+
socialLinks: users.socialLinks,
|
|
25
|
+
profileVisibility: users.profileVisibility,
|
|
26
|
+
createdAt: users.createdAt,
|
|
27
|
+
deletedAt: users.deletedAt,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export default defineEventHandler(async (event) => {
|
|
31
|
+
requireApiScope(event, 'read:users');
|
|
32
|
+
const parsed = querySchema.safeParse(getQuery(event));
|
|
33
|
+
if (!parsed.success) {
|
|
34
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters', data: parsed.error.flatten() });
|
|
35
|
+
}
|
|
36
|
+
const { q, limit, offset } = parsed.data;
|
|
37
|
+
|
|
38
|
+
const db = useDB();
|
|
39
|
+
const conds = [
|
|
40
|
+
isNull(users.deletedAt),
|
|
41
|
+
eq(users.profileVisibility, 'public'),
|
|
42
|
+
eq(users.status, 'active'),
|
|
43
|
+
];
|
|
44
|
+
if (q) {
|
|
45
|
+
const pattern = `%${q.replace(/[%_]/g, (c) => `\\${c}`)}%`;
|
|
46
|
+
conds.push(or(ilike(users.username, pattern), ilike(users.displayName, pattern))!);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const [rows, totalRow] = await Promise.all([
|
|
50
|
+
db.select(PUBLIC_FIELDS).from(users).where(and(...conds)).orderBy(desc(users.createdAt)).limit(limit).offset(offset),
|
|
51
|
+
db.select({ count: sql<number>`count(*)::int` }).from(users).where(and(...conds)),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const items = rows
|
|
55
|
+
.filter((r) => isPublicUser(r as PublicUserRow))
|
|
56
|
+
.map((r) => toPublicUser(r as PublicUserRow));
|
|
57
|
+
const total = totalRow[0]?.count ?? 0;
|
|
58
|
+
|
|
59
|
+
return { items, total, limit, offset };
|
|
60
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
apiKeyRateLimit,
|
|
3
|
+
authenticateApiKey,
|
|
4
|
+
logApiKeyUsage,
|
|
5
|
+
touchLastUsed,
|
|
6
|
+
type ApiKey,
|
|
7
|
+
} from '@commonpub/server';
|
|
8
|
+
|
|
9
|
+
declare module 'h3' {
|
|
10
|
+
interface H3EventContext {
|
|
11
|
+
apiKey?: ApiKey;
|
|
12
|
+
apiScopes?: string[];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Authenticate Bearer-token requests to `/api/public/v1/*`.
|
|
18
|
+
*
|
|
19
|
+
* Returns 404 (not 401) when the feature flag is off — we don't want to leak
|
|
20
|
+
* even the existence of the API surface on instances that haven't opted in.
|
|
21
|
+
*
|
|
22
|
+
* All lookup-failure reasons (missing, malformed, not_found) map to a single
|
|
23
|
+
* 401 response with a generic `Invalid API key` message. Expired keys get
|
|
24
|
+
* their own response so a legitimate consumer knows to rotate — that
|
|
25
|
+
* distinction is safe because the key clearly existed.
|
|
26
|
+
*/
|
|
27
|
+
export default defineEventHandler(async (event) => {
|
|
28
|
+
const path = getRequestURL(event).pathname;
|
|
29
|
+
if (!path.startsWith('/api/public/')) return;
|
|
30
|
+
|
|
31
|
+
const config = useConfig();
|
|
32
|
+
if (!config.features.publicApi) {
|
|
33
|
+
throw createError({ statusCode: 404, statusMessage: 'Not Found' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const authHeader = getRequestHeader(event, 'authorization');
|
|
37
|
+
const token = authHeader?.startsWith('Bearer ')
|
|
38
|
+
? authHeader.slice(7).trim()
|
|
39
|
+
: undefined;
|
|
40
|
+
|
|
41
|
+
const db = useDB();
|
|
42
|
+
const result = await authenticateApiKey(db, token);
|
|
43
|
+
|
|
44
|
+
if (!result.ok) {
|
|
45
|
+
if (result.reason === 'expired') {
|
|
46
|
+
throw createError({ statusCode: 401, statusMessage: 'API key expired' });
|
|
47
|
+
}
|
|
48
|
+
throw createError({
|
|
49
|
+
statusCode: 401,
|
|
50
|
+
statusMessage: 'Invalid API key. Use Authorization: Bearer <key>.',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const { key } = result;
|
|
55
|
+
|
|
56
|
+
// Per-key rate limit (separate store from IP-based rate limit so a noisy
|
|
57
|
+
// public-API consumer can't DoS the web UI for their own home IP).
|
|
58
|
+
const rl = apiKeyRateLimit.check(key.id, key.rateLimitPerMinute);
|
|
59
|
+
setResponseHeader(event, 'X-RateLimit-Limit', String(rl.limit));
|
|
60
|
+
setResponseHeader(event, 'X-RateLimit-Remaining', String(rl.remaining));
|
|
61
|
+
setResponseHeader(event, 'X-RateLimit-Reset', String(rl.resetAt));
|
|
62
|
+
if (!rl.allowed) {
|
|
63
|
+
throw createError({ statusCode: 429, statusMessage: 'Rate limit exceeded' });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Per-key CORS allow-list. `null` means server-to-server only (no CORS
|
|
67
|
+
// headers, so browser cross-origin calls are blocked by the browser).
|
|
68
|
+
if (key.allowedOrigins && key.allowedOrigins.length > 0) {
|
|
69
|
+
const origin = getRequestHeader(event, 'origin');
|
|
70
|
+
if (origin && key.allowedOrigins.includes(origin)) {
|
|
71
|
+
setResponseHeader(event, 'Access-Control-Allow-Origin', origin);
|
|
72
|
+
setResponseHeader(event, 'Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
73
|
+
setResponseHeader(event, 'Access-Control-Allow-Headers', 'Authorization, Content-Type');
|
|
74
|
+
appendResponseHeader(event, 'Vary', 'Origin');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
event.context.apiKey = key;
|
|
79
|
+
event.context.apiScopes = key.scopes;
|
|
80
|
+
|
|
81
|
+
// Fire-and-forget debounced touch. Any DB error is swallowed — logging is
|
|
82
|
+
// best-effort and must never fail a request.
|
|
83
|
+
touchLastUsed(db, key.id).catch(() => {});
|
|
84
|
+
|
|
85
|
+
// Log on response finish (statusCode + latency known only then).
|
|
86
|
+
const start = Date.now();
|
|
87
|
+
event.node.res.on('finish', () => {
|
|
88
|
+
const statusCode = event.node.res.statusCode;
|
|
89
|
+
const latencyMs = Date.now() - start;
|
|
90
|
+
logApiKeyUsage(db, { keyId: key.id, endpoint: path, method: event.method, statusCode, latencyMs })
|
|
91
|
+
.catch(() => {});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { hasScope } from '@commonpub/server';
|
|
2
|
+
import type { PublicApiScope } from '@commonpub/schema';
|
|
3
|
+
import type { H3Event } from 'h3';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Enforce a single scope on a public-API endpoint. Throws 403 if the key
|
|
7
|
+
* doesn't hold it (or the wildcard). Throws 401 if the event isn't an
|
|
8
|
+
* API-key request at all — that shouldn't happen because the middleware
|
|
9
|
+
* runs first, but defending against misconfiguration is cheap.
|
|
10
|
+
*/
|
|
11
|
+
export function requireApiScope(event: H3Event, needed: PublicApiScope): void {
|
|
12
|
+
const scopes = event.context.apiScopes;
|
|
13
|
+
if (!scopes) {
|
|
14
|
+
throw createError({ statusCode: 401, statusMessage: 'Missing API key' });
|
|
15
|
+
}
|
|
16
|
+
if (!hasScope(scopes, needed)) {
|
|
17
|
+
throw createError({ statusCode: 403, statusMessage: `Missing scope: ${needed}` });
|
|
18
|
+
}
|
|
19
|
+
}
|