@commonpub/layer 0.73.3 → 0.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/CpubMarkdown.vue +16 -2
- package/components/contest/ContestPrizes.vue +3 -2
- package/components/contest/ContestRules.vue +2 -1
- package/composables/useAuth.ts +7 -2
- package/composables/useSanitize.ts +122 -2
- package/package.json +8 -8
- package/pages/contests/[slug]/edit.vue +27 -6
- package/pages/contests/[slug]/index.vue +6 -4
- package/pages/contests/create.vue +17 -4
- package/server/utils/validate.ts +25 -1
|
@@ -11,16 +11,27 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { markdownToBlockTuples } from '@commonpub/editor';
|
|
13
13
|
import type { BlockTuple } from '@commonpub/editor';
|
|
14
|
+
import { sanitizeRichHtml } from '../composables/useSanitize';
|
|
14
15
|
|
|
15
16
|
const props = defineProps<{
|
|
16
17
|
/** Markdown source (may contain inline/block HTML — passed through). */
|
|
17
18
|
source?: string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Render mode. `markdown` (default) runs the Markdown pipeline; `html` renders
|
|
21
|
+
* the source as the author's raw HTML through the permissive (script-free)
|
|
22
|
+
* sanitizer. The HTML path is also cheaper, so large bodies render instantly
|
|
23
|
+
* (no synchronous Markdown parse) on both SSR and client.
|
|
24
|
+
*/
|
|
25
|
+
format?: 'markdown' | 'html' | null;
|
|
18
26
|
}>();
|
|
19
27
|
|
|
20
28
|
const trimmed = computed(() => (props.source ?? '').trim());
|
|
29
|
+
const isHtml = computed(() => props.format === 'html');
|
|
30
|
+
|
|
31
|
+
const richHtml = computed(() => (isHtml.value && trimmed.value ? sanitizeRichHtml(trimmed.value) : ''));
|
|
21
32
|
|
|
22
33
|
const blocks = computed<BlockTuple[]>(() => {
|
|
23
|
-
if (!trimmed.value) return [];
|
|
34
|
+
if (isHtml.value || !trimmed.value) return [];
|
|
24
35
|
try {
|
|
25
36
|
return markdownToBlockTuples(trimmed.value);
|
|
26
37
|
} catch {
|
|
@@ -30,8 +41,11 @@ const blocks = computed<BlockTuple[]>(() => {
|
|
|
30
41
|
</script>
|
|
31
42
|
|
|
32
43
|
<template>
|
|
44
|
+
<!-- eslint-disable vue/no-v-html -- author HTML, sanitized via sanitizeRichHtml (script-free allowlist) -->
|
|
45
|
+
<div v-if="isHtml && richHtml" class="cpub-md cpub-md-html" v-html="richHtml" />
|
|
46
|
+
<!-- eslint-enable vue/no-v-html -->
|
|
33
47
|
<BlocksBlockContentRenderer
|
|
34
|
-
v-if="blocks.length"
|
|
48
|
+
v-else-if="blocks.length"
|
|
35
49
|
:blocks="(blocks as [string, Record<string, unknown>][])"
|
|
36
50
|
class="cpub-prose cpub-md"
|
|
37
51
|
/>
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
interface Prize { place?: number; category?: string; title?: string; description?: string; value?: string }
|
|
3
3
|
defineProps<{
|
|
4
4
|
prizes: Prize[];
|
|
5
|
-
/** Optional
|
|
5
|
+
/** Optional intro shown above the prize cards (section-level, not per-prize). */
|
|
6
6
|
description?: string | null;
|
|
7
|
+
format?: 'markdown' | 'html' | null;
|
|
7
8
|
}>();
|
|
8
9
|
|
|
9
10
|
function prizeLabel(prize: Prize): string {
|
|
@@ -37,7 +38,7 @@ function prizeIcon(prize: Prize): string {
|
|
|
37
38
|
<div class="cpub-sec-head">
|
|
38
39
|
<h2><i class="fa fa-trophy" style="color: var(--yellow);"></i> Prizes</h2>
|
|
39
40
|
</div>
|
|
40
|
-
<CpubMarkdown v-if="description" :source="description" class="cpub-prizes-intro" />
|
|
41
|
+
<CpubMarkdown v-if="description" :source="description" :format="format" class="cpub-prizes-intro" />
|
|
41
42
|
<div v-if="prizes.length" class="cpub-prize-grid">
|
|
42
43
|
<div
|
|
43
44
|
v-for="(prize, i) in prizes"
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
defineProps<{
|
|
10
10
|
rules: string;
|
|
11
|
+
format?: 'markdown' | 'html' | null;
|
|
11
12
|
}>();
|
|
12
13
|
</script>
|
|
13
14
|
|
|
@@ -17,7 +18,7 @@ defineProps<{
|
|
|
17
18
|
<h2><i class="fa fa-file-lines" style="color: var(--purple);"></i> Rules</h2>
|
|
18
19
|
</div>
|
|
19
20
|
<div class="cpub-rules-card">
|
|
20
|
-
<CpubMarkdown :source="rules" />
|
|
21
|
+
<CpubMarkdown :source="rules" :format="format" />
|
|
21
22
|
</div>
|
|
22
23
|
</div>
|
|
23
24
|
</template>
|
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
|
|
|
@@ -115,7 +115,127 @@ export function sanitizeBlockHtml(html: string): string {
|
|
|
115
115
|
return result;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Rich HTML mode (the opt-in "Full HTML" content format, e.g. contest pages).
|
|
120
|
+
//
|
|
121
|
+
// A deliberately PERMISSIVE but still DEFAULT-DENY allowlist: it renders an
|
|
122
|
+
// author's presentational HTML verbatim — layout tags, inline CSS, SVG icons —
|
|
123
|
+
// while never permitting script execution. Anything not on the allowlist is
|
|
124
|
+
// dropped, so `<script>`, `<iframe>`, `<object>`, `<style>`, `on*` handlers and
|
|
125
|
+
// `javascript:` URLs cannot get through. Authoring is gated to trusted
|
|
126
|
+
// (staff/admin) contest creators and is opt-in per contest. Tag/attr NAME case
|
|
127
|
+
// is preserved on output so case-sensitive SVG names (viewBox, linearGradient)
|
|
128
|
+
// survive. The `style` attribute is allowed but its value is scrubbed of the
|
|
129
|
+
// exfil/script vectors (url(), expression(), javascript:, @import, behavior).
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
const RICH_ALLOWED_ELEMENTS = new Set([
|
|
133
|
+
'div', 'section', 'article', 'aside', 'header', 'footer', 'main', 'nav', 'figure', 'figcaption', 'address',
|
|
134
|
+
'p', 'span', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
135
|
+
'blockquote', 'pre', 'code', 'kbd', 'samp', 'var', 'cite', 'q', 'wbr', 'small', 'mark', 'abbr', 'time', 'sub', 'sup',
|
|
136
|
+
'em', 'strong', 'b', 'i', 'u', 's', 'del', 'ins',
|
|
137
|
+
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
|
|
138
|
+
'a', 'img', 'picture', 'source',
|
|
139
|
+
'table', 'caption', 'colgroup', 'col', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
|
|
140
|
+
'details', 'summary', 'button',
|
|
141
|
+
// SVG (lowercased for the allowlist check; original case preserved on output)
|
|
142
|
+
'svg', 'g', 'path', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'rect', 'defs',
|
|
143
|
+
'lineargradient', 'radialgradient', 'stop', 'text', 'tspan', 'use', 'symbol', 'clippath', 'mask', 'pattern', 'marker',
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
// Attributes allowed on any rich element (lowercased). `aria-*`/`data-*` are
|
|
147
|
+
// allowed by prefix; per-tag extras and `style` are handled separately.
|
|
148
|
+
const RICH_GLOBAL_ATTRS = new Set([
|
|
149
|
+
'class', 'id', 'title', 'role', 'lang', 'dir', 'slot', 'tabindex', 'hidden', 'datetime', 'open',
|
|
150
|
+
// SVG presentation
|
|
151
|
+
'viewbox', 'xmlns', 'version', 'preserveaspectratio', 'transform', 'opacity',
|
|
152
|
+
'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
|
|
153
|
+
'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'stroke-miterlimit', 'clip-rule', 'clip-path', 'mask',
|
|
154
|
+
'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'd', 'points', 'width', 'height',
|
|
155
|
+
'offset', 'stop-color', 'stop-opacity', 'gradientunits', 'gradienttransform', 'spreadmethod', 'patternunits',
|
|
156
|
+
'text-anchor', 'dominant-baseline', 'font-size', 'font-family', 'font-weight', 'letter-spacing',
|
|
157
|
+
'marker-start', 'marker-mid', 'marker-end',
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
const RICH_PER_TAG_ATTRS: Record<string, Set<string>> = {
|
|
161
|
+
a: new Set(['href', 'rel', 'target', 'name']),
|
|
162
|
+
img: new Set(['src', 'alt', 'loading', 'decoding']),
|
|
163
|
+
source: new Set(['type', 'media', 'sizes']),
|
|
164
|
+
td: new Set(['colspan', 'rowspan', 'headers']),
|
|
165
|
+
th: new Set(['colspan', 'rowspan', 'headers', 'scope']),
|
|
166
|
+
ol: new Set(['start', 'type', 'reversed']),
|
|
167
|
+
col: new Set(['span']),
|
|
168
|
+
colgroup: new Set(['span']),
|
|
169
|
+
button: new Set(['type']),
|
|
170
|
+
use: new Set(['href']),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const RICH_URL_ATTRS = new Set(['href', 'src', 'xlink:href']);
|
|
174
|
+
// CSS constructs that can fetch, exfiltrate, or execute — stripped per-declaration.
|
|
175
|
+
const STYLE_DECL_BLOCKLIST = /expression\s*\(|javascript:|vbscript:|behavior\s*:|-moz-binding|@import|url\s*\(/i;
|
|
176
|
+
|
|
177
|
+
function sanitizeStyleAttr(value: string): string {
|
|
178
|
+
return value
|
|
179
|
+
.split(';')
|
|
180
|
+
.filter((decl) => decl.trim() && !STYLE_DECL_BLOCKLIST.test(decl))
|
|
181
|
+
.join(';');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Sanitize author HTML for "full HTML" rendering — permissive but script-free. */
|
|
185
|
+
export function sanitizeRichHtml(html: string): string {
|
|
186
|
+
if (!html || typeof html !== 'string') return '';
|
|
187
|
+
|
|
188
|
+
let result = html.replace(/<!--[\s\S]*?-->/g, '');
|
|
189
|
+
|
|
190
|
+
// Remove raw-text / active elements WITH their contents, so script/style/embed
|
|
191
|
+
// bodies don't leak through as visible text or execute. The tag pass below
|
|
192
|
+
// also drops these by allowlist, but only the open/close tags — this strips
|
|
193
|
+
// the inner payload (e.g. `<script>code</script>` → '' rather than `code`).
|
|
194
|
+
result = result.replace(/<(script|style|noscript|template|iframe|object|embed)\b[^>]*>[\s\S]*?<\/\1>/gi, '');
|
|
195
|
+
|
|
196
|
+
result = result.replace(/<\/?([a-zA-Z][a-zA-Z0-9:-]*)\b([^>]*)?\/?>/g, (match, rawTag: string, attrs?: string) => {
|
|
197
|
+
const tag = rawTag.toLowerCase();
|
|
198
|
+
if (!RICH_ALLOWED_ELEMENTS.has(tag)) return '';
|
|
199
|
+
if (match.startsWith('</')) return `</${rawTag}>`;
|
|
200
|
+
|
|
201
|
+
const safeAttrs: string[] = [];
|
|
202
|
+
if (attrs) {
|
|
203
|
+
const attrRegex = /([a-zA-Z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
|
|
204
|
+
let attrMatch: RegExpExecArray | null;
|
|
205
|
+
while ((attrMatch = attrRegex.exec(attrs)) !== null) {
|
|
206
|
+
const rawName = attrMatch[1]!;
|
|
207
|
+
const name = rawName.toLowerCase();
|
|
208
|
+
const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? '';
|
|
209
|
+
|
|
210
|
+
if (name.startsWith('on')) continue; // event handlers never allowed
|
|
211
|
+
const allowed =
|
|
212
|
+
name === 'style' ||
|
|
213
|
+
RICH_GLOBAL_ATTRS.has(name) ||
|
|
214
|
+
name.startsWith('aria-') ||
|
|
215
|
+
name.startsWith('data-') ||
|
|
216
|
+
(RICH_PER_TAG_ATTRS[tag]?.has(name) ?? false);
|
|
217
|
+
if (!allowed) continue;
|
|
218
|
+
|
|
219
|
+
if (RICH_URL_ATTRS.has(name) && !isSafeUrl(value)) continue;
|
|
220
|
+
|
|
221
|
+
if (name === 'style') {
|
|
222
|
+
const cleaned = sanitizeStyleAttr(value);
|
|
223
|
+
if (cleaned) safeAttrs.push(`style="${escapeAttrValue(cleaned)}"`);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
safeAttrs.push(`${rawName}="${escapeAttrValue(value)}"`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const attrsStr = safeAttrs.length > 0 ? ' ' + safeAttrs.join(' ') : '';
|
|
231
|
+
const selfClose = match.endsWith('/>') ? ' /' : '';
|
|
232
|
+
return `<${rawTag}${attrsStr}${selfClose}>`;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
|
|
118
238
|
/** Composable wrapper for template use */
|
|
119
|
-
export function useSanitize(): { sanitize: (html: string) => string } {
|
|
120
|
-
return { sanitize: sanitizeBlockHtml };
|
|
239
|
+
export function useSanitize(): { sanitize: (html: string) => string; sanitizeRich: (html: string) => string } {
|
|
240
|
+
return { sanitize: sanitizeBlockHtml, sanitizeRich: sanitizeRichHtml };
|
|
121
241
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.75.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -55,15 +55,15 @@
|
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
56
|
"@commonpub/auth": "0.8.0",
|
|
57
57
|
"@commonpub/docs": "0.6.3",
|
|
58
|
-
"@commonpub/config": "0.22.1",
|
|
59
|
-
"@commonpub/editor": "0.7.11",
|
|
60
|
-
"@commonpub/explainer": "0.7.15",
|
|
61
58
|
"@commonpub/learning": "0.5.2",
|
|
62
|
-
"@commonpub/
|
|
63
|
-
"@commonpub/schema": "0.40.1",
|
|
64
|
-
"@commonpub/server": "2.84.1",
|
|
59
|
+
"@commonpub/server": "2.85.0",
|
|
65
60
|
"@commonpub/theme-studio": "0.6.1",
|
|
66
|
-
"@commonpub/
|
|
61
|
+
"@commonpub/config": "0.22.1",
|
|
62
|
+
"@commonpub/explainer": "0.7.15",
|
|
63
|
+
"@commonpub/schema": "0.42.0",
|
|
64
|
+
"@commonpub/ui": "0.13.1",
|
|
65
|
+
"@commonpub/editor": "0.7.12",
|
|
66
|
+
"@commonpub/protocol": "0.13.0"
|
|
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
|
|
|
@@ -24,6 +29,8 @@ function slugify(s: string): string {
|
|
|
24
29
|
const subheading = ref('');
|
|
25
30
|
const description = ref('');
|
|
26
31
|
const rules = ref('');
|
|
32
|
+
// Render mode for description/rules/prizes overview: Markdown (default) or raw HTML.
|
|
33
|
+
const contentFormat = ref<'markdown' | 'html'>('markdown');
|
|
27
34
|
const bannerUrl = ref('');
|
|
28
35
|
const coverImageUrl = ref('');
|
|
29
36
|
const startDate = ref('');
|
|
@@ -81,6 +88,7 @@ watch(contest, (c) => {
|
|
|
81
88
|
subheading.value = c.subheading ?? '';
|
|
82
89
|
description.value = c.description ?? '';
|
|
83
90
|
rules.value = c.rules ?? '';
|
|
91
|
+
contentFormat.value = (c.contentFormat as 'markdown' | 'html') ?? 'markdown';
|
|
84
92
|
bannerUrl.value = c.bannerUrl ?? '';
|
|
85
93
|
coverImageUrl.value = c.coverImageUrl ?? '';
|
|
86
94
|
startDate.value = c.startDate ? new Date(c.startDate).toISOString().slice(0, 16) : '';
|
|
@@ -119,7 +127,7 @@ watch(contest, (c) => {
|
|
|
119
127
|
// Mark the form dirty on any post-hydration edit (gives the save bar its
|
|
120
128
|
// "unsaved changes" cue). Worst case (timing) is a harmless early "dirty".
|
|
121
129
|
watch(
|
|
122
|
-
[title, slugInput, subheading, description, rules, bannerUrl, coverImageUrl, startDate, endDate, judgingEndDate,
|
|
130
|
+
[title, slugInput, subheading, description, rules, contentFormat, bannerUrl, coverImageUrl, startDate, endDate, judgingEndDate,
|
|
123
131
|
communityVotingEnabled, judgingVisibility, eligibleContentTypes, maxEntriesPerUser, visibility, visibleToRoles,
|
|
124
132
|
showPrizes, stages, currentStageIdRef, prizesDescription, prizes, criteria],
|
|
125
133
|
() => { if (!hydratingForm) formDirty.value = true; },
|
|
@@ -190,6 +198,7 @@ async function handleSave(): Promise<void> {
|
|
|
190
198
|
subheading: subheading.value || undefined,
|
|
191
199
|
description: description.value || undefined,
|
|
192
200
|
rules: rules.value || undefined,
|
|
201
|
+
contentFormat: contentFormat.value,
|
|
193
202
|
bannerUrl: bannerUrl.value || undefined,
|
|
194
203
|
coverImageUrl: coverImageUrl.value || undefined,
|
|
195
204
|
startDate: startDate.value ? new Date(startDate.value).toISOString() : undefined,
|
|
@@ -342,14 +351,25 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
342
351
|
<input v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
|
|
343
352
|
<p class="cpub-form-hint">Short plain-text tagline shown under the title in the hero. The Description below is the full body.</p>
|
|
344
353
|
</div>
|
|
354
|
+
<div class="cpub-form-field">
|
|
355
|
+
<label class="cpub-form-label">Content format</label>
|
|
356
|
+
<div class="cpub-type-options" role="radiogroup" aria-label="Content format">
|
|
357
|
+
<label class="cpub-form-check"><input v-model="contentFormat" type="radio" value="markdown" /> <span>Markdown</span></label>
|
|
358
|
+
<label class="cpub-form-check"><input v-model="contentFormat" type="radio" value="html" /> <span>Full HTML</span></label>
|
|
359
|
+
</div>
|
|
360
|
+
<p class="cpub-form-hint">
|
|
361
|
+
Applies to Description, Rules, and the Prizes overview. <strong>Markdown</strong> supports headings, lists, links, and safe inline HTML.
|
|
362
|
+
<strong>Full HTML</strong> renders your raw HTML, CSS, and SVG as-is (scripts and event handlers are removed for safety).
|
|
363
|
+
</p>
|
|
364
|
+
</div>
|
|
345
365
|
<div class="cpub-form-field">
|
|
346
366
|
<label class="cpub-form-label">Description</label>
|
|
347
|
-
<textarea v-model="description" class="cpub-form-textarea" rows="4" />
|
|
348
|
-
<p class="cpub-form-hint">Supports Markdown (headings, lists, bold, links) and inline HTML. Shown formatted on the contest page.</p>
|
|
367
|
+
<textarea v-model="description" class="cpub-form-textarea" rows="4" maxlength="50000" />
|
|
368
|
+
<p class="cpub-form-hint">{{ contentFormat === 'html' ? 'Rendered as full HTML/CSS/SVG.' : 'Supports Markdown (headings, lists, bold, links) and inline HTML.' }} Shown formatted on the contest page.</p>
|
|
349
369
|
</div>
|
|
350
370
|
<div class="cpub-form-field">
|
|
351
371
|
<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" />
|
|
372
|
+
<textarea v-model="rules" class="cpub-form-textarea" rows="6" maxlength="50000" placeholder="One rule per line, or full Markdown" />
|
|
353
373
|
<p class="cpub-form-hint">Supports Markdown. Plain one-rule-per-line text is rendered as a numbered list.</p>
|
|
354
374
|
</div>
|
|
355
375
|
<div class="cpub-form-field">
|
|
@@ -438,7 +458,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
438
458
|
<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
459
|
<div class="cpub-form-field">
|
|
440
460
|
<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." />
|
|
461
|
+
<textarea v-model="prizesDescription" class="cpub-form-textarea" rows="3" maxlength="50000" placeholder="Intro shown above the prize cards. Supports Markdown." />
|
|
442
462
|
<p class="cpub-form-hint">Markdown intro displayed on the Prizes tab, above the individual prizes.</p>
|
|
443
463
|
</div>
|
|
444
464
|
<div v-for="(prize, i) in prizes" :key="i" class="cpub-prize-row">
|
|
@@ -626,6 +646,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
626
646
|
</div>
|
|
627
647
|
</form>
|
|
628
648
|
</div>
|
|
649
|
+
<div v-else-if="contestLoading" class="cpub-not-found"><p>Loading contest…</p></div>
|
|
629
650
|
<div v-else class="cpub-not-found"><p>Contest not found</p></div>
|
|
630
651
|
</template>
|
|
631
652
|
|
|
@@ -7,7 +7,9 @@ const toast = useToast();
|
|
|
7
7
|
const { extract: extractError } = useApiError();
|
|
8
8
|
const { isAuthenticated, isAdmin, user } = useAuth();
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// Blocking fetch (not lazy) so the description/rules render server-side and are
|
|
11
|
+
// present on first paint — no empty flash while the client fetches + renders.
|
|
12
|
+
const { data: contest } = await useFetch(`/api/contests/${slug}`);
|
|
11
13
|
const { data: apiEntriesData, refresh: refreshEntries } = useLazyFetch<{ items: Serialized<ContestEntryItem>[]; total: number }>(`/api/contests/${slug}/entries`);
|
|
12
14
|
const { data: judgesData, refresh: refreshJudges } = useLazyFetch<ContestJudgeItem[]>(`/api/contests/${slug}/judges`);
|
|
13
15
|
|
|
@@ -310,7 +312,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
310
312
|
<div class="cpub-about-section">
|
|
311
313
|
<div class="cpub-sec-head"><h2><i class="fa fa-circle-info" style="color: var(--accent);"></i> About This Contest</h2></div>
|
|
312
314
|
<div class="cpub-about-card">
|
|
313
|
-
<CpubMarkdown v-if="c?.description" :source="c.description" />
|
|
315
|
+
<CpubMarkdown v-if="c?.description" :source="c.description" :format="c?.contentFormat" />
|
|
314
316
|
<p v-else>No description available for this contest.</p>
|
|
315
317
|
</div>
|
|
316
318
|
</div>
|
|
@@ -319,12 +321,12 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
319
321
|
|
|
320
322
|
<!-- RULES -->
|
|
321
323
|
<div v-show="activeTab === 'rules'" id="cpub-panel-rules" role="tabpanel" aria-labelledby="cpub-tab-rules" tabindex="0">
|
|
322
|
-
<ContestRules v-if="c?.rules" :rules="c.rules" />
|
|
324
|
+
<ContestRules v-if="c?.rules" :rules="c.rules" :format="c?.contentFormat" />
|
|
323
325
|
</div>
|
|
324
326
|
|
|
325
327
|
<!-- PRIZES -->
|
|
326
328
|
<div v-show="activeTab === 'prizes'" id="cpub-panel-prizes" role="tabpanel" aria-labelledby="cpub-tab-prizes" tabindex="0">
|
|
327
|
-
<ContestPrizes v-if="c?.showPrizes !== false && (c?.prizes?.length || c?.prizesDescription)" :prizes="c?.prizes ?? []" :description="c?.prizesDescription" />
|
|
329
|
+
<ContestPrizes v-if="c?.showPrizes !== false && (c?.prizes?.length || c?.prizesDescription)" :prizes="c?.prizes ?? []" :description="c?.prizesDescription" :format="c?.contentFormat" />
|
|
328
330
|
</div>
|
|
329
331
|
|
|
330
332
|
<!-- ENTRIES -->
|
|
@@ -20,6 +20,7 @@ watch(title, (t) => { if (!slugTouched.value) slug.value = slugify(t); });
|
|
|
20
20
|
const subheading = ref('');
|
|
21
21
|
const description = ref('');
|
|
22
22
|
const rules = ref('');
|
|
23
|
+
const contentFormat = ref<'markdown' | 'html'>('markdown');
|
|
23
24
|
const bannerUrl = ref('');
|
|
24
25
|
const coverImageUrl = ref('');
|
|
25
26
|
const startDate = ref('');
|
|
@@ -110,6 +111,7 @@ async function handleCreate(): Promise<void> {
|
|
|
110
111
|
subheading: subheading.value || undefined,
|
|
111
112
|
description: description.value || undefined,
|
|
112
113
|
rules: rules.value || undefined,
|
|
114
|
+
contentFormat: contentFormat.value,
|
|
113
115
|
bannerUrl: bannerUrl.value || undefined,
|
|
114
116
|
coverImageUrl: coverImageUrl.value || undefined,
|
|
115
117
|
startDate: new Date(startDate.value).toISOString(),
|
|
@@ -185,14 +187,25 @@ function prizeLabel(prize: Prize): string {
|
|
|
185
187
|
<input id="contest-subheading" v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
|
|
186
188
|
<p class="cpub-form-hint">Short plain-text tagline shown under the title in the hero. The Description below is the full body.</p>
|
|
187
189
|
</div>
|
|
190
|
+
<div class="cpub-form-field">
|
|
191
|
+
<label class="cpub-form-label">Content format</label>
|
|
192
|
+
<div class="cpub-type-options" role="radiogroup" aria-label="Content format">
|
|
193
|
+
<label class="cpub-form-check"><input v-model="contentFormat" type="radio" value="markdown" /> <span>Markdown</span></label>
|
|
194
|
+
<label class="cpub-form-check"><input v-model="contentFormat" type="radio" value="html" /> <span>Full HTML</span></label>
|
|
195
|
+
</div>
|
|
196
|
+
<p class="cpub-form-hint">
|
|
197
|
+
Applies to Description, Rules, and the Prizes overview. <strong>Markdown</strong> supports headings, lists, links, and safe inline HTML.
|
|
198
|
+
<strong>Full HTML</strong> renders your raw HTML, CSS, and SVG as-is (scripts and event handlers are removed for safety).
|
|
199
|
+
</p>
|
|
200
|
+
</div>
|
|
188
201
|
<div class="cpub-form-field">
|
|
189
202
|
<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)…" />
|
|
191
|
-
<p class="cpub-form-hint">Supports Markdown (headings, lists, bold, links) and inline HTML. Shown formatted on the contest page.</p>
|
|
203
|
+
<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)…" />
|
|
204
|
+
<p class="cpub-form-hint">{{ contentFormat === 'html' ? 'Rendered as full HTML/CSS/SVG.' : 'Supports Markdown (headings, lists, bold, links) and inline HTML.' }} Shown formatted on the contest page.</p>
|
|
192
205
|
</div>
|
|
193
206
|
<div class="cpub-form-field">
|
|
194
207
|
<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." />
|
|
208
|
+
<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
209
|
<p class="cpub-form-hint">Supports Markdown. Plain one-rule-per-line text is rendered as a numbered list.</p>
|
|
197
210
|
</div>
|
|
198
211
|
<div class="cpub-form-field">
|
|
@@ -336,7 +349,7 @@ function prizeLabel(prize: Prize): string {
|
|
|
336
349
|
<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
350
|
<div class="cpub-form-field">
|
|
338
351
|
<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." />
|
|
352
|
+
<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
353
|
<p class="cpub-form-hint">Markdown intro displayed on the Prizes tab, above the individual prizes.</p>
|
|
341
354
|
</div>
|
|
342
355
|
<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) {
|