feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging
This commit is contained in:
@@ -71,6 +71,6 @@
|
||||
</Button>
|
||||
</div>
|
||||
<pre bind:this={preRef} draggable={false}>
|
||||
<NodeViewContent as="code" class={`language-${defaultLanguage}`} {...node.attrs} />
|
||||
<NodeViewContent as="code" class={`language-${defaultLanguage} ui:pt-3!`} {...node.attrs} />
|
||||
</pre>
|
||||
</NodeViewWrapper>
|
||||
|
||||
+10
-4
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionLabels } from '@cio/question-types';
|
||||
import { Badge } from '../../../../base/badge';
|
||||
|
||||
@@ -10,13 +12,17 @@
|
||||
|
||||
let { labels, showCorrect = false, showIncorrect = false }: Props = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
const ariaCorrectLabel = $derived(getExerciseQuestionLabel(labels, 'common.correct_badge', 'Correct'));
|
||||
const ariaIncorrectLabel = $derived(getExerciseQuestionLabel(labels, 'common.incorrect_badge', 'Incorrect'));
|
||||
</script>
|
||||
|
||||
{#if showCorrect}
|
||||
<Badge variant="success" class="ui:ml-2">{label('common.correct_badge')}</Badge>
|
||||
<Badge variant="success" class="ui:ml-2 ui:p-1" aria-label={ariaCorrectLabel} title={ariaCorrectLabel}>
|
||||
<CheckIcon aria-hidden="true" class="custom" />
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if showIncorrect}
|
||||
<Badge variant="destructive" class="ui:ml-2">{label('common.incorrect_badge')}</Badge>
|
||||
<Badge variant="destructive" class="ui:ml-2 ui:p-1" aria-label={ariaIncorrectLabel} title={ariaIncorrectLabel}>
|
||||
<XIcon aria-hidden="true" class="custom" />
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Spinner } from '../../base/spinner';
|
||||
import { CheckboxField } from '../checkbox-field';
|
||||
import { Search } from '../search';
|
||||
import { cn } from '../../tools';
|
||||
import type { MultiSelectListItem } from './types';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
heading: string;
|
||||
/** When omitted, the list renders without a heading row (e.g. when the parent supplies the title). */
|
||||
heading?: string;
|
||||
headingSnippet?: Snippet;
|
||||
emptyMessage: string;
|
||||
items: MultiSelectListItem[];
|
||||
isLoading?: boolean;
|
||||
isSelected: (id: string) => boolean;
|
||||
onToggle: (id: string) => void;
|
||||
/** Prefix for each row's checkbox `name` (e.g. `assign-course` → `assign-course-<id>`). */
|
||||
@@ -14,35 +20,78 @@
|
||||
class?: string;
|
||||
/** Extra classes for the scrollable list container (`data-slot="multi-select-list-items"`). */
|
||||
listClass?: string;
|
||||
searchPlaceholder?: string;
|
||||
searchValue?: string;
|
||||
onSearchValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
heading,
|
||||
heading = '',
|
||||
headingSnippet,
|
||||
emptyMessage,
|
||||
items,
|
||||
isLoading = false,
|
||||
isSelected,
|
||||
onToggle,
|
||||
namePrefix = 'multi-select',
|
||||
class: className,
|
||||
listClass
|
||||
listClass,
|
||||
searchPlaceholder = '',
|
||||
searchValue = $bindable(''),
|
||||
onSearchValueChange
|
||||
}: Props = $props();
|
||||
|
||||
const hasHeading = $derived(heading || headingSnippet);
|
||||
</script>
|
||||
|
||||
<div data-slot="multi-select-list" class={cn('ui:border-border space-y-3 rounded-md border', className)}>
|
||||
<p class="ui:border-border border-b px-2 py-2 text-sm font-medium">
|
||||
{heading} ({items.length})
|
||||
</p>
|
||||
{#if items.length === 0}
|
||||
<p class="ui:text-muted-foreground px-2 pb-2 text-sm">{emptyMessage}</p>
|
||||
<div data-slot="multi-select-list" class={cn('ui:border-border ui:space-y-3 ui:rounded-md ui:border', className)}>
|
||||
{#if hasHeading || searchPlaceholder}
|
||||
<div
|
||||
class={cn(
|
||||
'ui:border-border ui:flex ui:gap-3 ui:border-b ui:px-2 ui:py-2 ui:items-center ui:justify-between',
|
||||
!hasHeading && 'ui:justify-end'
|
||||
)}
|
||||
>
|
||||
{#if heading}
|
||||
<p class="ui:text-sm ui:font-medium">
|
||||
{heading} ({items.length})
|
||||
</p>
|
||||
{:else if headingSnippet}
|
||||
{@render headingSnippet()}
|
||||
{/if}
|
||||
|
||||
{#if searchPlaceholder}
|
||||
<Search
|
||||
placeholder={searchPlaceholder}
|
||||
bind:value={searchValue}
|
||||
onValueChange={onSearchValueChange}
|
||||
class="ui:w-full ui:max-w-sm"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if isLoading}
|
||||
<div class="ui:flex ui:min-h-40 ui:items-center ui:justify-center ui:px-2 ui:pb-2">
|
||||
<Spinner class="ui:size-5 ui:text-muted-foreground" />
|
||||
</div>
|
||||
{:else if items.length === 0}
|
||||
<p class="ui:text-muted-foreground ui:px-2 ui:pb-2 ui:text-sm">{emptyMessage}</p>
|
||||
{:else}
|
||||
<div data-slot="multi-select-list-items" class={cn('max-h-60 space-y-2 overflow-y-auto px-2 pb-2', listClass)}>
|
||||
<div
|
||||
data-slot="multi-select-list-items"
|
||||
class={cn('ui:max-h-60 ui:space-y-2 ui:overflow-y-auto ui:px-2 ui:pb-2', listClass)}
|
||||
>
|
||||
{#each items as item (item.id)}
|
||||
<CheckboxField
|
||||
name={`${namePrefix}-${item.id}`}
|
||||
label={item.label || item.id}
|
||||
checked={isSelected(item.id)}
|
||||
onclick={() => onToggle(item.id)}
|
||||
/>
|
||||
>
|
||||
{#if item.description}
|
||||
<p class="ui:ml-2 ui:text-muted-foreground ui:text-sm">{item.description}</p>
|
||||
{/if}
|
||||
</CheckboxField>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type MultiSelectListItem = { id: string; label: string };
|
||||
export type MultiSelectListItem = { id: string; label: string; description?: string };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as InputGroup from '../../base/input-group';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { cn } from '../../tools';
|
||||
|
||||
@@ -8,12 +9,14 @@
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
class?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
placeholder = '',
|
||||
value = $bindable(''),
|
||||
class: className = '',
|
||||
onValueChange,
|
||||
...restProps
|
||||
}: Props & Omit<ComponentProps<typeof InputGroup.Root>, 'class' | 'children'> = $props();
|
||||
|
||||
@@ -22,10 +25,26 @@
|
||||
'data-slot': 'search',
|
||||
...restProps
|
||||
});
|
||||
|
||||
function handleInput(event: Event) {
|
||||
onValueChange?.((event.currentTarget as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
function clearValue() {
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
}
|
||||
</script>
|
||||
|
||||
<InputGroup.Root {...mergedProps}>
|
||||
<InputGroup.Input {placeholder} bind:value />
|
||||
<InputGroup.Input {placeholder} bind:value oninput={handleInput} />
|
||||
{#if value}
|
||||
<InputGroup.Addon align="inline-end">
|
||||
<InputGroup.Button aria-label="Clear search" onclick={clearValue}>
|
||||
<XIcon />
|
||||
</InputGroup.Button>
|
||||
</InputGroup.Addon>
|
||||
{/if}
|
||||
<InputGroup.Addon>
|
||||
<SearchIcon />
|
||||
</InputGroup.Addon>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* HTML Sanitization utilities to prevent XSS attacks
|
||||
*/
|
||||
import { createSanitizeHtmlConfig, FORBID_TAGS, stripSvgDataUrls } from '@cio/utils/functions';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
const browser = typeof window !== 'undefined' && typeof window.document !== 'undefined';
|
||||
@@ -17,77 +18,10 @@ export function sanitizeHtml(html: string): string {
|
||||
return fallbackSanitize(html);
|
||||
}
|
||||
|
||||
let sanitized = DOMPurify.sanitize(html, {
|
||||
// Allow safe HTML tags
|
||||
ALLOWED_TAGS: [
|
||||
'p',
|
||||
'br',
|
||||
'strong',
|
||||
'b',
|
||||
'em',
|
||||
'i',
|
||||
'u',
|
||||
'span',
|
||||
'div',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'blockquote',
|
||||
'pre',
|
||||
'code',
|
||||
'a',
|
||||
'img'
|
||||
],
|
||||
// Allow safe attributes
|
||||
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'style', 'data-type', 'data-latex'],
|
||||
// Forbid dangerous attributes
|
||||
FORBID_ATTR: [
|
||||
'onerror',
|
||||
'onload',
|
||||
'onclick',
|
||||
'onmouseover',
|
||||
'onfocus',
|
||||
'onblur',
|
||||
'onchange',
|
||||
'onsubmit',
|
||||
'onreset',
|
||||
'onselect',
|
||||
'onunload'
|
||||
],
|
||||
// Forbid dangerous tags (including svg)
|
||||
FORBID_TAGS: [
|
||||
'script',
|
||||
'iframe',
|
||||
'object',
|
||||
'embed',
|
||||
'form',
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
'button',
|
||||
'meta',
|
||||
'link',
|
||||
'svg',
|
||||
'math'
|
||||
],
|
||||
// Keep content of forbidden tags
|
||||
KEEP_CONTENT: true,
|
||||
// Sanitize attributes
|
||||
SANITIZE_DOM: true,
|
||||
// Allow data attributes
|
||||
ALLOW_DATA_ATTR: false,
|
||||
// Transform URLs and filter dangerous protocols
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
|
||||
});
|
||||
let sanitized = String(DOMPurify.sanitize(html, createSanitizeHtmlConfig()));
|
||||
|
||||
// Additional post-processing to remove any SVG data URLs that might have slipped through
|
||||
sanitized = sanitized.replace(/src\s*=\s*["']data:image\/svg[^"']*["']/gi, 'src=""');
|
||||
sanitized = stripSvgDataUrls(sanitized);
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
@@ -125,20 +59,7 @@ function fallbackSanitize(html: string): string {
|
||||
html = html.replace(/expression\s*\(/gi, '');
|
||||
|
||||
// Remove dangerous tags
|
||||
const dangerousTags = [
|
||||
'script',
|
||||
'iframe',
|
||||
'object',
|
||||
'embed',
|
||||
'form',
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
'button',
|
||||
'svg',
|
||||
'math'
|
||||
];
|
||||
dangerousTags.forEach((tag) => {
|
||||
FORBID_TAGS.forEach((tag: string) => {
|
||||
const regex = new RegExp(`<${tag}\\b[^>]*>.*?<\\/${tag}>`, 'gi');
|
||||
html = html.replace(regex, '');
|
||||
const selfClosing = new RegExp(`<${tag}\\b[^>]*\\/>`, 'gi');
|
||||
|
||||
Reference in New Issue
Block a user