feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging

This commit is contained in:
Rotimi Best
2026-03-30 08:24:55 +01:00
parent ff2ad59377
commit 5a8f50f67b
131 changed files with 5643 additions and 2134 deletions
@@ -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>
@@ -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 };
+20 -1
View File
@@ -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
+4 -83
View File
@@ -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');