@commonpub/layer 0.73.3 → 0.74.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/composables/useAuth.ts
CHANGED
|
@@ -80,11 +80,16 @@ export function useAuth() {
|
|
|
80
80
|
if (import.meta.server) return;
|
|
81
81
|
try {
|
|
82
82
|
const data = await authGet('/api/me');
|
|
83
|
+
// A *successful* response is authoritative: it reflects the real session
|
|
84
|
+
// (a logged-out user gets `{ user: null }`), so mirror it exactly.
|
|
83
85
|
user.value = data?.user ?? null;
|
|
84
86
|
session.value = data?.session ?? null;
|
|
85
87
|
} catch {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
// A *thrown* error means we couldn't reach /api/me (network blip, 5xx, a
|
|
89
|
+
// slow/overloaded server timing out). That is NOT evidence the session is
|
|
90
|
+
// gone — clearing auth here spuriously logs the user out on any transient
|
|
91
|
+
// hiccup. Keep the SSR-hydrated state; the next successful refresh
|
|
92
|
+
// reconciles it.
|
|
88
93
|
}
|
|
89
94
|
}
|
|
90
95
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.74.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -53,17 +53,17 @@
|
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
-
"@commonpub/auth": "0.8.0",
|
|
57
|
-
"@commonpub/docs": "0.6.3",
|
|
58
56
|
"@commonpub/config": "0.22.1",
|
|
59
|
-
"@commonpub/
|
|
57
|
+
"@commonpub/protocol": "0.13.0",
|
|
60
58
|
"@commonpub/explainer": "0.7.15",
|
|
59
|
+
"@commonpub/schema": "0.41.0",
|
|
61
60
|
"@commonpub/learning": "0.5.2",
|
|
62
|
-
"@commonpub/
|
|
63
|
-
"@commonpub/schema": "0.40.1",
|
|
61
|
+
"@commonpub/ui": "0.13.1",
|
|
64
62
|
"@commonpub/server": "2.84.1",
|
|
63
|
+
"@commonpub/auth": "0.8.0",
|
|
64
|
+
"@commonpub/docs": "0.6.3",
|
|
65
65
|
"@commonpub/theme-studio": "0.6.1",
|
|
66
|
-
"@commonpub/
|
|
66
|
+
"@commonpub/editor": "0.7.12"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -10,7 +10,12 @@ const toast = useToast();
|
|
|
10
10
|
const { extract: extractError } = useApiError();
|
|
11
11
|
const { user, isAdmin } = useAuth();
|
|
12
12
|
|
|
13
|
-
const { data: contest, refresh } = useLazyFetch(`/api/contests/${slug}`);
|
|
13
|
+
const { data: contest, refresh, status: contestStatus } = useLazyFetch(`/api/contests/${slug}`);
|
|
14
|
+
// `useLazyFetch` doesn't block navigation, so on a client-side nav (clicking
|
|
15
|
+
// "Edit Contest") `contest` is null until the fetch resolves. Without this we'd
|
|
16
|
+
// render the "Contest not found" branch during that window — which reads as a
|
|
17
|
+
// broken link. Treat idle/pending as "loading", not "not found".
|
|
18
|
+
const contestLoading = computed(() => contestStatus.value === 'idle' || contestStatus.value === 'pending');
|
|
14
19
|
const isOwner = computed(() => isAdmin.value || !!(user.value?.id && contest.value?.createdById === user.value.id));
|
|
15
20
|
useSeoMeta({ title: () => `Edit: ${contest.value?.title ?? 'Contest'}, ${useSiteName()}` });
|
|
16
21
|
|
|
@@ -344,12 +349,12 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
344
349
|
</div>
|
|
345
350
|
<div class="cpub-form-field">
|
|
346
351
|
<label class="cpub-form-label">Description</label>
|
|
347
|
-
<textarea v-model="description" class="cpub-form-textarea" rows="4" />
|
|
352
|
+
<textarea v-model="description" class="cpub-form-textarea" rows="4" maxlength="50000" />
|
|
348
353
|
<p class="cpub-form-hint">Supports Markdown (headings, lists, bold, links) and inline HTML. Shown formatted on the contest page.</p>
|
|
349
354
|
</div>
|
|
350
355
|
<div class="cpub-form-field">
|
|
351
356
|
<label class="cpub-form-label">Rules</label>
|
|
352
|
-
<textarea v-model="rules" class="cpub-form-textarea" rows="6" placeholder="One rule per line, or full Markdown" />
|
|
357
|
+
<textarea v-model="rules" class="cpub-form-textarea" rows="6" maxlength="50000" placeholder="One rule per line, or full Markdown" />
|
|
353
358
|
<p class="cpub-form-hint">Supports Markdown. Plain one-rule-per-line text is rendered as a numbered list.</p>
|
|
354
359
|
</div>
|
|
355
360
|
<div class="cpub-form-field">
|
|
@@ -438,7 +443,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
438
443
|
<p class="cpub-form-hint">Every field is optional. Use <strong>place</strong> for ranked prizes, a <strong>category</strong> for themed awards, or just a <strong>description</strong>, whatever fits. Cash value is optional.</p>
|
|
439
444
|
<div class="cpub-form-field">
|
|
440
445
|
<label class="cpub-form-label">Prizes overview (optional)</label>
|
|
441
|
-
<textarea v-model="prizesDescription" class="cpub-form-textarea" rows="3" placeholder="Intro shown above the prize cards. Supports Markdown." />
|
|
446
|
+
<textarea v-model="prizesDescription" class="cpub-form-textarea" rows="3" maxlength="50000" placeholder="Intro shown above the prize cards. Supports Markdown." />
|
|
442
447
|
<p class="cpub-form-hint">Markdown intro displayed on the Prizes tab, above the individual prizes.</p>
|
|
443
448
|
</div>
|
|
444
449
|
<div v-for="(prize, i) in prizes" :key="i" class="cpub-prize-row">
|
|
@@ -626,6 +631,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
626
631
|
</div>
|
|
627
632
|
</form>
|
|
628
633
|
</div>
|
|
634
|
+
<div v-else-if="contestLoading" class="cpub-not-found"><p>Loading contest…</p></div>
|
|
629
635
|
<div v-else class="cpub-not-found"><p>Contest not found</p></div>
|
|
630
636
|
</template>
|
|
631
637
|
|
|
@@ -187,12 +187,12 @@ function prizeLabel(prize: Prize): string {
|
|
|
187
187
|
</div>
|
|
188
188
|
<div class="cpub-form-field">
|
|
189
189
|
<label for="contest-desc" class="cpub-form-label">Description</label>
|
|
190
|
-
<textarea id="contest-desc" v-model="description" class="cpub-form-textarea" rows="4" placeholder="Describe your contest. Supports Markdown, # headings, - lists, **bold**, [links](url)…" />
|
|
190
|
+
<textarea id="contest-desc" v-model="description" class="cpub-form-textarea" rows="4" maxlength="50000" placeholder="Describe your contest. Supports Markdown, # headings, - lists, **bold**, [links](url)…" />
|
|
191
191
|
<p class="cpub-form-hint">Supports Markdown (headings, lists, bold, links) and inline HTML. Shown formatted on the contest page.</p>
|
|
192
192
|
</div>
|
|
193
193
|
<div class="cpub-form-field">
|
|
194
194
|
<label for="contest-rules" class="cpub-form-label">Rules</label>
|
|
195
|
-
<textarea id="contest-rules" v-model="rules" class="cpub-form-textarea" rows="6" placeholder="Contest rules and requirements. Supports Markdown, one rule per line, or full Markdown." />
|
|
195
|
+
<textarea id="contest-rules" v-model="rules" class="cpub-form-textarea" rows="6" maxlength="50000" placeholder="Contest rules and requirements. Supports Markdown, one rule per line, or full Markdown." />
|
|
196
196
|
<p class="cpub-form-hint">Supports Markdown. Plain one-rule-per-line text is rendered as a numbered list.</p>
|
|
197
197
|
</div>
|
|
198
198
|
<div class="cpub-form-field">
|
|
@@ -336,7 +336,7 @@ function prizeLabel(prize: Prize): string {
|
|
|
336
336
|
<p class="cpub-form-hint">Contests don't need prizes, leave this empty to skip them entirely. If you do add prizes, every field is optional: use <strong>place</strong> for ranked prizes (1st/2nd/3rd), a <strong>category</strong> for themed awards (e.g. "Best in Show"), or just a <strong>description</strong>. Cash value is optional.</p>
|
|
337
337
|
<div class="cpub-form-field">
|
|
338
338
|
<label for="prizes-desc" class="cpub-form-label">Prizes overview (optional)</label>
|
|
339
|
-
<textarea id="prizes-desc" v-model="prizesDescription" class="cpub-form-textarea" rows="3" placeholder="Intro shown above the prize cards. Supports Markdown." />
|
|
339
|
+
<textarea id="prizes-desc" v-model="prizesDescription" class="cpub-form-textarea" rows="3" maxlength="50000" placeholder="Intro shown above the prize cards. Supports Markdown." />
|
|
340
340
|
<p class="cpub-form-hint">Markdown intro displayed on the Prizes tab, above the individual prizes.</p>
|
|
341
341
|
</div>
|
|
342
342
|
<div v-for="(prize, idx) in prizes" :key="idx" class="cpub-prize-card">
|
package/server/utils/validate.ts
CHANGED
|
@@ -9,10 +9,34 @@ import type { ZodType } from 'zod';
|
|
|
9
9
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
10
|
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]*$/;
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Hard ceiling on JSON request bodies (10 MB). Every JSON write route funnels
|
|
14
|
+
* through `parseBody`, so this one guard caps them all. It rejects on the
|
|
15
|
+
* `Content-Length` header *before* `readBody` buffers + `JSON.parse`s the
|
|
16
|
+
* payload — both synchronous, event-loop-blocking, memory-spiking operations.
|
|
17
|
+
* A pathological body (e.g. a giant blob pasted/scripted into a write) is what
|
|
18
|
+
* can stall or OOM-restart the server; this stops it at the door.
|
|
19
|
+
*
|
|
20
|
+
* The ceiling is deliberately generous, NOT tight: some content bodies are
|
|
21
|
+
* legitimately large and *unbounded* at the schema layer (`content` is
|
|
22
|
+
* `z.unknown()` for articles/projects/docs), so a low cap would reject real
|
|
23
|
+
* saves. 10MB is impossible for a single legitimate document (~6000 pages of
|
|
24
|
+
* text) yet still kills the truly catastrophic payload. The per-field Zod
|
|
25
|
+
* `.max()` caps (e.g. contest text at `CONTEST_RICH_TEXT_MAX`) are the real
|
|
26
|
+
* semantic bound *within* this envelope. Multipart uploads use
|
|
27
|
+
* `readMultipartFormData` (not `parseBody`) and are bounded separately by
|
|
28
|
+
* `validateUpload`, so they are unaffected.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_JSON_BODY_BYTES = 10_000_000;
|
|
31
|
+
|
|
12
32
|
type ParamType = 'uuid' | 'slug' | 'string';
|
|
13
33
|
|
|
14
|
-
/** Parse and validate request body against a Zod schema. Throws 400 on failure. */
|
|
34
|
+
/** Parse and validate request body against a Zod schema. Throws 400 on failure, 413 if oversized. */
|
|
15
35
|
export async function parseBody<T>(event: H3Event, schema: ZodType<T>): Promise<T> {
|
|
36
|
+
const declaredLength = Number(getRequestHeader(event, 'content-length') ?? 0);
|
|
37
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_JSON_BODY_BYTES) {
|
|
38
|
+
throw createError({ statusCode: 413, statusMessage: 'Payload too large' });
|
|
39
|
+
}
|
|
16
40
|
const body = await readBody(event);
|
|
17
41
|
const parsed = schema.safeParse(body);
|
|
18
42
|
if (!parsed.success) {
|