Files
openmaic/OpenMAIC/lib/video-export-app/cover-config.ts
2026-08-16 14:58:47 +08:00

218 lines
7.9 KiB
TypeScript

import type { Locale } from '@/lib/i18n';
import arSA from '@/lib/i18n/locales/ar-SA.json';
import enUS from '@/lib/i18n/locales/en-US.json';
import esMX from '@/lib/i18n/locales/es-MX.json';
import frFR from '@/lib/i18n/locales/fr-FR.json';
import jaJP from '@/lib/i18n/locales/ja-JP.json';
import koKR from '@/lib/i18n/locales/ko-KR.json';
import ptBR from '@/lib/i18n/locales/pt-BR.json';
import ruRU from '@/lib/i18n/locales/ru-RU.json';
import viVN from '@/lib/i18n/locales/vi-VN.json';
import zhCN from '@/lib/i18n/locales/zh-CN.json';
import zhTW from '@/lib/i18n/locales/zh-TW.json';
import type { VideoExportLabels, VideoExportCta } from '@/lib/video-export';
const DEFAULT_DESTINATION = 'open.maic.chat';
const MAX_RAW_DESTINATION_LENGTH = 96;
const ASCII_CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
const BIDI_CONTROL_CHARACTER = /[\u061c\u200e-\u200f\u202a-\u202e\u2066-\u2069]/;
const URL_SCHEME = /^[a-z][a-z\d+.-]*:/i;
const BARE_HOST_WITH_PORT = /^[a-z\d.-]+:\d+(?:\/|$)/i;
/**
* Decode only URL-serialized non-ASCII code points. ASCII escapes stay exactly
* encoded, so a valid HTTP path such as `a%2Fb` remains the same target while
* authored CJK/emoji stays human-readable and bounded by the raw input length.
*/
function decodeUnicodePathForDisplay(serializedPath: string): string | null {
if (/%(?![\da-f]{2})/i.test(serializedPath)) return null;
let decodedControl = false;
try {
const display = serializedPath.replace(/(?:%[89a-f][\da-f])+/gi, (encoded) => {
const decoded = decodeURIComponent(encoded);
decodedControl ||= /\p{Cc}/u.test(decoded);
return decoded;
});
return decodedControl ? null : display;
} catch {
return null;
}
}
function normalizePercentHexCase(value: string): string {
return value.replace(/%[\da-f]{2}/gi, (encoded) => encoded.toUpperCase());
}
const LOCALE_RESOURCES: Record<Locale, Record<string, unknown>> = {
'en-US': enUS,
'zh-CN': zhCN,
'zh-TW': zhTW,
'ja-JP': jaJP,
'ko-KR': koKR,
'es-MX': esMX,
'fr-FR': frFR,
'vi-VN': viVN,
'pt-BR': ptBR,
'ru-RU': ruRU,
'ar-SA': arSA,
};
/**
* Resolve an environment-provided CTA destination without reading environment
* state or producing side effects.
*/
export function resolveVideoExportCta(raw: string | undefined): VideoExportCta | null {
const input = raw ?? '';
const value = input.trim();
if (!value) return { destination: DEFAULT_DESTINATION };
if (value.toLowerCase() === 'off') return null;
if (
input.length > MAX_RAW_DESTINATION_LENGTH ||
ASCII_CONTROL_CHARACTER.test(input) ||
BIDI_CONTROL_CHARACTER.test(input)
) {
return null;
}
const hasHttpScheme = /^https?:\/\//i.test(value);
if (URL_SCHEME.test(value) && !hasHttpScheme && !BARE_HOST_WITH_PORT.test(value)) return null;
if (value.includes('?') || value.includes('#') || value.includes('\\')) return null;
const displayInput = hasHttpScheme ? value.replace(/^https?:\/\//i, '') : value;
if (!displayInput || displayInput.startsWith('/')) return null;
const firstSlash = displayInput.indexOf('/');
const sourceAuthority = firstSlash === -1 ? displayInput : displayInput.slice(0, firstSlash);
if (!sourceAuthority || sourceAuthority.includes('@')) return null;
try {
const url = new URL(hasHttpScheme ? value : `https://${value}`);
if (
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
url.username ||
url.password ||
url.search ||
url.hash ||
!url.hostname
) {
return null;
}
// Keep the validated URL as the target identity, but derive the display
// host from the source authority so an IDN stays readable instead of
// expanding to punycode. The round-trip check below proves both map to the
// same host and port.
const sourceHostname = (() => {
if (sourceAuthority.startsWith('[')) {
const closingBracket = sourceAuthority.indexOf(']');
if (closingBracket === -1) return null;
const suffix = sourceAuthority.slice(closingBracket + 1);
if (suffix && !/^:\d*$/.test(suffix)) return null;
return sourceAuthority.slice(0, closingBracket + 1);
}
const lastColon = sourceAuthority.lastIndexOf(':');
if (lastColon === -1) return sourceAuthority;
const port = sourceAuthority.slice(lastColon + 1);
if (!/^\d*$/.test(port)) return null;
return sourceAuthority.slice(0, lastColon);
})();
if (!sourceHostname) return null;
let displayHostname: string;
try {
displayHostname = decodeURIComponent(sourceHostname).replace(/[A-Z]/g, (char) =>
char.toLowerCase(),
);
} catch {
return null;
}
if (
!displayHostname ||
ASCII_CONTROL_CHARACTER.test(displayHostname) ||
BIDI_CONTROL_CHARACTER.test(displayHostname) ||
/[\\/?#@]/.test(displayHostname) ||
(!sourceHostname.startsWith('[') && displayHostname.includes(':'))
) {
return null;
}
const displayAuthority = `${displayHostname}${url.port ? `:${url.port}` : ''}`;
const authorityCheck = new URL(`${url.protocol}//${displayAuthority}`);
if (authorityCheck.hostname !== url.hostname || authorityCheck.port !== url.port) return null;
// URL serialization percent-encodes authored Unicode. Decode only those
// non-ASCII code points for display; valid encoded HTTP delimiters remain
// encoded and therefore retain their exact target semantics.
const serializedPath = url.pathname.replace(/\/+$/, '');
const displayPath = decodeUnicodePathForDisplay(serializedPath);
if (displayPath === null) return null;
if (
ASCII_CONTROL_CHARACTER.test(displayPath) ||
BIDI_CONTROL_CHARACTER.test(displayPath) ||
displayPath.includes('\\') ||
displayPath.includes('?') ||
displayPath.includes('#')
) {
return null;
}
const pathCheck = new URL(`${url.protocol}//${url.host}${displayPath}`);
if (
normalizePercentHexCase(pathCheck.pathname.replace(/\/+$/, '')) !==
normalizePercentHexCase(serializedPath)
) {
return null;
}
const destination = `${displayAuthority}${displayPath}`;
if (destination.length > value.length || destination.length > MAX_RAW_DESTINATION_LENGTH) {
return null;
}
return { destination };
} catch {
return null;
}
}
/** Resolve every learner-facing cover label synchronously for one export locale. */
export function getVideoExportCoverLabels(locale: Locale): VideoExportLabels {
const resource = LOCALE_RESOURCES[locale];
const at = (key: string): string => {
const value = key.split('.').reduce<unknown>((current, part) => {
if (!current || typeof current !== 'object') return undefined;
return (current as Record<string, unknown>)[part];
}, resource);
if (typeof value !== 'string') {
throw new Error(`Missing video-export cover label "${key}" for locale "${locale}"`);
}
return value;
};
return {
quiz: at('quiz.title'),
questions: at('quiz.questionsCount'),
points: at('quiz.pointsSuffix'),
singleChoice: at('quiz.singleChoice'),
multipleChoice: at('quiz.multipleChoice'),
shortAnswer: at('quiz.shortAnswer'),
answerPlaceholder: at('quiz.inputPlaceholder'),
pbl: at('pbl.v2.hero.title'),
stages: at('pbl.v2.hero.stage'),
tasks: at('pbl.v2.hero.task'),
gains: at('pbl.v2.hero.youWillLearn'),
instructor: at('pbl.v2.hero.tutor'),
instructorTagline: at('pbl.v2.hero.instructorTagline'),
scenarioCharacter: at('pbl.v2.hero.scenarioCharacter'),
scenarioCharacterTagline: at('pbl.v2.hero.scenarioCharacterTagline'),
quizCtaPrompt: at('export.videoQuizCtaPrompt'),
pblCtaPrompt: at('export.videoPblCtaPrompt'),
ctaVisit: at('export.videoCtaVisit'),
interactive: {
fallback: at('export.videoFailed'),
readyTimeout: at('export.videoRendering'),
loadFailure: at('export.videoFailed'),
readyFailure: at('export.videoFailed'),
runtimeFailure: at('export.videoFailed'),
},
};
}