feat: implement automation feature

This commit is contained in:
Rotimi Best
2026-03-18 23:39:12 +01:00
parent 576d0366de
commit ef4424e267
121 changed files with 4544 additions and 1055 deletions
@@ -0,0 +1,71 @@
// SaaS default CSP domains, baked in at build time.
// Self-hosted (adapter-node) starts with empty lists; runtime domains
// are added via env vars in hooks.server.ts so pre-built Docker images stay configurable.
const saasDefaults = {
scriptSrc: [
'https://assets.cdn.clsrio.com',
'https://cdnjs.cloudflare.com',
'https://*.posthog.com',
'https://*.senja.io',
'https://umami.hz.oncws.com',
'https://www.youtube.com',
'https://youtube.com',
'https://google.com'
],
styleSrc: [
'https://cdn.plyr.io',
'https://unpkg.com/katex@0.12.0/dist/katex.min.css',
'https://assets.cdn.clsrio.com/eqneditor_1.css',
'https://fonts.googleapis.com'
],
connectSrc: [
'https://*.classroomio.com',
'https://classroomio.com',
'https://app.classroomio.com',
'https://api.classroomio.com',
'https://pgrest.classroomio.com',
'https://play.classroomio.com',
'wss://*.classroomio.com',
'https://assets.cdn.clsrio.com',
'https://cdn.plyr.io',
'https://*.posthog.com',
'https://umami.hz.oncws.com',
'https://*.r2.cloudflarestorage.com',
'https://*.senja.io',
'https://*.ytimg.com',
'https://noembed.com'
],
frameSrc: [
'https://www.youtube.com',
'https://youtube.com',
'https://www.youtube-nocookie.com',
'https://www.google.com',
'https://google.com'
],
fontSrc: ['https://fonts.gstatic.com', 'https://cdn.plyr.io'],
mediaSrc: ['https:']
};
/**
* @param {boolean} isSelfHosted
* @param {string | undefined} serverUrl - PUBLIC_SERVER_URL, added to connect-src for SaaS builds
*/
export function getCspDomains(isSelfHosted, serverUrl) {
if (isSelfHosted) {
return {
scriptSrc: [],
styleSrc: [],
connectSrc: [],
frameSrc: [],
fontSrc: [],
mediaSrc: [],
apiOrigin: null
};
}
return {
...saasDefaults,
apiOrigin: serverUrl ?? null
};
}
+75
View File
@@ -0,0 +1,75 @@
function parseCspDomains(value: string | undefined): string[] {
if (!value) return [];
return value
.split(',')
.map((d) => d.trim())
.filter(Boolean)
.map((d) => (d.startsWith('http://') || d.startsWith('https://') ? d : `https://${d}`));
}
function buildCspExtensions(): Record<string, string[]> {
const allDomains = parseCspDomains(process.env.ALLOWED_EXTERNAL_DOMAINS);
const or = (envVar: string) => (allDomains.length ? allDomains : parseCspDomains(process.env[envVar]));
const scriptSrc = or('CSP_SCRIPT_SRC_DOMAINS');
const styleSrc = or('CSP_STYLE_SRC_DOMAINS');
const connectSrcBase = or('CSP_CONNECT_SRC_DOMAINS');
const frameSrc = or('CSP_FRAME_SRC_DOMAINS');
const fontSrc = or('CSP_FONT_SRC_DOMAINS');
const mediaSrc = or('CSP_MEDIA_SRC_DOMAINS');
const serverUrl = process.env.PUBLIC_SERVER_URL;
const connectSrc = serverUrl ? [...connectSrcBase, serverUrl] : connectSrcBase;
const extensions: Record<string, string[]> = {};
if (scriptSrc.length) extensions['script-src'] = scriptSrc;
if (styleSrc.length) {
extensions['style-src'] = styleSrc;
extensions['style-src-elem'] = styleSrc;
}
if (connectSrc.length) extensions['connect-src'] = connectSrc;
if (frameSrc.length) extensions['frame-src'] = frameSrc;
if (fontSrc.length) extensions['font-src'] = fontSrc;
if (mediaSrc.length) {
extensions['img-src'] = mediaSrc;
extensions['media-src'] = mediaSrc;
}
return extensions;
}
let cachedExtensions: Record<string, string[]> | null = null;
function getExtensions(): Record<string, string[]> {
if (!cachedExtensions) cachedExtensions = buildCspExtensions();
return cachedExtensions;
}
function extendHeader(header: string, extensions: Record<string, string[]>): string {
return header
.split(';')
.map((d) => d.trim())
.filter(Boolean)
.map((directive) => {
const spaceIdx = directive.indexOf(' ');
const name = spaceIdx === -1 ? directive : directive.substring(0, spaceIdx);
const extra = extensions[name];
return extra?.length ? `${directive} ${extra.join(' ')}` : directive;
})
.join('; ');
}
export function applyCspExtensions(response: Response): Response {
const extensions = getExtensions();
if (Object.keys(extensions).length === 0) return response;
for (const name of ['content-security-policy', 'content-security-policy-report-only']) {
const header = response.headers.get(name);
if (header) {
response.headers.set(name, extendHeader(header, extensions));
}
}
return response;
}
@@ -3,6 +3,8 @@ import { browser } from '$app/environment';
export const isHtmlValueEmpty = (html: string) => {
if (!html || typeof html !== 'string') return true;
if (/<svg\b/i.test(html)) return false;
if (!document || !browser) return false;
return getTextFromHTML(html) === '';
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Opdater",
"unsaved_changes": {
"message": "Du har ikke-gemte ændringer. Er du sikker på, at du vil tage afsted?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Søg efter lektioner eller øvelser",
"empty": "Ingen resultater fundet"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Tilbage til kurset",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Dato oprettet",
"published": "Udgivet",
"lessons": "Lektioner"
@@ -1592,6 +1603,9 @@
"clear_all": "Ryd alle",
"popover_title": "Filtre",
"sort": "Sorter",
"order": "Rækkefølge",
"ascending": "Asc",
"descending": "Desc",
"tags": "Tags",
"empty_tags": "Ingen tags tilgængelige"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} er aktiveret for dit domæne",
"sso_redirect_error": "SSO-logon er forkert konfigureret. Indstil PUBLIC_SERVER_URL til din API-server.",
"sso_required": "Log venligst ind med SSO. Adgangskodelogin er deaktiveret.",
"sign_in_with_sso": "Log ind med {provider}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Aktualisieren",
"unsaved_changes": {
"message": "Sie haben nicht gespeicherte Änderungen. Sind Sie sicher, dass Sie gehen möchten?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Suchen Sie nach Lektionen oder Übungen",
"empty": "Keine Ergebnisse gefunden"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Zurück zum Kurs",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Erstellungsdatum",
"published": "Veröffentlicht",
"lessons": "Lektionen"
@@ -1592,6 +1603,9 @@
"clear_all": "Alles löschen",
"popover_title": "Filter",
"sort": "Sortieren",
"order": "Reihenfolge",
"ascending": "Asc",
"descending": "Desc",
"tags": "Schlagworte",
"empty_tags": "Keine Tags verfügbar"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} ist für Ihre Domain aktiviert",
"sso_redirect_error": "Die SSO-Anmeldung ist falsch konfiguriert. Bitte legen Sie PUBLIC_SERVER_URL auf Ihren API-Server fest.",
"sso_required": "Bitte melden Sie sich mit SSO an. Die Passwortanmeldung ist deaktiviert.",
"sign_in_with_sso": "Melden Sie sich mit {provider} an",
@@ -93,6 +93,9 @@
"placeholder": "Search"
}
},
"common": {
"refresh": "Refresh"
},
"login": {
"welcome": "Welcome back",
"email": "Your email",
@@ -240,7 +243,7 @@
},
"login": {
"or_continue_with": "Or continue with email and password",
"sso_enabled": "{provider} is enabled for your domain",
"sso_enabled": "{provider} is enabled for this organization",
"sso_redirect_error": "SSO sign-in is misconfigured. Please set PUBLIC_SERVER_URL to your API server.",
"sso_required": "Please sign in with SSO. Password login is disabled.",
"sign_in_with_sso": "Sign in with {provider}",
@@ -566,6 +569,7 @@
"actions": {
"create_tag": "Create Tag",
"create_group": "Create Tag Group",
"actions_menu": "Tag actions",
"edit_group": "Edit Group",
"edit_tag": "Edit Tag",
"save": "Save",
@@ -865,10 +869,19 @@
"clear_all": "Clear all",
"popover_title": "Filters",
"sort": "Sort",
"order": "Order",
"ascending": "Asc",
"descending": "Desc",
"tags": "Tags",
"empty_tags": "No tags available"
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Date Created",
"published": "Published",
"lessons": "Lessons"
@@ -947,6 +960,10 @@
"placeholder": "Search lessons or exercises",
"empty": "No results found"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"navItem": {
"news_feed": {
"heading": "News Feed",
@@ -2083,6 +2100,10 @@
"created_at_label": "Created:",
"last_used_label": "Last used:",
"never_used": "Never",
"table_key": "Key",
"table_status": "Status",
"table_created": "Created",
"table_last_used": "Last used",
"scopes_label": "Scopes:",
"revoke_confirm": "Revoke this automation key?",
"rotate_confirm": "Rotate this automation key?"
@@ -2097,9 +2118,7 @@
"keys_description": "Use one MCP key per integration so access can be rotated or revoked cleanly.",
"rate_limits_title": "Rate limits",
"rate_limits_description": "Read / write / publish requests allowed per minute.",
"used_label": "Used:",
"per_key_label": "Per key:",
"per_org_label": "Per org:"
"used_label": "Used:"
},
"keys": {
"title": "MCP keys",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Actualizar",
"unsaved_changes": {
"message": "Tienes cambios sin guardar. ¿Estás seguro de que quieres irte?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Buscar lecciones o ejercicios",
"empty": "No se encontraron resultados"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "volver al curso",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Fecha de creación",
"published": "Publicado",
"lessons": "Lecciones"
@@ -1592,6 +1603,9 @@
"clear_all": "Borrar todo",
"popover_title": "Filtros",
"sort": "ordenar",
"order": "Orden",
"ascending": "Asc",
"descending": "Desc",
"tags": "Etiquetas",
"empty_tags": "No hay etiquetas disponibles"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} está habilitado para tu dominio",
"sso_redirect_error": "El inicio de sesión SSO está mal configurado. Configure PUBLIC_SERVER_URL en su servidor API.",
"sso_required": "Inicie sesión con SSO. El inicio de sesión con contraseña está deshabilitado.",
"sign_in_with_sso": "Iniciar sesión con {proveedor}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Actualiser",
"unsaved_changes": {
"message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir partir ?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Rechercher des leçons ou des exercices",
"empty": "Aucun résultat trouvé"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Retour au cours",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Date de création",
"published": "Publié",
"lessons": "Leçons"
@@ -1592,6 +1603,9 @@
"clear_all": "Tout effacer",
"popover_title": "Filtres",
"sort": "Trier",
"order": "Ordre",
"ascending": "Asc",
"descending": "Desc",
"tags": "Balises",
"empty_tags": "Aucune balise disponible"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} est activé pour votre domaine",
"sso_redirect_error": "La connexion SSO est mal configurée. Veuillez définir PUBLIC_SERVER_URL sur votre serveur API.",
"sso_required": "Veuillez vous connecter avec SSO. La connexion par mot de passe est désactivée.",
"sign_in_with_sso": "Connectez-vous avec {provider}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "रीफ़्रेश करें",
"unsaved_changes": {
"message": "आपके पास सहेजे नहीं गए परिवर्तन हैं. क्या आपकी वाकई छोड़ने की इच्छा है?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "पाठ या अभ्यास खोजें",
"empty": "कोई परिणाम नहीं मिला"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "पाठ्यक्रम पर वापस जाएँ",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "दिनांक निर्मित",
"published": "प्रकाशित",
"lessons": "सबक"
@@ -1592,6 +1603,9 @@
"clear_all": "सब साफ़ करें",
"popover_title": "फिल्टर",
"sort": "क्रमबद्ध करें",
"order": "क्रम",
"ascending": "Asc",
"descending": "Desc",
"tags": "टैग",
"empty_tags": "कोई टैग उपलब्ध नहीं है"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "आपके डोमेन के लिए {प्रदाता} सक्षम है",
"sso_redirect_error": "SSO साइन-इन ग़लत कॉन्फ़िगर किया गया है. कृपया अपने एपीआई सर्वर पर PUBLIC_SERVER_URL सेट करें।",
"sso_required": "कृपया एसएसओ के साथ साइन इन करें। पासवर्ड लॉगिन अक्षम है.",
"sign_in_with_sso": "{प्रदाता} के साथ साइन इन करें",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Odśwież",
"unsaved_changes": {
"message": "Masz niezapisane zmiany. Czy na pewno chcesz wyjść?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Wyszukaj lekcje lub ćwiczenia",
"empty": "Nie znaleziono żadnych wyników"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Powrót na kurs",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Data utworzenia",
"published": "Opublikowano",
"lessons": "Lekcje"
@@ -1592,6 +1603,9 @@
"clear_all": "Wyczyść wszystko",
"popover_title": "Filtry",
"sort": "Sortuj",
"order": "Kolejność",
"ascending": "Asc",
"descending": "Desc",
"tags": "Tagi",
"empty_tags": "Brak dostępnych tagów"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "Usługa {provider} jest włączona w Twojej domenie",
"sso_redirect_error": "Logowanie jednokrotne jest błędnie skonfigurowane. Ustaw PUBLIC_SERVER_URL na swój serwer API.",
"sso_required": "Proszę zalogować się za pomocą SSO. Logowanie hasłem jest wyłączone.",
"sign_in_with_sso": "Zaloguj się przez {provider}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Atualizar",
"unsaved_changes": {
"message": "Você tem alterações não salvas. Tem certeza de que deseja sair?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Pesquise aulas ou exercícios",
"empty": "Nenhum resultado encontrado"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "De volta ao curso",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Data de criação",
"published": "Publicado",
"lessons": "Lições"
@@ -1592,6 +1603,9 @@
"clear_all": "Limpar tudo",
"popover_title": "Filtros",
"sort": "Classificar",
"order": "Ordem",
"ascending": "Asc",
"descending": "Desc",
"tags": "Etiquetas",
"empty_tags": "Nenhuma tag disponível"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} está ativado para seu domínio",
"sso_redirect_error": "O login do SSO está configurado incorretamente. Defina PUBLIC_SERVER_URL para seu servidor API.",
"sso_required": "Faça login com SSO. O login por senha está desabilitado.",
"sign_in_with_sso": "Faça login com {provedor}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Обновить",
"unsaved_changes": {
"message": "У вас есть несохраненные изменения. Вы уверены, что хотите уйти?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Поиск уроков или упражнений",
"empty": "Результаты не найдены"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Вернуться к курсу",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Дата создания",
"published": "Опубликовано",
"lessons": "Уроки"
@@ -1592,6 +1603,9 @@
"clear_all": "Очистить все",
"popover_title": "Фильтры",
"sort": "Сортировать",
"order": "Порядок",
"ascending": "Asc",
"descending": "Desc",
"tags": "Теги",
"empty_tags": "Нет доступных тегов"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} включен для вашего домена",
"sso_redirect_error": "Вход в систему единого входа настроен неправильно. Установите PUBLIC_SERVER_URL для вашего сервера API.",
"sso_required": "Пожалуйста, войдите с помощью SSO. Вход по паролю отключен.",
"sign_in_with_sso": "Войдите через {provider}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Làm mới",
"unsaved_changes": {
"message": "Bạn có những thay đổi chưa được lưu. Bạn có chắc chắn muốn rời đi không?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Tìm kiếm bài học hoặc bài tập",
"empty": "Không tìm thấy kết quả nào"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Quay lại khóa học",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Ngày tạo",
"published": "Đã xuất bản",
"lessons": "Bài học"
@@ -1592,6 +1603,9 @@
"clear_all": "Xóa tất cả",
"popover_title": "Bộ lọc",
"sort": "Sắp xếp",
"order": "Thứ tự",
"ascending": "Asc",
"descending": "Desc",
"tags": "Thẻ",
"empty_tags": "Không có thẻ nào"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} đã được bật cho miền của bạn",
"sso_redirect_error": "Đăng nhập SSO bị định cấu hình sai. Vui lòng đặt PUBLIC_SERVER_URL cho máy chủ API của bạn.",
"sso_required": "Vui lòng đăng nhập bằng SSO. Đăng nhập bằng mật khẩu bị vô hiệu hóa.",
"sign_in_with_sso": "Đăng nhập bằng {nhà cung cấp}",