import fs from 'node:fs'; import path from 'node:path'; import { Buffer } from 'node:buffer'; import express from 'express'; import fetch from 'node-fetch'; import sanitize from 'sanitize-filename'; import { sync as writeFileAtomicSync } from 'write-file-atomic'; import { getConfigValue, color, setPermissionsSync } from '../util.js'; import { write } from '../character-card-parser.js'; import { serverDirectory } from '../server-directory.js'; import { DEFAULT_AVATAR_PATH } from '../constants.js'; const contentDirectory = path.join(serverDirectory, 'default/content'); const scaffoldDirectory = path.join(serverDirectory, 'default/scaffold'); const contentIndexPath = path.join(contentDirectory, 'index.json'); const scaffoldIndexPath = path.join(scaffoldDirectory, 'index.json'); const WHITELIST_GENERIC_URL_DOWNLOAD_SOURCES = getConfigValue('whitelistImportDomains', []); const USER_AGENT = 'SillyTavern'; /** * @typedef {Object} ContentItem * @property {string} filename * @property {string} type * @property {string} [name] * @property {string|null} [folder] */ /** * @typedef {string} ContentType * @enum {string} */ export const CONTENT_TYPES = { SETTINGS: 'settings', CHARACTER: 'character', SPRITES: 'sprites', BACKGROUND: 'background', WORLD: 'world', AVATAR: 'avatar', THEME: 'theme', WORKFLOW: 'workflow', KOBOLD_PRESET: 'kobold_preset', OPENAI_PRESET: 'openai_preset', NOVEL_PRESET: 'novel_preset', TEXTGEN_PRESET: 'textgen_preset', INSTRUCT: 'instruct', CONTEXT: 'context', MOVING_UI: 'moving_ui', QUICK_REPLIES: 'quick_replies', SYSPROMPT: 'sysprompt', REASONING: 'reasoning', }; /** * Gets the default presets from the content directory. * @param {import('../users.js').UserDirectoryList} directories User directories * @returns {object[]} Array of default presets */ export function getDefaultPresets(directories) { try { const contentIndex = getContentIndex(); const presets = []; for (const contentItem of contentIndex) { if (contentItem.type.endsWith('_preset') || ['instruct', 'context', 'sysprompt', 'reasoning'].includes(contentItem.type)) { contentItem.name = path.parse(contentItem.filename).name; contentItem.folder = getTargetByType(contentItem.type, directories); presets.push(contentItem); } } return presets; } catch (err) { console.warn('Failed to get default presets', err); return []; } } /** * Gets a default JSON file from the content directory. * @param {string} filename Name of the file to get * @returns {object | null} JSON object or null if the file doesn't exist */ export function getDefaultPresetFile(filename) { try { const contentPath = path.join(contentDirectory, filename); if (!fs.existsSync(contentPath)) { return null; } const fileContent = fs.readFileSync(contentPath, 'utf8'); return JSON.parse(fileContent); } catch (err) { console.warn(`Failed to get default file ${filename}`, err); return null; } } /** * Seeds content for a user. * @param {ContentItem[]} contentIndex Content index * @param {import('../users.js').UserDirectoryList} directories User directories * @param {string[]} forceCategories List of categories to force check (even if content check is skipped) * @returns {Promise} Whether any content was added */ async function seedContentForUser(contentIndex, directories, forceCategories) { let anyContentAdded = false; if (!fs.existsSync(directories.root)) { fs.mkdirSync(directories.root, { recursive: true }); } const contentLogPath = path.join(directories.root, 'content.log'); const contentLog = getContentLog(contentLogPath); for (const contentItem of contentIndex) { // If the content item is already in the log, skip it if (contentLog.includes(contentItem.filename) && !forceCategories?.includes(contentItem.type)) { continue; } if (!contentItem.folder) { console.warn(`Content file ${contentItem.filename} has no parent folder`); continue; } const contentPath = path.join(contentItem.folder, contentItem.filename); if (!fs.existsSync(contentPath)) { console.warn(`Content file ${contentItem.filename} is missing`); continue; } const contentTarget = getTargetByType(contentItem.type, directories); if (!contentTarget) { console.warn(`Content file ${contentItem.filename} has unknown type ${contentItem.type}`); continue; } const basePath = path.parse(contentItem.filename).base; const targetPath = path.join(contentTarget, basePath); contentLog.push(contentItem.filename); if (fs.existsSync(targetPath)) { console.warn(`Content file ${contentItem.filename} already exists in ${contentTarget}`); continue; } fs.cpSync(contentPath, targetPath, { recursive: true, force: false }); setPermissionsSync(targetPath); console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`); anyContentAdded = true; } writeFileAtomicSync(contentLogPath, contentLog.join('\n')); return anyContentAdded; } /** * Checks for new content and seeds it for all users. * @param {import('../users.js').UserDirectoryList[]} directoriesList List of user directories * @param {string[]} forceCategories List of categories to force check (even if content check is skipped) * @returns {Promise} */ export async function checkForNewContent(directoriesList, forceCategories = []) { try { const contentCheckSkip = getConfigValue('skipContentCheck', false, 'boolean'); if (contentCheckSkip && forceCategories?.length === 0) { return; } const contentIndex = getContentIndex(); let anyContentAdded = false; for (const directories of directoriesList) { const seedResult = await seedContentForUser(contentIndex, directories, forceCategories); if (seedResult) { anyContentAdded = true; } } if (anyContentAdded && !contentCheckSkip && forceCategories?.length === 0) { console.info(); console.info(`${color.blue('If you don\'t want to receive content updates in the future, set')} ${color.yellow('skipContentCheck')} ${color.blue('to true in the config.yaml file.')}`); console.info(); } } catch (err) { console.error('Content check failed', err); } } /** * Gets combined content index from the content and scaffold directories. * @returns {ContentItem[]} Array of content index */ function getContentIndex() { const result = []; if (fs.existsSync(scaffoldIndexPath)) { const scaffoldIndexText = fs.readFileSync(scaffoldIndexPath, 'utf8'); const scaffoldIndex = JSON.parse(scaffoldIndexText); if (Array.isArray(scaffoldIndex)) { scaffoldIndex.forEach((item) => { item.folder = scaffoldDirectory; }); result.push(...scaffoldIndex); } } if (fs.existsSync(contentIndexPath)) { const contentIndexText = fs.readFileSync(contentIndexPath, 'utf8'); const contentIndex = JSON.parse(contentIndexText); if (Array.isArray(contentIndex)) { contentIndex.forEach((item) => { item.folder = contentDirectory; }); result.push(...contentIndex); } } return result; } /** * Gets content by type and format. * @param {string} type Type of content * @param {'json'|'string'|'raw'} format Format of content * @returns {string[]|Buffer[]} Array of content */ export function getContentOfType(type, format) { const contentIndex = getContentIndex(); const indexItems = contentIndex.filter((item) => item.type === type && item.folder); const files = []; for (const item of indexItems) { if (!item.folder) { continue; } try { const filePath = path.join(item.folder, item.filename); const fileContent = fs.readFileSync(filePath); switch (format) { case 'json': files.push(JSON.parse(fileContent.toString())); break; case 'string': files.push(fileContent.toString()); break; case 'raw': files.push(fileContent); break; } } catch { // Ignore errors } } return files; } /** * Gets the target directory for the specified asset type. * @param {ContentType} type Asset type * @param {import('../users.js').UserDirectoryList} directories User directories * @returns {string | null} Target directory */ function getTargetByType(type, directories) { switch (type) { case CONTENT_TYPES.SETTINGS: return directories.root; case CONTENT_TYPES.CHARACTER: return directories.characters; case CONTENT_TYPES.SPRITES: return directories.characters; case CONTENT_TYPES.BACKGROUND: return directories.backgrounds; case CONTENT_TYPES.WORLD: return directories.worlds; case CONTENT_TYPES.AVATAR: return directories.avatars; case CONTENT_TYPES.THEME: return directories.themes; case CONTENT_TYPES.WORKFLOW: return directories.comfyWorkflows; case CONTENT_TYPES.KOBOLD_PRESET: return directories.koboldAI_Settings; case CONTENT_TYPES.OPENAI_PRESET: return directories.openAI_Settings; case CONTENT_TYPES.NOVEL_PRESET: return directories.novelAI_Settings; case CONTENT_TYPES.TEXTGEN_PRESET: return directories.textGen_Settings; case CONTENT_TYPES.INSTRUCT: return directories.instruct; case CONTENT_TYPES.CONTEXT: return directories.context; case CONTENT_TYPES.MOVING_UI: return directories.movingUI; case CONTENT_TYPES.QUICK_REPLIES: return directories.quickreplies; case CONTENT_TYPES.SYSPROMPT: return directories.sysprompt; case CONTENT_TYPES.REASONING: return directories.reasoning; default: return null; } } /** * Gets the content log from the content log file. * @param {string} contentLogPath Path to the content log file * @returns {string[]} Array of content log lines */ function getContentLog(contentLogPath) { if (!fs.existsSync(contentLogPath)) { return []; } const contentLogText = fs.readFileSync(contentLogPath, 'utf8'); return contentLogText.split('\n'); } async function downloadChubLorebook(id) { const [lorebooks, creatorName, projectName] = id.split('/'); const result = await fetch(`https://api.chub.ai/api/${lorebooks}/${creatorName}/${projectName}`, { method: 'GET', headers: { 'Accept': 'application/json', 'User-Agent': USER_AGENT }, }); if (!result.ok) { const text = await result.text(); console.error('Chub returned error', result.statusText, text); throw new Error('Failed to fetch lorebook metadata'); } /** @type {any} */ const metadata = await result.json(); const projectId = metadata.node?.id; if (!projectId) { throw new Error('Project ID not found in lorebook metadata'); } const downloadUrl = `https://api.chub.ai/api/v4/projects/${projectId}/repository/files/raw%252Fsillytavern_raw.json/raw`; const downloadResult = await fetch(downloadUrl, { method: 'GET', headers: { 'Accept': 'application/json', 'User-Agent': USER_AGENT }, }); if (!downloadResult.ok) { const text = await downloadResult.text(); console.error('Chub returned error', downloadResult.statusText, text); throw new Error('Failed to download lorebook'); } const name = projectName; const buffer = Buffer.from(await downloadResult.arrayBuffer()); const fileName = `${sanitize(name)}.json`; const fileType = downloadResult.headers.get('content-type'); return { buffer, fileName, fileType }; } async function downloadChubCharacter(id) { const [creatorName, projectName] = id.split('/'); const result = await fetch(`https://api.chub.ai/api/characters/${creatorName}/${projectName}`, { method: 'GET', headers: { 'Accept': 'application/json', 'User-Agent': USER_AGENT }, }); if (!result.ok) { const text = await result.text(); console.error('Chub returned error', result.statusText, text); throw new Error('Failed to fetch character metadata'); } /** @type {any} */ const metadata = await result.json(); const downloadUrl = metadata.node?.max_res_url; if (!downloadUrl) { throw new Error('Download URL not found in character metadata'); } const downloadResult = await fetch(downloadUrl); if (!downloadResult.ok) { const text = await downloadResult.text(); console.error('Chub returned error', downloadResult.statusText, text); throw new Error('Failed to download character'); } const buffer = Buffer.from(await downloadResult.arrayBuffer()); const fileName = downloadResult.headers.get('content-disposition')?.split('filename=')[1]?.replace(/["']/g, '') || `${sanitize(projectName)}.png`; const fileType = downloadResult.headers.get('content-type'); return { buffer, fileName, fileType }; } /** * Downloads a character card from the Pygsite. * @param {string} id UUID of the character * @returns {Promise<{buffer: Buffer, fileName: string, fileType: string}>} */ async function downloadPygmalionCharacter(id) { const result = await fetch(`https://server.pygmalion.chat/api/export/character/${id}/v2`); if (!result.ok) { const text = await result.text(); console.error('Pygsite returned error', result.status, text); throw new Error('Failed to download character'); } /** @type {any} */ const jsonData = await result.json(); const characterData = jsonData?.character; if (!characterData || typeof characterData !== 'object') { console.error('Pygsite returned invalid character data', jsonData); throw new Error('Failed to download character'); } try { const avatarUrl = characterData?.data?.avatar; if (!avatarUrl) { console.error('Pygsite character does not have an avatar', characterData); throw new Error('Failed to download avatar'); } const avatarResult = await fetch(avatarUrl); const avatarBuffer = Buffer.from(await avatarResult.arrayBuffer()); const cardBuffer = write(avatarBuffer, JSON.stringify(characterData)); return { buffer: cardBuffer, fileName: `${sanitize(id)}.png`, fileType: 'image/png', }; } catch (e) { console.error('Failed to download avatar, using JSON instead', e); return { buffer: Buffer.from(JSON.stringify(jsonData)), fileName: `${sanitize(id)}.json`, fileType: 'application/json', }; } } /** * * @param {String} str * @returns { { id: string, type: "character" | "lorebook" } | null } */ function parseChubUrl(str) { const splitStr = str.split('/'); const length = splitStr.length; if (length < 2) { return null; } let domainIndex = -1; splitStr.forEach((part, index) => { if (part === 'www.chub.ai' || part === 'chub.ai' || part === 'www.characterhub.org' || part === 'characterhub.org') { domainIndex = index; } }); const lastTwo = domainIndex !== -1 ? splitStr.slice(domainIndex + 1) : splitStr; const firstPart = lastTwo[0].toLowerCase(); if (firstPart === 'characters' || firstPart === 'lorebooks') { const type = firstPart === 'characters' ? 'character' : 'lorebook'; const id = type === 'character' ? lastTwo.slice(1).join('/') : lastTwo.join('/'); return { id: id, type: type, }; } else if (length === 2) { return { id: lastTwo.join('/'), type: 'character', }; } return null; } // Warning: Some characters might not exist in JannyAI.me async function downloadJannyCharacter(uuid) { // This endpoint is being guarded behind Bot Fight Mode of Cloudflare // So hosted ST on Azure/AWS/GCP/Collab might get blocked by IP // Should work normally on self-host PC/Android const result = await fetch('https://api.jannyai.com/api/v1/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 'characterId': uuid, }), }); if (result.ok) { /** @type {any} */ const downloadResult = await result.json(); if (downloadResult.status === 'ok') { const imageResult = await fetch(downloadResult.downloadUrl); const buffer = Buffer.from(await imageResult.arrayBuffer()); const fileName = `${sanitize(uuid)}.png`; const fileType = imageResult.headers.get('content-type'); return { buffer, fileName, fileType }; } } console.error('Janny returned error', result.statusText, await result.text()); throw new Error('Failed to download character'); } //Download Character Cards from AICharactersCards.com (AICC) API. async function downloadAICCCharacter(id) { const apiURL = `https://aicharactercards.com/wp-json/pngapi/v1/image/${id}`; try { const response = await fetch(apiURL); if (!response.ok) { throw new Error(`Failed to download character: ${response.statusText}`); } const contentType = response.headers.get('content-type') || 'image/png'; // Default to 'image/png' if header is missing const buffer = Buffer.from(await response.arrayBuffer()); const fileName = `${sanitize(id)}.png`; // Assuming PNG, but adjust based on actual content or headers return { buffer: buffer, fileName: fileName, fileType: contentType, }; } catch (error) { console.error('Error downloading character:', error); throw error; } } /** * Parses an aicharactercards URL to extract the path. * @param {string} url URL to parse * @returns {string | null} AICC path */ function parseAICC(url) { const pattern = /^https?:\/\/aicharactercards\.com\/character-cards\/([^/]+)\/([^/]+)\/?$|([^/]+)\/([^/]+)$/; const match = url.match(pattern); if (match) { // Match group 1 & 2 for full URL, 3 & 4 for relative path return match[1] && match[2] ? `${match[1]}/${match[2]}` : `${match[3]}/${match[4]}`; } return null; } /** * Download character card from generic url. * @param {String} url */ async function downloadGenericPng(url) { try { const result = await fetch(url); if (result.ok) { const buffer = Buffer.from(await result.arrayBuffer()); let fileName = sanitize(result.url.split('?')[0].split('/').reverse()[0]); const contentType = result.headers.get('content-type') || 'image/png'; //yoink it from AICC function lol // The `importCharacter()` function detects the MIME (content-type) of the file // using its file extension. The problem is that not all third-party APIs serve // their cards with a `.png` extension. To support more third-party sites, // dynamically append the `.png` extension to the filename if it doesn't // already have a file extension. if (contentType === 'image/png') { const ext = fileName.match(/\.(\w+)$/); // Same regex used by `importCharacter()` if (!ext) { fileName += '.png'; } } return { buffer: buffer, fileName: fileName, fileType: contentType, }; } } catch (error) { console.error('Error downloading file: ', error); throw error; } return null; } /** * Parse Risu Realm URL to extract the UUID. * @param {string} url Risu Realm URL * @returns {string | null} UUID of the character */ function parseRisuUrl(url) { // Example: https://realm.risuai.net/character/7adb0ed8d81855c820b3506980fb40f054ceef010ff0c4bab73730c0ebe92279 // or https://realm.risuai.net/character/7adb0ed8-d818-55c8-20b3-506980fb40f0 const pattern = /^https?:\/\/realm\.risuai\.net\/character\/([a-f0-9-]+)\/?$/i; const match = url.match(pattern); return match ? match[1] : null; } /** * Download RisuAI character card * @param {string} uuid UUID of the character * @returns {Promise<{buffer: Buffer, fileName: string, fileType: string}>} */ async function downloadRisuCharacter(uuid) { const result = await fetch(`https://realm.risuai.net/api/v1/download/png-v3/${uuid}?non_commercial=true`); if (!result.ok) { const text = await result.text(); console.error('RisuAI returned error', result.statusText, text); throw new Error('Failed to download character'); } const buffer = Buffer.from(await result.arrayBuffer()); const fileName = `${sanitize(uuid)}.png`; const fileType = 'image/png'; return { buffer, fileName, fileType }; } /** * Parse Soulkyn URL to extract the character slug. * @param {string} url Soulkyn character URL * @returns {string | null} Slug of the character */ function parseSoulkynUrl(url) { // Example: https://soulkyn.com/l/en-US/@kayla-marie const pattern = /^https:\/\/soulkyn\.com\/l\/[a-z]{2}-[A-Z]{2}\/@([\w\d-]+)/i; const match = url.match(pattern); return match ? match[1] : null; } /** * Download Soulkyn character card * @param {string} slug Slug of the character * @returns {Promise<{buffer: Buffer, fileName: string, fileType: string} | null>} */ async function downloadSoulkynCharacter(slug) { const soulkynReplacements = [ // https://soulkyn.com/l/en-US/help/character-backgrounds-advanced#variables-you-can-use-in-character-background-text { pattern: /__USER_?NAME__/gi, replacement: '{{user}}' }, { pattern: /__PERSONA_?NAME__/gi, replacement: '{{char}}' }, // ST doesn't support gender-specific pronoun macros { pattern: /__U_PRONOUN_1__/gi, replacement: 'they' }, { pattern: /__U_PRONOUN_2__/gi, replacement: 'them' }, { pattern: /__U_PRONOUN_3__/gi, replacement: 'their' }, { pattern: /__U_PRONOUN_4__/gi, replacement: 'themselves' }, { pattern: /__(USER_)?PRONOUN__/gi, replacement: 'they' }, { pattern: /__(USER_)?CPRONOUN__/gi, replacement: 'them' }, { pattern: /__(USER_)?UPRONOUN__/gi, replacement: 'their' }, // HTML tags -> Markdown syntax { pattern: /<(strong|b)>/gi, replacement: '**' }, { pattern: /<\/(strong|b)>/gi, replacement: '**' }, { pattern: /<(em|i)>/gi, replacement: '*' }, { pattern: /<\/(em|i)>/gi, replacement: '*' }, ]; const normalizeContent = (str) => soulkynReplacements.reduce((acc, { pattern, replacement }) => acc.replace(pattern, replacement), str); try { const url = `https://soulkyn.com/_special/rest/Sk/public/Persona/${slug}`; const result = await fetch(url, { headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT }, }); if (result.ok) { /** @type {any} */ const soulkynCharData = await result.json(); if (soulkynCharData.result !== 'success') { console.error('Soulkyn returned error', soulkynCharData.message); throw new Error(`Failed to download character: ${soulkynCharData.message}`); } // Fetch avatar let avatarBuffer = null; if (soulkynCharData.data?.Avatar?.FWSUUID) { const avatarUrl = `https://rub.soulkyn.com/${soulkynCharData.data.Avatar.FWSUUID}/`; const avatarResult = await fetch(avatarUrl, { headers: { 'User-Agent': USER_AGENT } }); if (avatarResult.ok) { const avatarContentType = avatarResult.headers.get('content-type'); if (avatarContentType === 'image/png') { avatarBuffer = Buffer.from(await avatarResult.arrayBuffer()); } else { console.warn(`Soulkyn character (${slug}) avatar is not PNG: ${avatarContentType}`); } } else { console.warn(`Soulkyn character (${slug}) avatar download failed: ${avatarResult.status}`); } } else { console.warn(`Soulkyn character (${slug}) does not have an avatar`); } // Fallback to default avatar if (!avatarBuffer) { const defaultAvatarPath = path.join(serverDirectory, DEFAULT_AVATAR_PATH); avatarBuffer = fs.readFileSync(defaultAvatarPath); } const d = soulkynCharData.data; soulkynReplacements.push({ pattern: d.Username, replacement: '{{char}}' }); // Parse Soulkyn data into character chard const charData = { name: d.Username, first_mes: '', tags: [], description: '', creator: d.User.Username, creator_notes: '', alternate_greetings: [], character_version: '', mes_example: '', post_history_instructions: '', system_prompt: '', scenario: '', personality: '', extensions: { soulkyn_slug: slug, soulkyn_id: d.UUID, }, }; if (d?.PersonaIntroText) { const match = d.PersonaIntroText.match(/^(?:\[Scenario:\s*([\s\S]*?)\]\s*)?([\s\S]*)$/); if (match) { if (match[1]) { charData.scenario = normalizeContent(match[1].trim()); } charData.first_mes = normalizeContent(match[2].trim()); } } const descriptionArr = ['Name: {{char}}']; if (d?.Version?.Age) { descriptionArr.push(`Age: ${d.Version.Age}`); } if (d?.Version?.Gender) { descriptionArr.push(`Gender: ${d.Version.Gender}`); } if (d?.Version?.Race?.Name && !d.Version.Race.Name.match(/no preset/i)) { let race = d.Version.Race.Name; if (d.Version.Race?.Description) { race += ` (${d.Version.Race.Description})`; } descriptionArr.push(`Race: ${race}`); } if (d?.PersonalityType) { descriptionArr.push(`Personality type: ${d.PersonalityType}`); } if (Array.isArray(d?.Version?.PropertyPersonality)) { const traits = d.Version.PropertyPersonality.map((t) => t.Value).join(', '); descriptionArr.push(`Personality Traits: ${traits}`); } if (Array.isArray(d?.Version?.PropertyPhysical)) { const traits = d.Version.PropertyPhysical.map((t) => t.Value).join(', '); descriptionArr.push(`Physical Traits: ${traits}`); } if (Array.isArray(d?.Clothes?.Preset)) { descriptionArr.push(`Clothes: ${d.Clothes.Preset.join(', ')}`); } if (d?.Avatar?.Caption) { descriptionArr.push(`Image description featuring {{char}}: ${d.Avatar.Caption.replace(/\n+/g, ' ')}`); } if (d?.Version?.WelcomeMessage) { if (charData.first_mes) { descriptionArr.push(`{{char}}'s self-description: "${d.Version.WelcomeMessage}"`); } else { // Some characters lack `PersonaIntroText`. In that case we use `Version.WelcomeMessage` for `first_mes` charData.first_mes = normalizeContent(d.Version.WelcomeMessage); } } charData.description = normalizeContent(descriptionArr.join('\n')); if (Array.isArray(d?.Version?.ChatExamplesValue)) { charData.mes_example = d.Version.ChatExamplesValue.map((example) => `\n${normalizeContent(example)}`).join('\n'); } if (Array.isArray(d?.PersonaTags)) { charData.tags = d.PersonaTags.map((t) => t.Slug); } // Character card const buffer = write(avatarBuffer, JSON.stringify({ 'spec': 'chara_card_v2', 'spec_version': '2.0', 'data': charData, })); const fileName = `${sanitize(d.UUID)}.png`; const fileType = 'image/png'; return { buffer, fileName, fileType }; } } catch (error) { console.error('Error downloading character:', error); throw error; } return null; } /** * @param {String} url * @returns {String | null } UUID of the character */ function getUuidFromUrl(url) { // Extract UUID from URL const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/; const matches = url.match(uuidRegex); // Check if UUID is found const uuid = matches ? matches[0] : null; return uuid; } /** * Filter to get the domain host of a url instead of a blanket string search. * @param {String} url URL to strip * @returns {String} Domain name */ function getHostFromUrl(url) { try { const urlObj = new URL(url); return urlObj.hostname; } catch { return ''; } } /** * Checks if host is part of generic download source whitelist. * @param {String} host Host to check * @returns {boolean} If the host is on the whitelist. */ function isHostWhitelisted(host) { return WHITELIST_GENERIC_URL_DOWNLOAD_SOURCES.includes(host); } export const router = express.Router(); router.post('/importURL', async (request, response) => { if (!request.body.url) { return response.sendStatus(400); } try { const url = request.body.url; const host = getHostFromUrl(url); let result; let type; const isChub = host.includes('chub.ai') || host.includes('characterhub.org'); const isJannnyContent = host.includes('janitorai'); const isPygmalionContent = host.includes('pygmalion.chat'); const isAICharacterCardsContent = host.includes('aicharactercards.com'); const isRisu = host.includes('realm.risuai.net'); const isSoulkyn = host.includes('soulkyn.com'); const isGeneric = isHostWhitelisted(host); if (isPygmalionContent) { const uuid = getUuidFromUrl(url); if (!uuid) { return response.sendStatus(404); } type = 'character'; result = await downloadPygmalionCharacter(uuid); } else if (isJannnyContent) { const uuid = getUuidFromUrl(url); if (!uuid) { return response.sendStatus(404); } type = 'character'; result = await downloadJannyCharacter(uuid); } else if (isAICharacterCardsContent) { const AICCParsed = parseAICC(url); if (!AICCParsed) { return response.sendStatus(404); } type = 'character'; result = await downloadAICCCharacter(AICCParsed); } else if (isChub) { const chubParsed = parseChubUrl(url); type = chubParsed?.type; if (chubParsed?.type === 'character') { console.info('Downloading chub character:', chubParsed.id); result = await downloadChubCharacter(chubParsed.id); } else if (chubParsed?.type === 'lorebook') { console.info('Downloading chub lorebook:', chubParsed.id); result = await downloadChubLorebook(chubParsed.id); } else { return response.sendStatus(404); } } else if (isRisu) { const uuid = parseRisuUrl(url); if (!uuid) { return response.sendStatus(404); } type = 'character'; result = await downloadRisuCharacter(uuid); } else if (isSoulkyn) { const soulkynSlug = parseSoulkynUrl(url); if (!soulkynSlug) { return response.sendStatus(404); } type = 'character'; result = await downloadSoulkynCharacter(soulkynSlug); } else if (isGeneric) { console.info('Downloading from generic url:', url); type = 'character'; result = await downloadGenericPng(url); } else { console.error(`Received an import for "${getHostFromUrl(url)}", but site is not whitelisted. This domain must be added to the config key "whitelistImportDomains" to allow import from this source.`); return response.sendStatus(404); } if (!result) { return response.sendStatus(404); } if (result.fileType) response.set('Content-Type', result.fileType); response.set('Content-Disposition', `attachment; filename="${encodeURI(result.fileName)}"`); response.set('X-Custom-Content-Type', type); return response.send(result.buffer); } catch (error) { console.error('Importing custom content failed', error); return response.sendStatus(500); } }); router.post('/importUUID', async (request, response) => { if (!request.body.url) { return response.sendStatus(400); } try { const uuid = request.body.url; let result; const isJannny = uuid.includes('_character'); const isPygmalion = (!isJannny && uuid.length == 36); const isAICC = uuid.startsWith('AICC/'); const uuidType = uuid.includes('lorebook') ? 'lorebook' : 'character'; if (isPygmalion) { console.info('Downloading Pygmalion character:', uuid); result = await downloadPygmalionCharacter(uuid); } else if (isJannny) { console.info('Downloading Janitor character:', uuid.split('_')[0]); result = await downloadJannyCharacter(uuid.split('_')[0]); } else if (isAICC) { const [, author, card] = uuid.split('/'); console.info('Downloading AICC character:', `${author}/${card}`); result = await downloadAICCCharacter(`${author}/${card}`); } else { if (uuidType === 'character') { console.info('Downloading chub character:', uuid); result = await downloadChubCharacter(uuid); } else if (uuidType === 'lorebook') { console.info('Downloading chub lorebook:', uuid); result = await downloadChubLorebook(uuid); } else { return response.sendStatus(404); } } if (result.fileType) response.set('Content-Type', result.fileType); response.set('Content-Disposition', `attachment; filename="${result.fileName}"`); response.set('X-Custom-Content-Type', uuidType); return response.send(result.buffer); } catch (error) { console.error('Importing custom content failed', error); return response.sendStatus(500); } });