@commonpub/layer 0.16.2 → 0.18.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/package.json +6 -6
- package/pages/admin/api-keys.vue +96 -10
- package/server/api/admin/api-keys/[id]/usage.get.ts +20 -0
- package/server/api/learn/[slug]/[lessonSlug]/complete.post.ts +18 -2
- package/server/api/learn/[slug]/[lessonSlug]/index.get.ts +11 -1
- package/server/api/public/v1/contests/[slug].get.ts +15 -0
- package/server/api/public/v1/contests/index.get.ts +26 -0
- package/server/api/public/v1/docs/[slug].get.ts +15 -0
- package/server/api/public/v1/docs/index.get.ts +25 -0
- package/server/api/public/v1/events/[slug].get.ts +15 -0
- package/server/api/public/v1/events/index.get.ts +43 -0
- package/server/api/public/v1/learn/[slug].get.ts +15 -0
- package/server/api/public/v1/learn/index.get.ts +31 -0
- package/server/api/public/v1/openapi.json.get.ts +372 -0
- package/server/api/public/v1/search/index.get.ts +26 -0
- package/server/api/public/v1/tags/index.get.ts +39 -0
- package/server/api/public/v1/videos/[id].get.ts +18 -0
- package/server/api/public/v1/videos/index.get.ts +27 -0
- package/server/api/realtime/stream.get.ts +57 -10
- package/server/middleware/public-api-auth.ts +1 -1
- package/server/middleware/security.ts +6 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
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.14.
|
|
33
|
-
"@commonpub/server": "^2.
|
|
32
|
+
"@commonpub/schema": "^0.14.3",
|
|
33
|
+
"@commonpub/server": "^2.47.0",
|
|
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,11 +53,11 @@
|
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
-
"@commonpub/config": "0.11.0",
|
|
57
|
-
"@commonpub/docs": "0.6.2",
|
|
58
56
|
"@commonpub/auth": "0.5.1",
|
|
57
|
+
"@commonpub/docs": "0.6.2",
|
|
58
|
+
"@commonpub/config": "0.11.0",
|
|
59
|
+
"@commonpub/learning": "0.5.1",
|
|
59
60
|
"@commonpub/editor": "0.7.9",
|
|
60
|
-
"@commonpub/learning": "0.5.0",
|
|
61
61
|
"@commonpub/protocol": "0.9.9",
|
|
62
62
|
"@commonpub/ui": "0.8.5"
|
|
63
63
|
},
|
package/pages/admin/api-keys.vue
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { AdminApiKeyView } from '@commonpub/server';
|
|
2
|
+
import type { AdminApiKeyView, ApiKeyUsageStats } from '@commonpub/server';
|
|
3
3
|
import { PUBLIC_API_SCOPES } from '@commonpub/schema';
|
|
4
4
|
|
|
5
5
|
definePageMeta({ layout: 'admin', middleware: 'auth' });
|
|
@@ -119,6 +119,28 @@ function fmtDate(iso: string | null): string {
|
|
|
119
119
|
if (!iso) return '—';
|
|
120
120
|
return new Date(iso).toLocaleString();
|
|
121
121
|
}
|
|
122
|
+
|
|
123
|
+
// Usage panel per key — lazy-loaded when the admin expands a row.
|
|
124
|
+
const usageOpen = ref<Record<string, boolean>>({});
|
|
125
|
+
const usageCache = ref<Record<string, ApiKeyUsageStats | 'loading' | 'error'>>({});
|
|
126
|
+
|
|
127
|
+
async function toggleUsage(keyId: string): Promise<void> {
|
|
128
|
+
usageOpen.value[keyId] = !usageOpen.value[keyId];
|
|
129
|
+
if (usageOpen.value[keyId] && !usageCache.value[keyId]) {
|
|
130
|
+
usageCache.value[keyId] = 'loading';
|
|
131
|
+
try {
|
|
132
|
+
const stats = await $fetch<ApiKeyUsageStats>(`/api/admin/api-keys/${keyId}/usage`);
|
|
133
|
+
usageCache.value[keyId] = stats;
|
|
134
|
+
} catch {
|
|
135
|
+
usageCache.value[keyId] = 'error';
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function fmtErrorRate(rate: number): string {
|
|
141
|
+
if (rate === 0) return '0%';
|
|
142
|
+
return `${(rate * 100).toFixed(1)}%`;
|
|
143
|
+
}
|
|
122
144
|
</script>
|
|
123
145
|
|
|
124
146
|
<template>
|
|
@@ -237,7 +259,8 @@ function fmtDate(iso: string | null): string {
|
|
|
237
259
|
</tr>
|
|
238
260
|
</thead>
|
|
239
261
|
<tbody>
|
|
240
|
-
<
|
|
262
|
+
<template v-for="k in data.items" :key="k.id">
|
|
263
|
+
<tr :class="{ 'cpub-key-revoked': !!k.revokedAt }">
|
|
241
264
|
<td>
|
|
242
265
|
<strong>{{ k.name }}</strong>
|
|
243
266
|
<div v-if="k.description" class="cpub-key-desc">{{ k.description }}</div>
|
|
@@ -254,16 +277,66 @@ function fmtDate(iso: string | null): string {
|
|
|
254
277
|
<span v-else class="cpub-key-badge cpub-key-badge-green">Active</span>
|
|
255
278
|
</td>
|
|
256
279
|
<td>
|
|
257
|
-
<
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
280
|
+
<div class="cpub-key-actions">
|
|
281
|
+
<button
|
|
282
|
+
class="cpub-btn-link"
|
|
283
|
+
:aria-expanded="!!usageOpen[k.id]"
|
|
284
|
+
:aria-label="`Toggle usage for ${k.name}`"
|
|
285
|
+
@click="toggleUsage(k.id)"
|
|
286
|
+
>
|
|
287
|
+
{{ usageOpen[k.id] ? 'Hide usage' : 'Usage' }}
|
|
288
|
+
</button>
|
|
289
|
+
<button
|
|
290
|
+
v-if="!k.revokedAt"
|
|
291
|
+
class="cpub-btn-link cpub-btn-danger"
|
|
292
|
+
@click="revoke(k.id, k.name)"
|
|
293
|
+
:aria-label="`Revoke ${k.name}`"
|
|
294
|
+
>
|
|
295
|
+
Revoke
|
|
296
|
+
</button>
|
|
297
|
+
</div>
|
|
298
|
+
</td>
|
|
299
|
+
</tr>
|
|
300
|
+
<tr v-if="usageOpen[k.id]" class="cpub-key-usage-row">
|
|
301
|
+
<td colspan="7">
|
|
302
|
+
<div v-if="usageCache[k.id] === 'loading'" class="cpub-loading">Loading usage...</div>
|
|
303
|
+
<p v-else-if="usageCache[k.id] === 'error'" class="cpub-form-error">Failed to load usage.</p>
|
|
304
|
+
<div v-else-if="usageCache[k.id] && typeof usageCache[k.id] === 'object'" class="cpub-usage-grid">
|
|
305
|
+
<div class="cpub-usage-stat">
|
|
306
|
+
<span class="cpub-usage-stat-label">Requests (last {{ (usageCache[k.id] as ApiKeyUsageStats).windowDays }}d)</span>
|
|
307
|
+
<strong>{{ (usageCache[k.id] as ApiKeyUsageStats).totalRequests.toLocaleString() }}</strong>
|
|
308
|
+
</div>
|
|
309
|
+
<div class="cpub-usage-stat">
|
|
310
|
+
<span class="cpub-usage-stat-label">Errors</span>
|
|
311
|
+
<strong>{{ (usageCache[k.id] as ApiKeyUsageStats).errorCount }} ({{ fmtErrorRate((usageCache[k.id] as ApiKeyUsageStats).errorRate) }})</strong>
|
|
312
|
+
</div>
|
|
313
|
+
<div class="cpub-usage-by-day">
|
|
314
|
+
<span class="cpub-usage-stat-label">By day</span>
|
|
315
|
+
<ul>
|
|
316
|
+
<li v-for="r in (usageCache[k.id] as ApiKeyUsageStats).requestsByDay" :key="r.day">
|
|
317
|
+
<code>{{ r.day }}</code> {{ r.count }}
|
|
318
|
+
</li>
|
|
319
|
+
</ul>
|
|
320
|
+
</div>
|
|
321
|
+
<div class="cpub-usage-top">
|
|
322
|
+
<span class="cpub-usage-stat-label">Top endpoints</span>
|
|
323
|
+
<table class="cpub-usage-table">
|
|
324
|
+
<thead>
|
|
325
|
+
<tr><th scope="col">Endpoint</th><th scope="col">Count</th><th scope="col">p95 ms</th></tr>
|
|
326
|
+
</thead>
|
|
327
|
+
<tbody>
|
|
328
|
+
<tr v-for="e in (usageCache[k.id] as ApiKeyUsageStats).topEndpoints" :key="e.endpoint">
|
|
329
|
+
<td><code>{{ e.endpoint }}</code></td>
|
|
330
|
+
<td>{{ e.count }}</td>
|
|
331
|
+
<td>{{ e.p95LatencyMs ?? '—' }}</td>
|
|
332
|
+
</tr>
|
|
333
|
+
</tbody>
|
|
334
|
+
</table>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
265
337
|
</td>
|
|
266
338
|
</tr>
|
|
339
|
+
</template>
|
|
267
340
|
</tbody>
|
|
268
341
|
</table>
|
|
269
342
|
</div>
|
|
@@ -356,6 +429,19 @@ function fmtDate(iso: string | null): string {
|
|
|
356
429
|
.cpub-key-badge-yellow { background: var(--yellow-bg); color: var(--yellow); border: var(--border-width-default) solid var(--yellow); }
|
|
357
430
|
.cpub-key-badge-red { background: var(--red-bg); color: var(--red); border: var(--border-width-default) solid var(--red); }
|
|
358
431
|
|
|
432
|
+
.cpub-key-actions { display: flex; gap: 8px; }
|
|
433
|
+
.cpub-key-usage-row td { background: var(--surface2); padding: 16px; }
|
|
434
|
+
.cpub-usage-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
|
|
435
|
+
.cpub-usage-stat { display: flex; flex-direction: column; gap: 4px; }
|
|
436
|
+
.cpub-usage-stat strong { font-size: 18px; color: var(--text); font-family: var(--font-mono); }
|
|
437
|
+
.cpub-usage-stat-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); }
|
|
438
|
+
.cpub-usage-by-day ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--text-dim); }
|
|
439
|
+
.cpub-usage-by-day code { font-family: var(--font-mono); color: var(--text); }
|
|
440
|
+
.cpub-usage-top { grid-column: 1 / -1; }
|
|
441
|
+
.cpub-usage-table { width: 100%; border-collapse: collapse; margin-top: 6px; }
|
|
442
|
+
.cpub-usage-table th, .cpub-usage-table td { padding: 4px 8px; text-align: left; font-size: 11px; border-bottom: var(--border-width-default) solid var(--border2); }
|
|
443
|
+
.cpub-usage-table code { font-family: var(--font-mono); font-size: 10px; }
|
|
444
|
+
|
|
359
445
|
.cpub-loading { padding: 40px; text-align: center; color: var(--text-dim); }
|
|
360
446
|
.cpub-empty { padding: 40px; text-align: center; color: var(--text-dim); background: var(--surface); border: var(--border-width-default) solid var(--border); }
|
|
361
447
|
.cpub-empty code { font-family: var(--font-mono); background: var(--surface2); padding: 1px 6px; }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getApiKeyById, getApiKeyUsageStats } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const querySchema = z.object({
|
|
5
|
+
windowDays: z.coerce.number().int().min(1).max(90).default(7),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export default defineEventHandler(async (event) => {
|
|
9
|
+
requireAdmin(event);
|
|
10
|
+
const id = getRouterParam(event, 'id');
|
|
11
|
+
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing id' });
|
|
12
|
+
const parsed = querySchema.safeParse(getQuery(event));
|
|
13
|
+
if (!parsed.success) {
|
|
14
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters' });
|
|
15
|
+
}
|
|
16
|
+
const db = useDB();
|
|
17
|
+
const key = await getApiKeyById(db, id);
|
|
18
|
+
if (!key) throw createError({ statusCode: 404, statusMessage: 'Key not found' });
|
|
19
|
+
return getApiKeyUsageStats(db, id, parsed.data.windowDays);
|
|
20
|
+
});
|
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
import { getLessonBySlug, markLessonComplete } from '@commonpub/server';
|
|
2
|
+
import { completeLessonSchema } from '@commonpub/learning';
|
|
2
3
|
|
|
3
4
|
export default defineEventHandler(async (event) => {
|
|
4
5
|
const user = requireAuth(event);
|
|
5
6
|
const db = useDB();
|
|
6
7
|
const { slug, lessonSlug } = parseParams(event, { slug: 'string', lessonSlug: 'string' });
|
|
7
|
-
|
|
8
|
+
|
|
9
|
+
// Validate body. Strip any client-supplied quizScore/quizPassed (the schema
|
|
10
|
+
// is `.strict()` and only whitelists `answers`) — server is the source of
|
|
11
|
+
// truth for whether a quiz passed. Accept empty body for non-quiz lessons.
|
|
12
|
+
const input = await parseBody(event, completeLessonSchema);
|
|
8
13
|
|
|
9
14
|
const result = await getLessonBySlug(db, slug, lessonSlug);
|
|
10
15
|
if (!result) throw createError({ statusCode: 404, statusMessage: 'Lesson not found' });
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
try {
|
|
18
|
+
return await markLessonComplete(db, user.id, result.lesson.id, input.answers);
|
|
19
|
+
} catch (err: unknown) {
|
|
20
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
21
|
+
if (msg.includes('Quiz lessons require answers')) {
|
|
22
|
+
throw createError({ statusCode: 400, statusMessage: msg });
|
|
23
|
+
}
|
|
24
|
+
if (msg.includes('Not enrolled')) {
|
|
25
|
+
throw createError({ statusCode: 403, statusMessage: msg });
|
|
26
|
+
}
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
13
29
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getLessonBySlug } from '@commonpub/server';
|
|
2
2
|
import { renderMarkdown } from '@commonpub/docs';
|
|
3
|
+
import { redactQuizAnswers } from '@commonpub/learning';
|
|
3
4
|
|
|
4
5
|
function blocksToHtml(blocks: unknown): string {
|
|
5
6
|
if (!Array.isArray(blocks)) return '';
|
|
@@ -39,6 +40,7 @@ function blocksToHtml(blocks: unknown): string {
|
|
|
39
40
|
export default defineEventHandler(async (event) => {
|
|
40
41
|
const db = useDB();
|
|
41
42
|
const { slug, lessonSlug } = parseParams(event, { slug: 'string', lessonSlug: 'string' });
|
|
43
|
+
const user = getOptionalUser(event);
|
|
42
44
|
|
|
43
45
|
const result = await getLessonBySlug(db, slug, lessonSlug);
|
|
44
46
|
if (!result) {
|
|
@@ -64,5 +66,13 @@ export default defineEventHandler(async (event) => {
|
|
|
64
66
|
}
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
|
|
69
|
+
// Quiz lessons: strip `correctOptionId` + `explanation` from each question
|
|
70
|
+
// unless the caller is the path author. Without this, anyone enrolled (or
|
|
71
|
+
// anonymous) could fetch the answer key directly from the lesson content.
|
|
72
|
+
const isAuthor = !!user && user.id === result.pathAuthorId;
|
|
73
|
+
const safeLesson = isAuthor
|
|
74
|
+
? result.lesson
|
|
75
|
+
: { ...result.lesson, content: redactQuizAnswers(result.lesson.content as Record<string, unknown>) };
|
|
76
|
+
|
|
77
|
+
return { ...result, lesson: safeLesson, renderedHtml };
|
|
68
78
|
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getContestBySlug, toPublicContest, isPublicContest, type PublicContestRow } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
requireApiScope(event, 'read:contests');
|
|
5
|
+
requireFeature('contests');
|
|
6
|
+
const slug = getRouterParam(event, 'slug');
|
|
7
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
8
|
+
const db = useDB();
|
|
9
|
+
const config = useConfig();
|
|
10
|
+
const row = await getContestBySlug(db, slug);
|
|
11
|
+
if (!row) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
12
|
+
const casted = row as unknown as PublicContestRow;
|
|
13
|
+
if (!isPublicContest(casted)) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
14
|
+
return toPublicContest(casted, config.instance.domain);
|
|
15
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { listContests, toPublicContest, isPublicContest, type PublicContestRow } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const querySchema = z.object({
|
|
5
|
+
status: z.enum(['upcoming', 'active', 'judging', 'completed']).optional(),
|
|
6
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
7
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export default defineEventHandler(async (event) => {
|
|
11
|
+
requireApiScope(event, 'read:contests');
|
|
12
|
+
requireFeature('contests');
|
|
13
|
+
const parsed = querySchema.safeParse(getQuery(event));
|
|
14
|
+
if (!parsed.success) {
|
|
15
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters', data: parsed.error.flatten() });
|
|
16
|
+
}
|
|
17
|
+
const filters = parsed.data;
|
|
18
|
+
const db = useDB();
|
|
19
|
+
const config = useConfig();
|
|
20
|
+
const result = await listContests(db, filters);
|
|
21
|
+
const domain = config.instance.domain;
|
|
22
|
+
const items = (result.items as unknown as PublicContestRow[])
|
|
23
|
+
.filter(isPublicContest)
|
|
24
|
+
.map((r) => toPublicContest(r, domain));
|
|
25
|
+
return { items, total: result.total, limit: filters.limit, offset: filters.offset };
|
|
26
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getDocsSiteBySlug, toPublicDocSite, isPublicDocSite, type PublicDocSiteRow } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
requireApiScope(event, 'read:docs');
|
|
5
|
+
requireFeature('docs');
|
|
6
|
+
const slug = getRouterParam(event, 'slug');
|
|
7
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
8
|
+
const db = useDB();
|
|
9
|
+
const config = useConfig();
|
|
10
|
+
const row = await getDocsSiteBySlug(db, slug);
|
|
11
|
+
if (!row) throw createError({ statusCode: 404, statusMessage: 'Docs site not found' });
|
|
12
|
+
const casted = row as unknown as PublicDocSiteRow;
|
|
13
|
+
if (!isPublicDocSite(casted)) throw createError({ statusCode: 404, statusMessage: 'Docs site not found' });
|
|
14
|
+
return toPublicDocSite(casted, config.instance.domain);
|
|
15
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { listDocsSites, toPublicDocSite, isPublicDocSite, type PublicDocSiteRow } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const querySchema = z.object({
|
|
5
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
6
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export default defineEventHandler(async (event) => {
|
|
10
|
+
requireApiScope(event, 'read:docs');
|
|
11
|
+
requireFeature('docs');
|
|
12
|
+
const parsed = querySchema.safeParse(getQuery(event));
|
|
13
|
+
if (!parsed.success) {
|
|
14
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters', data: parsed.error.flatten() });
|
|
15
|
+
}
|
|
16
|
+
const { limit, offset } = parsed.data;
|
|
17
|
+
const db = useDB();
|
|
18
|
+
const config = useConfig();
|
|
19
|
+
const result = await listDocsSites(db, { limit, offset });
|
|
20
|
+
const domain = config.instance.domain;
|
|
21
|
+
const items = (result.items as unknown as PublicDocSiteRow[])
|
|
22
|
+
.filter(isPublicDocSite)
|
|
23
|
+
.map((r) => toPublicDocSite(r, domain));
|
|
24
|
+
return { items, total: result.total, limit, offset };
|
|
25
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getEventBySlug, toPublicEvent, isPublicEvent, type PublicEventRow } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
requireApiScope(event, 'read:events');
|
|
5
|
+
requireFeature('events');
|
|
6
|
+
const slug = getRouterParam(event, 'slug');
|
|
7
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
8
|
+
const db = useDB();
|
|
9
|
+
const config = useConfig();
|
|
10
|
+
const row = await getEventBySlug(db, slug);
|
|
11
|
+
if (!row) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
|
|
12
|
+
const casted = row as unknown as PublicEventRow;
|
|
13
|
+
if (!isPublicEvent(casted)) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
|
|
14
|
+
return toPublicEvent(casted, config.instance.domain);
|
|
15
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { listEvents, toPublicEvent, type PublicEventRow } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const PUBLIC_STATUSES = new Set(['published', 'active', 'completed']);
|
|
5
|
+
|
|
6
|
+
const querySchema = z.object({
|
|
7
|
+
status: z.string().optional(),
|
|
8
|
+
hubId: z.string().uuid().optional(),
|
|
9
|
+
upcoming: z.coerce.boolean().optional(),
|
|
10
|
+
featured: z.coerce.boolean().optional(),
|
|
11
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
12
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export default defineEventHandler(async (event) => {
|
|
16
|
+
requireApiScope(event, 'read:events');
|
|
17
|
+
requireFeature('events');
|
|
18
|
+
const parsed = querySchema.safeParse(getQuery(event));
|
|
19
|
+
if (!parsed.success) {
|
|
20
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters', data: parsed.error.flatten() });
|
|
21
|
+
}
|
|
22
|
+
const filters = parsed.data;
|
|
23
|
+
// Non-owner may only request public-safe statuses — anything else coerces to undefined
|
|
24
|
+
// (list function default), matching the /api/events hardening from session 125.
|
|
25
|
+
const status = filters.status && PUBLIC_STATUSES.has(filters.status) ? filters.status : undefined;
|
|
26
|
+
const db = useDB();
|
|
27
|
+
const config = useConfig();
|
|
28
|
+
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
+
const result = await listEvents(db, {
|
|
31
|
+
status: status as any,
|
|
32
|
+
hubId: filters.hubId,
|
|
33
|
+
upcoming: filters.upcoming,
|
|
34
|
+
featured: filters.featured,
|
|
35
|
+
limit: filters.limit,
|
|
36
|
+
offset: filters.offset,
|
|
37
|
+
});
|
|
38
|
+
const domain = config.instance.domain;
|
|
39
|
+
const items = (result.items as unknown as PublicEventRow[])
|
|
40
|
+
.filter((r) => !r.deletedAt && PUBLIC_STATUSES.has(r.status))
|
|
41
|
+
.map((r) => toPublicEvent(r, domain));
|
|
42
|
+
return { items, total: result.total, limit: filters.limit, offset: filters.offset };
|
|
43
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getPathBySlug, toPublicLearningPath, type PublicLearningPathRow } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
requireApiScope(event, 'read:learn');
|
|
5
|
+
requireFeature('learning');
|
|
6
|
+
const slug = getRouterParam(event, 'slug');
|
|
7
|
+
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
|
|
8
|
+
const db = useDB();
|
|
9
|
+
const config = useConfig();
|
|
10
|
+
const path = await getPathBySlug(db, slug);
|
|
11
|
+
if (!path || path.status !== 'published' || (path as { deletedAt?: Date | null }).deletedAt) {
|
|
12
|
+
throw createError({ statusCode: 404, statusMessage: 'Learning path not found' });
|
|
13
|
+
}
|
|
14
|
+
return toPublicLearningPath(path as unknown as PublicLearningPathRow, config.instance.domain);
|
|
15
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { listPaths, toPublicLearningPath, type PublicLearningPathRow } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const querySchema = z.object({
|
|
5
|
+
authorId: z.string().uuid().optional(),
|
|
6
|
+
difficulty: z.enum(['beginner', 'intermediate', 'advanced']).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:learn');
|
|
13
|
+
requireFeature('learning');
|
|
14
|
+
const parsed = querySchema.safeParse(getQuery(event));
|
|
15
|
+
if (!parsed.success) {
|
|
16
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters', data: parsed.error.flatten() });
|
|
17
|
+
}
|
|
18
|
+
const filters = parsed.data;
|
|
19
|
+
const db = useDB();
|
|
20
|
+
const config = useConfig();
|
|
21
|
+
const result = await listPaths(db, {
|
|
22
|
+
status: 'published',
|
|
23
|
+
authorId: filters.authorId,
|
|
24
|
+
difficulty: filters.difficulty,
|
|
25
|
+
limit: filters.limit,
|
|
26
|
+
offset: filters.offset,
|
|
27
|
+
});
|
|
28
|
+
const domain = config.instance.domain;
|
|
29
|
+
const items = (result.items as unknown as PublicLearningPathRow[]).map((r) => toPublicLearningPath(r, domain));
|
|
30
|
+
return { items, total: result.total, limit: filters.limit, offset: filters.offset };
|
|
31
|
+
});
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { PUBLIC_API_SCOPES } from '@commonpub/schema';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/public/v1/openapi.json
|
|
5
|
+
*
|
|
6
|
+
* Hand-written OpenAPI 3.1 spec. Requires a valid key with any scope — we
|
|
7
|
+
* don't expose the spec anonymously because the existence of the API surface
|
|
8
|
+
* is itself gated by the publicApi feature flag. Consumers typically fetch
|
|
9
|
+
* the spec once at integration time and paste into Postman/Insomnia; a
|
|
10
|
+
* per-key spec lets us later vary the spec's advertised scopes by key (not
|
|
11
|
+
* doing that yet, but leaves the door open).
|
|
12
|
+
*/
|
|
13
|
+
export default defineEventHandler((event) => {
|
|
14
|
+
// Any valid scope grants access to the spec itself.
|
|
15
|
+
const scopes = event.context.apiScopes;
|
|
16
|
+
if (!scopes || scopes.length === 0) {
|
|
17
|
+
throw createError({ statusCode: 401, statusMessage: 'Missing API key' });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const config = useConfig();
|
|
21
|
+
const base = `https://${config.instance.domain}/api/public/v1`;
|
|
22
|
+
|
|
23
|
+
const errorResponse = {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
error: { type: 'boolean' },
|
|
27
|
+
statusCode: { type: 'integer' },
|
|
28
|
+
statusMessage: { type: 'string' },
|
|
29
|
+
message: { type: 'string' },
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const paginated = (itemsRef: string) => ({
|
|
34
|
+
type: 'object',
|
|
35
|
+
required: ['items', 'total', 'limit', 'offset'],
|
|
36
|
+
properties: {
|
|
37
|
+
items: { type: 'array', items: { $ref: itemsRef } },
|
|
38
|
+
total: { type: 'integer' },
|
|
39
|
+
limit: { type: 'integer' },
|
|
40
|
+
offset: { type: 'integer' },
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
openapi: '3.1.0',
|
|
46
|
+
info: {
|
|
47
|
+
title: `${config.instance.name} Public API`,
|
|
48
|
+
version: '1.0.0',
|
|
49
|
+
description:
|
|
50
|
+
'CommonPub Public Read API. Admin-provisioned Bearer tokens, per-key scopes, read-only in v1. ' +
|
|
51
|
+
'See https://commonpub.io/docs/public-api for the full reference.',
|
|
52
|
+
license: { name: 'AGPL-3.0-or-later', url: 'https://www.gnu.org/licenses/agpl-3.0.html' },
|
|
53
|
+
},
|
|
54
|
+
servers: [{ url: base }],
|
|
55
|
+
components: {
|
|
56
|
+
securitySchemes: {
|
|
57
|
+
bearer: {
|
|
58
|
+
type: 'http',
|
|
59
|
+
scheme: 'bearer',
|
|
60
|
+
bearerFormat: 'cpub_<env>_<type>_<random>',
|
|
61
|
+
description: 'Admin-provisioned opaque token. Create keys at /admin/api-keys.',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
schemas: {
|
|
65
|
+
Error: errorResponse,
|
|
66
|
+
PublicUser: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
required: ['id', 'username', 'createdAt'],
|
|
69
|
+
properties: {
|
|
70
|
+
id: { type: 'string', format: 'uuid' },
|
|
71
|
+
username: { type: 'string' },
|
|
72
|
+
displayName: { type: 'string', nullable: true },
|
|
73
|
+
headline: { type: 'string', nullable: true },
|
|
74
|
+
bio: { type: 'string', nullable: true },
|
|
75
|
+
avatarUrl: { type: 'string', format: 'uri', nullable: true },
|
|
76
|
+
bannerUrl: { type: 'string', format: 'uri', nullable: true },
|
|
77
|
+
pronouns: { type: 'string', nullable: true },
|
|
78
|
+
location: { type: 'string', nullable: true },
|
|
79
|
+
website: { type: 'string', format: 'uri', nullable: true },
|
|
80
|
+
skills: { type: 'array', items: { type: 'string' }, nullable: true },
|
|
81
|
+
socialLinks: { type: 'object', additionalProperties: { type: 'string' }, nullable: true },
|
|
82
|
+
createdAt: { type: 'string', format: 'date-time' },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
UserRef: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
required: ['id', 'username'],
|
|
88
|
+
properties: {
|
|
89
|
+
id: { type: 'string', format: 'uuid' },
|
|
90
|
+
username: { type: 'string' },
|
|
91
|
+
displayName: { type: 'string', nullable: true },
|
|
92
|
+
avatarUrl: { type: 'string', format: 'uri', nullable: true },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
PublicContentSummary: {
|
|
96
|
+
type: 'object',
|
|
97
|
+
required: ['id', 'type', 'title', 'slug', 'canonicalUrl', 'source'],
|
|
98
|
+
properties: {
|
|
99
|
+
id: { type: 'string', format: 'uuid' },
|
|
100
|
+
type: { type: 'string', enum: ['project', 'blog', 'explainer'] },
|
|
101
|
+
title: { type: 'string' },
|
|
102
|
+
slug: { type: 'string' },
|
|
103
|
+
description: { type: 'string', nullable: true },
|
|
104
|
+
coverImageUrl: { type: 'string', format: 'uri', nullable: true },
|
|
105
|
+
difficulty: { type: 'string', nullable: true },
|
|
106
|
+
publishedAt: { type: 'string', format: 'date-time', nullable: true },
|
|
107
|
+
updatedAt: { type: 'string', format: 'date-time' },
|
|
108
|
+
viewCount: { type: 'integer' },
|
|
109
|
+
likeCount: { type: 'integer' },
|
|
110
|
+
commentCount: { type: 'integer' },
|
|
111
|
+
author: { $ref: '#/components/schemas/UserRef' },
|
|
112
|
+
canonicalUrl: { type: 'string', format: 'uri' },
|
|
113
|
+
source: { type: 'string', enum: ['local', 'federated'] },
|
|
114
|
+
sourceDomain: { type: 'string', nullable: true },
|
|
115
|
+
sourceUri: { type: 'string', format: 'uri', nullable: true },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
PublicHub: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
required: ['id', 'name', 'slug', 'hubType', 'canonicalUrl'],
|
|
121
|
+
properties: {
|
|
122
|
+
id: { type: 'string', format: 'uuid' },
|
|
123
|
+
name: { type: 'string' },
|
|
124
|
+
slug: { type: 'string' },
|
|
125
|
+
description: { type: 'string', nullable: true },
|
|
126
|
+
hubType: { type: 'string', enum: ['community', 'product', 'company'] },
|
|
127
|
+
iconUrl: { type: 'string', format: 'uri', nullable: true },
|
|
128
|
+
bannerUrl: { type: 'string', format: 'uri', nullable: true },
|
|
129
|
+
memberCount: { type: 'integer' },
|
|
130
|
+
postCount: { type: 'integer' },
|
|
131
|
+
isOfficial: { type: 'boolean' },
|
|
132
|
+
categories: { type: 'array', items: { type: 'string' }, nullable: true },
|
|
133
|
+
website: { type: 'string', format: 'uri', nullable: true },
|
|
134
|
+
createdAt: { type: 'string', format: 'date-time' },
|
|
135
|
+
canonicalUrl: { type: 'string', format: 'uri' },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
PublicLearningPath: {
|
|
139
|
+
type: 'object',
|
|
140
|
+
required: ['id', 'title', 'slug', 'canonicalUrl'],
|
|
141
|
+
properties: {
|
|
142
|
+
id: { type: 'string', format: 'uuid' },
|
|
143
|
+
title: { type: 'string' },
|
|
144
|
+
slug: { type: 'string' },
|
|
145
|
+
description: { type: 'string', nullable: true },
|
|
146
|
+
coverImageUrl: { type: 'string', format: 'uri', nullable: true },
|
|
147
|
+
difficulty: { type: 'string', nullable: true },
|
|
148
|
+
lessonCount: { type: 'integer' },
|
|
149
|
+
enrollmentCount: { type: 'integer' },
|
|
150
|
+
publishedAt: { type: 'string', format: 'date-time', nullable: true },
|
|
151
|
+
createdAt: { type: 'string', format: 'date-time' },
|
|
152
|
+
author: { $ref: '#/components/schemas/UserRef' },
|
|
153
|
+
canonicalUrl: { type: 'string', format: 'uri' },
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
PublicEvent: {
|
|
157
|
+
type: 'object',
|
|
158
|
+
required: ['id', 'title', 'slug', 'startAt', 'canonicalUrl'],
|
|
159
|
+
properties: {
|
|
160
|
+
id: { type: 'string', format: 'uuid' },
|
|
161
|
+
title: { type: 'string' },
|
|
162
|
+
slug: { type: 'string' },
|
|
163
|
+
description: { type: 'string', nullable: true },
|
|
164
|
+
coverImageUrl: { type: 'string', format: 'uri', nullable: true },
|
|
165
|
+
eventType: { type: 'string' },
|
|
166
|
+
status: { type: 'string', enum: ['published', 'active', 'completed', 'upcoming', 'past'] },
|
|
167
|
+
location: { type: 'string', nullable: true },
|
|
168
|
+
locationUrl: { type: 'string', format: 'uri', nullable: true },
|
|
169
|
+
startAt: { type: 'string', format: 'date-time' },
|
|
170
|
+
endAt: { type: 'string', format: 'date-time', nullable: true },
|
|
171
|
+
timezone: { type: 'string', nullable: true },
|
|
172
|
+
capacity: { type: 'integer', nullable: true },
|
|
173
|
+
attendeeCount: { type: 'integer' },
|
|
174
|
+
waitlistCount: { type: 'integer' },
|
|
175
|
+
hubId: { type: 'string', format: 'uuid', nullable: true },
|
|
176
|
+
createdAt: { type: 'string', format: 'date-time' },
|
|
177
|
+
host: { $ref: '#/components/schemas/UserRef' },
|
|
178
|
+
canonicalUrl: { type: 'string', format: 'uri' },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
PublicContest: {
|
|
182
|
+
type: 'object',
|
|
183
|
+
required: ['id', 'title', 'slug', 'status', 'canonicalUrl'],
|
|
184
|
+
properties: {
|
|
185
|
+
id: { type: 'string', format: 'uuid' },
|
|
186
|
+
title: { type: 'string' },
|
|
187
|
+
slug: { type: 'string' },
|
|
188
|
+
description: { type: 'string', nullable: true },
|
|
189
|
+
bannerUrl: { type: 'string', format: 'uri', nullable: true },
|
|
190
|
+
status: { type: 'string', enum: ['upcoming', 'active', 'judging', 'completed'] },
|
|
191
|
+
startDate: { type: 'string', format: 'date-time' },
|
|
192
|
+
endDate: { type: 'string', format: 'date-time' },
|
|
193
|
+
entryDeadline: { type: 'string', format: 'date-time', nullable: true },
|
|
194
|
+
judgingDeadline: { type: 'string', format: 'date-time', nullable: true },
|
|
195
|
+
prizeDescription: { type: 'string', nullable: true },
|
|
196
|
+
entryCount: { type: 'integer' },
|
|
197
|
+
communityVotingEnabled: { type: 'boolean' },
|
|
198
|
+
createdAt: { type: 'string', format: 'date-time' },
|
|
199
|
+
canonicalUrl: { type: 'string', format: 'uri' },
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
PublicVideo: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
required: ['id', 'title', 'url', 'canonicalUrl'],
|
|
205
|
+
properties: {
|
|
206
|
+
id: { type: 'string', format: 'uuid' },
|
|
207
|
+
title: { type: 'string' },
|
|
208
|
+
description: { type: 'string', nullable: true },
|
|
209
|
+
url: { type: 'string', format: 'uri' },
|
|
210
|
+
embedUrl: { type: 'string', format: 'uri', nullable: true },
|
|
211
|
+
thumbnailUrl: { type: 'string', format: 'uri', nullable: true },
|
|
212
|
+
duration: { type: 'integer', nullable: true },
|
|
213
|
+
category: { type: 'object', nullable: true },
|
|
214
|
+
viewCount: { type: 'integer' },
|
|
215
|
+
likeCount: { type: 'integer' },
|
|
216
|
+
createdAt: { type: 'string', format: 'date-time' },
|
|
217
|
+
author: { $ref: '#/components/schemas/UserRef' },
|
|
218
|
+
canonicalUrl: { type: 'string', format: 'uri' },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
PublicDocSite: {
|
|
222
|
+
type: 'object',
|
|
223
|
+
required: ['id', 'name', 'slug', 'canonicalUrl'],
|
|
224
|
+
properties: {
|
|
225
|
+
id: { type: 'string', format: 'uuid' },
|
|
226
|
+
name: { type: 'string' },
|
|
227
|
+
slug: { type: 'string' },
|
|
228
|
+
description: { type: 'string', nullable: true },
|
|
229
|
+
pageCount: { type: 'integer' },
|
|
230
|
+
versionCount: { type: 'integer' },
|
|
231
|
+
defaultVersion: { type: 'string', nullable: true },
|
|
232
|
+
createdAt: { type: 'string', format: 'date-time' },
|
|
233
|
+
owner: { $ref: '#/components/schemas/UserRef' },
|
|
234
|
+
canonicalUrl: { type: 'string', format: 'uri' },
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
PublicTag: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
required: ['id', 'name', 'slug'],
|
|
240
|
+
properties: {
|
|
241
|
+
id: { type: 'string', format: 'uuid' },
|
|
242
|
+
name: { type: 'string' },
|
|
243
|
+
slug: { type: 'string' },
|
|
244
|
+
usageCount: { type: 'integer' },
|
|
245
|
+
canonicalUrl: { type: 'string', format: 'uri' },
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
PublicInstance: {
|
|
249
|
+
type: 'object',
|
|
250
|
+
required: ['name', 'domain', 'software'],
|
|
251
|
+
properties: {
|
|
252
|
+
name: { type: 'string' },
|
|
253
|
+
description: { type: 'string', nullable: true },
|
|
254
|
+
domain: { type: 'string' },
|
|
255
|
+
software: { type: 'object' },
|
|
256
|
+
users: { type: 'object' },
|
|
257
|
+
content: { type: 'object' },
|
|
258
|
+
hubs: { type: 'object' },
|
|
259
|
+
features: { type: 'object' },
|
|
260
|
+
openRegistrations: { type: 'boolean' },
|
|
261
|
+
links: { type: 'object' },
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
security: [{ bearer: PUBLIC_API_SCOPES.slice() }],
|
|
267
|
+
paths: {
|
|
268
|
+
'/content': {
|
|
269
|
+
get: {
|
|
270
|
+
summary: 'List published content',
|
|
271
|
+
security: [{ bearer: ['read:content'] }],
|
|
272
|
+
parameters: [
|
|
273
|
+
{ name: 'type', in: 'query', schema: { type: 'string', enum: ['project', 'blog', 'explainer'] } },
|
|
274
|
+
{ name: 'tag', in: 'query', schema: { type: 'string' } },
|
|
275
|
+
{ name: 'authorId', in: 'query', schema: { type: 'string', format: 'uuid' } },
|
|
276
|
+
{ name: 'categoryId', in: 'query', schema: { type: 'string', format: 'uuid' } },
|
|
277
|
+
{ name: 'difficulty', in: 'query', schema: { type: 'string', enum: ['beginner', 'intermediate', 'advanced'] } },
|
|
278
|
+
{ name: 'sort', in: 'query', schema: { type: 'string', enum: ['recent', 'popular', 'featured'] } },
|
|
279
|
+
{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 100 } },
|
|
280
|
+
{ name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0 } },
|
|
281
|
+
],
|
|
282
|
+
responses: {
|
|
283
|
+
'200': { description: 'OK', content: { 'application/json': { schema: paginated('#/components/schemas/PublicContentSummary') } } },
|
|
284
|
+
'401': { description: 'Missing/invalid API key', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } },
|
|
285
|
+
'403': { description: 'Missing scope', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } },
|
|
286
|
+
'429': { description: 'Rate limit exceeded' },
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
'/content/{slug}': {
|
|
291
|
+
get: {
|
|
292
|
+
summary: 'Get a single published content item',
|
|
293
|
+
security: [{ bearer: ['read:content'] }],
|
|
294
|
+
parameters: [
|
|
295
|
+
{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } },
|
|
296
|
+
{ name: 'author', in: 'query', schema: { type: 'string' }, description: 'Disambiguate user-scoped slugs.' },
|
|
297
|
+
],
|
|
298
|
+
responses: {
|
|
299
|
+
'200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/PublicContentSummary' } } } },
|
|
300
|
+
'404': { description: 'Not found', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } },
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
'/hubs': {
|
|
305
|
+
get: {
|
|
306
|
+
summary: 'List hubs',
|
|
307
|
+
security: [{ bearer: ['read:hubs'] }],
|
|
308
|
+
parameters: [
|
|
309
|
+
{ name: 'type', in: 'query', schema: { type: 'string', enum: ['community', 'product', 'company'] } },
|
|
310
|
+
{ name: 'search', in: 'query', schema: { type: 'string' } },
|
|
311
|
+
{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 100 } },
|
|
312
|
+
{ name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0 } },
|
|
313
|
+
],
|
|
314
|
+
responses: {
|
|
315
|
+
'200': { description: 'OK', content: { 'application/json': { schema: paginated('#/components/schemas/PublicHub') } } },
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
'/hubs/{slug}': {
|
|
320
|
+
get: { summary: 'Get a hub', security: [{ bearer: ['read:hubs'] }], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/PublicHub' } } } } } },
|
|
321
|
+
},
|
|
322
|
+
'/users': {
|
|
323
|
+
get: { summary: 'List public users', security: [{ bearer: ['read:users'] }], parameters: [{ name: 'q', in: 'query', schema: { type: 'string' } }, { name: 'limit', in: 'query', schema: { type: 'integer' } }, { name: 'offset', in: 'query', schema: { type: 'integer' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: paginated('#/components/schemas/PublicUser') } } } } },
|
|
324
|
+
},
|
|
325
|
+
'/users/{username}': {
|
|
326
|
+
get: { summary: 'Get a public user profile', security: [{ bearer: ['read:users'] }], parameters: [{ name: 'username', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/PublicUser' } } } } } },
|
|
327
|
+
},
|
|
328
|
+
'/instance': {
|
|
329
|
+
get: { summary: 'Instance metadata', security: [{ bearer: ['read:instance'] }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/PublicInstance' } } } } } },
|
|
330
|
+
},
|
|
331
|
+
'/learn': {
|
|
332
|
+
get: { summary: 'List published learning paths', security: [{ bearer: ['read:learn'] }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: paginated('#/components/schemas/PublicLearningPath') } } } } },
|
|
333
|
+
},
|
|
334
|
+
'/learn/{slug}': {
|
|
335
|
+
get: { summary: 'Get a learning path', security: [{ bearer: ['read:learn'] }], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/PublicLearningPath' } } } } } },
|
|
336
|
+
},
|
|
337
|
+
'/events': {
|
|
338
|
+
get: { summary: 'List events (feature-gated)', security: [{ bearer: ['read:events'] }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: paginated('#/components/schemas/PublicEvent') } } } } },
|
|
339
|
+
},
|
|
340
|
+
'/events/{slug}': {
|
|
341
|
+
get: { summary: 'Get an event', security: [{ bearer: ['read:events'] }], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/PublicEvent' } } } } } },
|
|
342
|
+
},
|
|
343
|
+
'/contests': {
|
|
344
|
+
get: { summary: 'List contests (feature-gated)', security: [{ bearer: ['read:contests'] }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: paginated('#/components/schemas/PublicContest') } } } } },
|
|
345
|
+
},
|
|
346
|
+
'/contests/{slug}': {
|
|
347
|
+
get: { summary: 'Get a contest', security: [{ bearer: ['read:contests'] }], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/PublicContest' } } } } } },
|
|
348
|
+
},
|
|
349
|
+
'/videos': {
|
|
350
|
+
get: { summary: 'List videos (feature-gated)', security: [{ bearer: ['read:videos'] }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: paginated('#/components/schemas/PublicVideo') } } } } },
|
|
351
|
+
},
|
|
352
|
+
'/videos/{id}': {
|
|
353
|
+
get: { summary: 'Get a video by id', security: [{ bearer: ['read:videos'] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/PublicVideo' } } } } } },
|
|
354
|
+
},
|
|
355
|
+
'/docs': {
|
|
356
|
+
get: { summary: 'List docs sites (feature-gated)', security: [{ bearer: ['read:docs'] }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: paginated('#/components/schemas/PublicDocSite') } } } } },
|
|
357
|
+
},
|
|
358
|
+
'/docs/{slug}': {
|
|
359
|
+
get: { summary: 'Get a docs site', security: [{ bearer: ['read:docs'] }], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/PublicDocSite' } } } } } },
|
|
360
|
+
},
|
|
361
|
+
'/tags': {
|
|
362
|
+
get: { summary: 'List tags with usage counts', security: [{ bearer: ['read:tags'] }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: paginated('#/components/schemas/PublicTag') } } } } },
|
|
363
|
+
},
|
|
364
|
+
'/search': {
|
|
365
|
+
get: { summary: 'Search content', security: [{ bearer: ['read:search'] }], parameters: [{ name: 'q', in: 'query', required: true, schema: { type: 'string' } }, { name: 'type', in: 'query', schema: { type: 'string' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: paginated('#/components/schemas/PublicContentSummary') } } } } },
|
|
366
|
+
},
|
|
367
|
+
'/openapi.json': {
|
|
368
|
+
get: { summary: 'This OpenAPI spec', responses: { '200': { description: 'OK' } } },
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { searchContent, toPublicContentSummary, type PublicContentRow } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const querySchema = z.object({
|
|
5
|
+
q: z.string().min(1).max(200),
|
|
6
|
+
type: z.enum(['project', 'blog', 'explainer']).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:search');
|
|
13
|
+
const parsed = querySchema.safeParse(getQuery(event));
|
|
14
|
+
if (!parsed.success) {
|
|
15
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters', data: parsed.error.flatten() });
|
|
16
|
+
}
|
|
17
|
+
const { q, type, limit, offset } = parsed.data;
|
|
18
|
+
const db = useDB();
|
|
19
|
+
const config = useConfig();
|
|
20
|
+
const result = await searchContent(db, { query: q, type, limit, offset });
|
|
21
|
+
const domain = config.instance.domain;
|
|
22
|
+
const items = (result.items as unknown as PublicContentRow[])
|
|
23
|
+
.filter((r) => r.status === 'published' && !r.deletedAt)
|
|
24
|
+
.map((r) => toPublicContentSummary(r, domain));
|
|
25
|
+
return { items, total: result.total, limit, offset, query: q };
|
|
26
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { toPublicTag, type PublicTagRow } from '@commonpub/server';
|
|
2
|
+
import { tags, contentTags } from '@commonpub/schema';
|
|
3
|
+
import { desc, sql } from 'drizzle-orm';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
const querySchema = z.object({
|
|
7
|
+
limit: z.coerce.number().int().min(1).max(100).default(50),
|
|
8
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export default defineEventHandler(async (event) => {
|
|
12
|
+
requireApiScope(event, 'read:tags');
|
|
13
|
+
const parsed = querySchema.safeParse(getQuery(event));
|
|
14
|
+
if (!parsed.success) {
|
|
15
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters', data: parsed.error.flatten() });
|
|
16
|
+
}
|
|
17
|
+
const { limit, offset } = parsed.data;
|
|
18
|
+
const db = useDB();
|
|
19
|
+
const config = useConfig();
|
|
20
|
+
|
|
21
|
+
const rows = await db
|
|
22
|
+
.select({
|
|
23
|
+
id: tags.id,
|
|
24
|
+
name: tags.name,
|
|
25
|
+
slug: tags.slug,
|
|
26
|
+
usageCount: sql<number>`count(${contentTags.contentId})::int`,
|
|
27
|
+
})
|
|
28
|
+
.from(tags)
|
|
29
|
+
.leftJoin(contentTags, sql`${contentTags.tagId} = ${tags.id}`)
|
|
30
|
+
.groupBy(tags.id, tags.name, tags.slug)
|
|
31
|
+
.orderBy(desc(sql<number>`count(${contentTags.contentId})`), tags.name)
|
|
32
|
+
.limit(limit)
|
|
33
|
+
.offset(offset);
|
|
34
|
+
|
|
35
|
+
const [{ total }] = await db.select({ total: sql<number>`count(*)::int` }).from(tags);
|
|
36
|
+
const domain = config.instance.domain;
|
|
37
|
+
const items = (rows as unknown as PublicTagRow[]).map((r) => toPublicTag(r, domain));
|
|
38
|
+
return { items, total: total ?? 0, limit, offset };
|
|
39
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { getVideoById, toPublicVideo, isPublicVideo, type PublicVideoRow } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
requireApiScope(event, 'read:videos');
|
|
5
|
+
requireFeature('video');
|
|
6
|
+
const id = getRouterParam(event, 'id');
|
|
7
|
+
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing id' });
|
|
8
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
|
9
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid id format' });
|
|
10
|
+
}
|
|
11
|
+
const db = useDB();
|
|
12
|
+
const config = useConfig();
|
|
13
|
+
const row = await getVideoById(db, id);
|
|
14
|
+
if (!row) throw createError({ statusCode: 404, statusMessage: 'Video not found' });
|
|
15
|
+
const casted = row as unknown as PublicVideoRow;
|
|
16
|
+
if (!isPublicVideo(casted)) throw createError({ statusCode: 404, statusMessage: 'Video not found' });
|
|
17
|
+
return toPublicVideo(casted, config.instance.domain);
|
|
18
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { listVideos, toPublicVideo, isPublicVideo, type PublicVideoRow } from '@commonpub/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const querySchema = z.object({
|
|
5
|
+
categoryId: z.string().uuid().optional(),
|
|
6
|
+
authorId: z.string().uuid().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:videos');
|
|
13
|
+
requireFeature('video');
|
|
14
|
+
const parsed = querySchema.safeParse(getQuery(event));
|
|
15
|
+
if (!parsed.success) {
|
|
16
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters', data: parsed.error.flatten() });
|
|
17
|
+
}
|
|
18
|
+
const filters = parsed.data;
|
|
19
|
+
const db = useDB();
|
|
20
|
+
const config = useConfig();
|
|
21
|
+
const result = await listVideos(db, filters);
|
|
22
|
+
const domain = config.instance.domain;
|
|
23
|
+
const items = (result.items as unknown as PublicVideoRow[])
|
|
24
|
+
.filter(isPublicVideo)
|
|
25
|
+
.map((r) => toPublicVideo(r, domain));
|
|
26
|
+
return { items, total: result.total, limit: filters.limit, offset: filters.offset };
|
|
27
|
+
});
|
|
@@ -1,9 +1,20 @@
|
|
|
1
|
-
import { getUnreadCount, getUnreadMessageCount } from '@commonpub/server';
|
|
1
|
+
import { getUnreadCount, getUnreadMessageCount, subscribeSseEvents } from '@commonpub/server';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Unified SSE stream for notification and message counts.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* Two delivery paths layered together:
|
|
7
|
+
* 1. Pub/sub — the server emits on a per-user channel whenever a
|
|
8
|
+
* notification or message is written. When `NUXT_REDIS_URL` is set,
|
|
9
|
+
* events cross Nitro processes; without Redis, fanout is in-process
|
|
10
|
+
* only (same behavior as before session 130).
|
|
11
|
+
* 2. Polling — every 30 s we re-query counts as a safety net, so a
|
|
12
|
+
* missed publish (Redis blip, connection drop) resolves itself in
|
|
13
|
+
* one poll tick instead of until the client reconnects.
|
|
14
|
+
*
|
|
15
|
+
* Both paths converge on `sendCounts()`, which fetches fresh counts from
|
|
16
|
+
* the DB. The pub/sub payload is a nudge; we never trust it as the source
|
|
17
|
+
* of truth.
|
|
7
18
|
*/
|
|
8
19
|
export default defineEventHandler(async (event) => {
|
|
9
20
|
const user = requireAuth(event);
|
|
@@ -14,20 +25,43 @@ export default defineEventHandler(async (event) => {
|
|
|
14
25
|
const stream = new ReadableStream({
|
|
15
26
|
async start(controller) {
|
|
16
27
|
let closed = false;
|
|
28
|
+
let unsubscribe: (() => void) | null = null;
|
|
29
|
+
let sending = false;
|
|
30
|
+
let pendingSend = false;
|
|
31
|
+
|
|
17
32
|
function cleanup(): void {
|
|
18
33
|
if (closed) return;
|
|
19
34
|
closed = true;
|
|
20
35
|
clearInterval(interval);
|
|
21
36
|
clearInterval(keepalive);
|
|
37
|
+
if (unsubscribe) {
|
|
38
|
+
try { unsubscribe(); } catch { /* ignore */ }
|
|
39
|
+
unsubscribe = null;
|
|
40
|
+
}
|
|
22
41
|
try { controller.close(); } catch { /* already closed */ }
|
|
23
42
|
}
|
|
24
43
|
|
|
25
44
|
async function sendCounts(): Promise<void> {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
45
|
+
if (closed) return;
|
|
46
|
+
// Coalesce overlapping triggers — if a pub/sub event fires while
|
|
47
|
+
// a previous sendCounts is still resolving, set pendingSend and
|
|
48
|
+
// run one more round after the current call returns. Prevents a
|
|
49
|
+
// burst of publishes from piling up N DB queries.
|
|
50
|
+
if (sending) { pendingSend = true; return; }
|
|
51
|
+
sending = true;
|
|
52
|
+
try {
|
|
53
|
+
do {
|
|
54
|
+
pendingSend = false;
|
|
55
|
+
const [notifications, messages] = await Promise.all([
|
|
56
|
+
getUnreadCount(db, userId),
|
|
57
|
+
getUnreadMessageCount(db, userId),
|
|
58
|
+
]);
|
|
59
|
+
if (closed) return;
|
|
60
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'counts', notifications, messages })}\n\n`));
|
|
61
|
+
} while (pendingSend && !closed);
|
|
62
|
+
} finally {
|
|
63
|
+
sending = false;
|
|
64
|
+
}
|
|
31
65
|
}
|
|
32
66
|
|
|
33
67
|
// Send initial counts — if DB is unavailable, send zeros and let polling retry
|
|
@@ -37,14 +71,27 @@ export default defineEventHandler(async (event) => {
|
|
|
37
71
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'counts', notifications: 0, messages: 0 })}\n\n`));
|
|
38
72
|
}
|
|
39
73
|
|
|
40
|
-
//
|
|
74
|
+
// Pub/sub subscription — nudges the send path whenever a
|
|
75
|
+
// notification or message is written for this user.
|
|
76
|
+
try {
|
|
77
|
+
unsubscribe = await subscribeSseEvents(userId, () => {
|
|
78
|
+
sendCounts().catch(() => {});
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
// Pub/sub unavailable — polling alone still works, just slower.
|
|
82
|
+
unsubscribe = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Polling fallback at 30 s (was 10 s). The pub/sub path is the
|
|
86
|
+
// primary delivery mechanism; polling only guards against missed
|
|
87
|
+
// events (Redis restart, subscriber dropped).
|
|
41
88
|
const interval = setInterval(async () => {
|
|
42
89
|
try {
|
|
43
90
|
await sendCounts();
|
|
44
91
|
} catch {
|
|
45
92
|
cleanup();
|
|
46
93
|
}
|
|
47
|
-
},
|
|
94
|
+
}, 30000);
|
|
48
95
|
|
|
49
96
|
// Keepalive every 30 seconds
|
|
50
97
|
const keepalive = setInterval(() => {
|
|
@@ -77,7 +77,7 @@ export default defineEventHandler(async (event) => {
|
|
|
77
77
|
|
|
78
78
|
// Per-key rate limit (separate store from IP-based rate limit so a noisy
|
|
79
79
|
// public-API consumer can't DoS the web UI for their own home IP).
|
|
80
|
-
const rl = apiKeyRateLimit.check(key.id, key.rateLimitPerMinute);
|
|
80
|
+
const rl = await apiKeyRateLimit.check(key.id, key.rateLimitPerMinute);
|
|
81
81
|
setResponseHeader(event, 'X-RateLimit-Limit', String(rl.limit));
|
|
82
82
|
setResponseHeader(event, 'X-RateLimit-Remaining', String(rl.remaining));
|
|
83
83
|
setResponseHeader(event, 'X-RateLimit-Reset', String(rl.resetAt));
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Security middleware — rate limiting + security headers + CSP
|
|
2
|
-
import {
|
|
2
|
+
import { checkRateLimit, createRateLimitStore, shouldSkipRateLimit, getSecurityHeaders, buildCspHeader, buildCspDirectives } from '@commonpub/server';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Selects a Redis-backed store when NUXT_REDIS_URL is set, otherwise the
|
|
5
|
+
// in-process memory store. Unset env = byte-identical behavior to pre-0.6.
|
|
6
|
+
const store = createRateLimitStore({ redisUrl: process.env.NUXT_REDIS_URL });
|
|
5
7
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
6
8
|
|
|
7
|
-
export default defineEventHandler((event) => {
|
|
9
|
+
export default defineEventHandler(async (event) => {
|
|
8
10
|
const url = getRequestURL(event);
|
|
9
11
|
const pathname = url.pathname;
|
|
10
12
|
|
|
@@ -18,7 +20,7 @@ export default defineEventHandler((event) => {
|
|
|
18
20
|
|| 'unknown';
|
|
19
21
|
|
|
20
22
|
const userId = event.context.auth?.user?.id as string | undefined;
|
|
21
|
-
const { result, headers: rlHeaders } = checkRateLimit(store, ip, pathname, userId);
|
|
23
|
+
const { result, headers: rlHeaders } = await checkRateLimit(store, ip, pathname, userId);
|
|
22
24
|
|
|
23
25
|
for (const [key, value] of Object.entries(rlHeaders)) {
|
|
24
26
|
setResponseHeader(event, key, value);
|