diff --git a/.eslintrc.js b/.eslintrc.js
index faafd14c6..a7d0800a7 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -58,6 +58,7 @@ module.exports = {
         'comma-dangle': ['error', 'always-multiline'],
         'eol-last': ['error', 'always'],
         'no-trailing-spaces': 'error',
+        'object-curly-spacing': ['error', 'always'],
 
         // These rules should eventually be enabled.
         'no-async-promise-executor': 'off',
diff --git a/public/script.js b/public/script.js
index 713300982..96f2c4fa0 100644
--- a/public/script.js
+++ b/public/script.js
@@ -887,7 +887,7 @@ async function getStatus() {
                 api_type: textgen_settings.type,
                 legacy_api: main_api == 'textgenerationwebui' ?
                     textgen_settings.legacy_api &&
-                        textgen_settings.type !== MANCER :
+                    textgen_settings.type !== MANCER :
                     false,
             }),
             signal: abortStatusCheck.signal,
@@ -2914,7 +2914,7 @@ async function Generate(type, { automatic_trigger, force_name2, resolve, reject,
         let textareaText;
         if (type !== 'regenerate' && type !== 'swipe' && type !== 'quiet' && !isImpersonate && !dryRun) {
             is_send_press = true;
-            textareaText = $('#send_textarea').val();
+            textareaText = String($('#send_textarea').val());
             $('#send_textarea').val('').trigger('input');
         } else {
             textareaText = '';
@@ -2960,7 +2960,7 @@ async function Generate(type, { automatic_trigger, force_name2, resolve, reject,
         //*********************************
 
         //for normal messages sent from user..
-        if ((textareaText != '' || hasPendingFileAttachment()) && !automatic_trigger && type !== 'quiet') {
+        if ((textareaText != '' || hasPendingFileAttachment()) && !automatic_trigger && type !== 'quiet' && !dryRun) {
             // If user message contains no text other than bias - send as a system message
             if (messageBias && replaceBiasMarkup(textareaText).trim().length === 0) {
                 sendSystemMessage(system_message_types.GENERIC, ' ', { bias: messageBias });
@@ -2969,7 +2969,7 @@ async function Generate(type, { automatic_trigger, force_name2, resolve, reject,
                 await sendMessageAsUser(textareaText, messageBias);
             }
         }
-        else if (textareaText == '' && !automatic_trigger && type === undefined && main_api == 'openai' && oai_settings.send_if_empty.trim().length > 0) {
+        else if (textareaText == '' && !automatic_trigger && !dryRun && type === undefined && main_api == 'openai' && oai_settings.send_if_empty.trim().length > 0) {
             // Use send_if_empty if set and the user message is empty. Only when sending messages normally
             await sendMessageAsUser(oai_settings.send_if_empty.trim(), messageBias);
         }
@@ -4154,11 +4154,11 @@ async function DupeChar() {
         return;
     }
 
-    const confirm = await callPopup(`
-        <h3>Are you sure you want to duplicate this character?</h3>
-        <span>If you just want to start a new chat with the same character, use "Start new chat" option in the bottom-left options menu.</span><br><br>`,
-        'confirm',
-    );
+    const confirmMessage = `
+    <h3>Are you sure you want to duplicate this character?</h3>
+    <span>If you just want to start a new chat with the same character, use "Start new chat" option in the bottom-left options menu.</span><br><br>`;
+
+    const confirm = await callPopup(confirmMessage, 'confirm');
 
     if (!confirm) {
         console.log('User cancelled duplication');
@@ -7631,10 +7631,7 @@ function doTogglePanels() {
 }
 
 function addDebugFunctions() {
-    registerDebugFunction('backfillTokenCounts', 'Backfill token counters',
-        `Recalculates token counts of all messages in the current chat to refresh the counters.
-        Useful when you switch between models that have different tokenizers.
-        This is a visual change only. Your chat will be reloaded.`, async () => {
+    const doBackfill = async () => {
         for (const message of chat) {
             // System messages are not counted
             if (message.is_system) {
@@ -7650,7 +7647,12 @@ function addDebugFunctions() {
 
         await saveChatConditional();
         await reloadCurrentChat();
-    });
+    };
+
+    registerDebugFunction('backfillTokenCounts', 'Backfill token counters',
+        `Recalculates token counts of all messages in the current chat to refresh the counters.
+        Useful when you switch between models that have different tokenizers.
+        This is a visual change only. Your chat will be reloaded.`, doBackfill);
 
     registerDebugFunction('generationTest', 'Send a generation request', 'Generates text using the currently selected API.', async () => {
         const text = prompt('Input text:', 'Hello');
diff --git a/public/scripts/bulk-edit.js b/public/scripts/bulk-edit.js
index 59124ff2b..7cb0d17b9 100644
--- a/public/scripts/bulk-edit.js
+++ b/public/scripts/bulk-edit.js
@@ -1,5 +1,5 @@
 import { characters, getCharacters, handleDeleteCharacter, callPopup } from '../script.js';
-import {BulkEditOverlay, BulkEditOverlayState} from './BulkEditOverlay.js';
+import { BulkEditOverlay, BulkEditOverlayState } from './BulkEditOverlay.js';
 
 
 let is_bulk_edit = false;
diff --git a/public/scripts/chats.js b/public/scripts/chats.js
index 26a40d90c..a53b8c712 100644
--- a/public/scripts/chats.js
+++ b/public/scripts/chats.js
@@ -152,7 +152,7 @@ export async function populateFileAttachment(message, inputId = 'file_form_input
  */
 export async function uploadFileAttachment(fileName, base64Data) {
     try {
-        const result = await fetch('/api/file/upload', {
+        const result = await fetch('/api/files/upload', {
             method: 'POST',
             headers: getRequestHeaders(),
             body: JSON.stringify({
@@ -302,7 +302,7 @@ async function viewMessageFile(messageId) {
     modalTemplate.addClass('file_modal');
     addCopyToCodeBlocks(modalTemplate);
 
-    callPopup(modalTemplate, 'text');
+    callPopup(modalTemplate, 'text', '', { wide: true, large: true });
 }
 
 /**
diff --git a/public/scripts/extensions/translate/index.js b/public/scripts/extensions/translate/index.js
index efd2ff5e0..d8e14b3d7 100644
--- a/public/scripts/extensions/translate/index.js
+++ b/public/scripts/extensions/translate/index.js
@@ -1,4 +1,4 @@
-export {translate};
+export { translate };
 
 import {
     callPopup,
diff --git a/public/scripts/extensions/tts/index.js b/public/scripts/extensions/tts/index.js
index 68561d3b7..9ae47750b 100644
--- a/public/scripts/extensions/tts/index.js
+++ b/public/scripts/extensions/tts/index.js
@@ -10,7 +10,7 @@ import { NovelTtsProvider } from './novel.js';
 import { power_user } from '../../power-user.js';
 import { registerSlashCommand } from '../../slash-commands.js';
 import { OpenAITtsProvider } from './openai.js';
-import {XTTSTtsProvider} from './xtts.js';
+import { XTTSTtsProvider } from './xtts.js';
 export { talkingAnimation };
 
 const UPDATE_INTERVAL = 1000;
diff --git a/public/scripts/extensions/tts/novel.js b/public/scripts/extensions/tts/novel.js
index 48db4aee2..62a6dd9ad 100644
--- a/public/scripts/extensions/tts/novel.js
+++ b/public/scripts/extensions/tts/novel.js
@@ -125,7 +125,7 @@ class NovelTtsProvider {
             throw 'TTS Voice name not provided';
         }
 
-        return { name: voiceName, voice_id: voiceName, lang: 'en-US', preview_url: false};
+        return { name: voiceName, voice_id: voiceName, lang: 'en-US', preview_url: false };
     }
 
     async generateTts(text, voiceId) {
diff --git a/public/scripts/power-user.js b/public/scripts/power-user.js
index 8c9b9dbec..ad45ab7ce 100644
--- a/public/scripts/power-user.js
+++ b/public/scripts/power-user.js
@@ -666,7 +666,7 @@ async function CreateZenSliders(elmnt) {
         min: sliderMin,
         max: sliderMax,
         create: async function () {
-            await delay(100)
+            await delay(100);
             var handle = $(this).find('.ui-slider-handle');
             var handleText, stepNumber, leftMargin;
 
@@ -711,7 +711,7 @@ async function CreateZenSliders(elmnt) {
                 stepNumber = ((sliderValue - sliderMin) / stepScale);
                 leftMargin = (stepNumber / numSteps) * 50 * -1;
                 originalSlider.val(numVal)
-                    .data('newSlider', newSlider)
+                    .data('newSlider', newSlider);
                 //console.log(`${newSlider.attr('id')} sliderValue = ${sliderValue}, handleText:${handleText, numVal}, stepNum:${stepNumber}, numSteps:${numSteps}, left-margin:${leftMargin}`)
                 var isManualInput = false;
                 var valueBeforeManualInput;
@@ -737,8 +737,8 @@ async function CreateZenSliders(elmnt) {
                         isManualInput = true;
                         //allow enter to trigger slider update
                         if (e.key === 'Enter') {
-                            e.preventDefault
-                            handle.trigger('blur')
+                            e.preventDefault;
+                            handle.trigger('blur');
                         }
                     })
                     //trigger slider changes when user clicks away
diff --git a/server.js b/server.js
index 200aed447..0c8d604a6 100644
--- a/server.js
+++ b/server.js
@@ -3576,55 +3576,58 @@ async function fetchJSON(url, args = {}) {
 // ** END **
 
 // OpenAI API
-require('./src/endpoints/openai').registerEndpoints(app, jsonParser, urlencodedParser);
+app.use('/api/openai', require('./src/endpoints/openai').router);
 
 // Tokenizers
 require('./src/endpoints/tokenizers').registerEndpoints(app, jsonParser);
 
 // Preset management
-require('./src/endpoints/presets').registerEndpoints(app, jsonParser);
+app.use('/api/presets', require('./src/endpoints/presets').router);
 
 // Secrets managemenet
-require('./src/endpoints/secrets').registerEndpoints(app, jsonParser);
+app.use('/api/secrets', require('./src/endpoints/secrets').router);
 
-// Thumbnail generation
-require('./src/endpoints/thumbnails').registerEndpoints(app, jsonParser);
+// Thumbnail generation. These URLs are saved in chat, so this route cannot be renamed!
+app.use('/thumbnail', require('./src/endpoints/thumbnails').router);
 
 // NovelAI generation
-require('./src/endpoints/novelai').registerEndpoints(app, jsonParser);
+app.use('/api/novelai', require('./src/endpoints/novelai').router);
 
 // Third-party extensions
-require('./src/endpoints/extensions').registerEndpoints(app, jsonParser);
+app.use('/api/extensions', require('./src/endpoints/extensions').router);
 
 // Asset management
-require('./src/endpoints/assets').registerEndpoints(app, jsonParser);
+app.use('/api/assets', require('./src/endpoints/assets').router);
+
+// File management
+app.use('/api/files', require('./src/endpoints/files').router);
 
 // Character sprite management
-require('./src/endpoints/sprites').registerEndpoints(app, jsonParser, urlencodedParser);
+app.use('/api/sprites', require('./src/endpoints/sprites').router);
 
 // Custom content management
-require('./src/endpoints/content-manager').registerEndpoints(app, jsonParser);
+app.use('/api/content', require('./src/endpoints/content-manager').router);
 
 // Stable Diffusion generation
 require('./src/endpoints/stable-diffusion').registerEndpoints(app, jsonParser);
 
 // LLM and SD Horde generation
-require('./src/endpoints/horde').registerEndpoints(app, jsonParser);
+app.use('/api/horde', require('./src/endpoints/horde').router);
 
 // Vector storage DB
 app.use('/api/vector', require('./src/endpoints/vectors').router);
 
 // Chat translation
-require('./src/endpoints/translate').registerEndpoints(app, jsonParser);
+app.use('/api/translate', require('./src/endpoints/translate').router);
 
 // Emotion classification
-require('./src/endpoints/classify').registerEndpoints(app, jsonParser);
+app.use('/api/extra/classify', require('./src/endpoints/classify').router);
 
 // Image captioning
-require('./src/endpoints/caption').registerEndpoints(app, jsonParser);
+app.use('/api/extra/caption', require('./src/endpoints/caption').router);
 
 // Web search extension
-require('./src/endpoints/serpapi').registerEndpoints(app, jsonParser);
+app.use('/api/serpapi', require('./src/endpoints/serpapi').router);
 
 const tavernUrl = new URL(
     (cliArguments.ssl ? 'https://' : 'http://') +
diff --git a/src/endpoints/assets.js b/src/endpoints/assets.js
index 5264e3576..55a939816 100644
--- a/src/endpoints/assets.js
+++ b/src/endpoints/assets.js
@@ -1,10 +1,12 @@
 const path = require('path');
 const fs = require('fs');
+const express = require('express');
 const sanitize = require('sanitize-filename');
 const fetch = require('node-fetch').default;
 const { finished } = require('stream/promises');
 const writeFileSyncAtomic = require('write-file-atomic').sync;
 const { DIRECTORIES, UNSAFE_EXTENSIONS } = require('../constants');
+const { jsonParser } = require('../express-common');
 
 const VALID_CATEGORIES = ['bgm', 'ambient', 'blip', 'live2d'];
 
@@ -57,273 +59,266 @@ function getFiles(dir, files = []) {
     return files;
 }
 
+const router = express.Router();
+
 /**
- * Registers the endpoints for the asset management.
- * @param {import('express').Express} app Express app
- * @param {any} jsonParser JSON parser middleware
+ * HTTP POST handler function to retrieve name of all files of a given folder path.
+ *
+ * @param {Object} request - HTTP Request object. Require folder path in query
+ * @param {Object} response - HTTP Response object will contain a list of file path.
+ *
+ * @returns {void}
  */
-function registerEndpoints(app, jsonParser) {
-    /**
-     * HTTP POST handler function to retrieve name of all files of a given folder path.
-     *
-     * @param {Object} request - HTTP Request object. Require folder path in query
-     * @param {Object} response - HTTP Response object will contain a list of file path.
-     *
-     * @returns {void}
-     */
-    app.post('/api/assets/get', jsonParser, async (_, response) => {
-        const folderPath = path.join(DIRECTORIES.assets);
-        let output = {};
-        //console.info("Checking files into",folderPath);
+router.post('/get', jsonParser, async (_, response) => {
+    const folderPath = path.join(DIRECTORIES.assets);
+    let output = {};
+    //console.info("Checking files into",folderPath);
 
-        try {
-            if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) {
-                const folders = fs.readdirSync(folderPath)
-                    .filter(filename => {
-                        return fs.statSync(path.join(folderPath, filename)).isDirectory();
-                    });
-
-                for (const folder of folders) {
-                    if (folder == 'temp')
-                        continue;
-
-                    // Live2d assets
-                    if (folder == 'live2d') {
-                        output[folder] = [];
-                        const live2d_folder = path.normalize(path.join(folderPath, folder));
-                        const files = getFiles(live2d_folder);
-                        //console.debug("FILE FOUND:",files)
-                        for (let file of files) {
-                            file = path.normalize(file.replace('public' + path.sep, ''));
-                            if (file.includes('model') && file.endsWith('.json')) {
-                                //console.debug("Asset live2d model found:",file)
-                                output[folder].push(path.normalize(path.join(file)));
-                            }
-                        }
-                        continue;
-                    }
-
-                    // Other assets (bgm/ambient/blip)
-                    const files = fs.readdirSync(path.join(folderPath, folder))
-                        .filter(filename => {
-                            return filename != '.placeholder';
-                        });
-                    output[folder] = [];
-                    for (const file of files) {
-                        output[folder].push(path.join('assets', folder, file));
-                    }
-                }
-            }
-        }
-        catch (err) {
-            console.log(err);
-        }
-        return response.send(output);
-    });
-
-    /**
-     * HTTP POST handler function to download the requested asset.
-     *
-     * @param {Object} request - HTTP Request object, expects a url, a category and a filename.
-     * @param {Object} response - HTTP Response only gives status.
-     *
-     * @returns {void}
-     */
-    app.post('/api/assets/download', jsonParser, async (request, response) => {
-        const url = request.body.url;
-        const inputCategory = request.body.category;
-        const inputFilename = sanitize(request.body.filename);
-
-        // Check category
-        let category = null;
-        for (let i of VALID_CATEGORIES)
-            if (i == inputCategory)
-                category = i;
-
-        if (category === null) {
-            console.debug('Bad request: unsuported asset category.');
-            return response.sendStatus(400);
-        }
-
-        // Sanitize filename
-        const safe_input = checkAssetFileName(inputFilename);
-        if (safe_input == '')
-            return response.sendStatus(400);
-
-        const temp_path = path.join(DIRECTORIES.assets, 'temp', safe_input);
-        const file_path = path.join(DIRECTORIES.assets, category, safe_input);
-        console.debug('Request received to download', url, 'to', file_path);
-
-        try {
-            // Download to temp
-            const res = await fetch(url);
-            if (!res.ok || res.body === null) {
-                throw new Error(`Unexpected response ${res.statusText}`);
-            }
-            const destination = path.resolve(temp_path);
-            // Delete if previous download failed
-            if (fs.existsSync(temp_path)) {
-                fs.unlink(temp_path, (err) => {
-                    if (err) throw err;
+    try {
+        if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) {
+            const folders = fs.readdirSync(folderPath)
+                .filter(filename => {
+                    return fs.statSync(path.join(folderPath, filename)).isDirectory();
                 });
-            }
-            const fileStream = fs.createWriteStream(destination, { flags: 'wx' });
-            await finished(res.body.pipe(fileStream));
 
-            // Move into asset place
-            console.debug('Download finished, moving file from', temp_path, 'to', file_path);
-            fs.renameSync(temp_path, file_path);
-            response.sendStatus(200);
-        }
-        catch (error) {
-            console.log(error);
-            response.sendStatus(500);
-        }
-    });
-
-    /**
-     * HTTP POST handler function to delete the requested asset.
-     *
-     * @param {Object} request - HTTP Request object, expects a category and a filename
-     * @param {Object} response - HTTP Response only gives stats.
-     *
-     * @returns {void}
-     */
-    app.post('/api/assets/delete', jsonParser, async (request, response) => {
-        const inputCategory = request.body.category;
-        const inputFilename = sanitize(request.body.filename);
-
-        // Check category
-        let category = null;
-        for (let i of VALID_CATEGORIES)
-            if (i == inputCategory)
-                category = i;
-
-        if (category === null) {
-            console.debug('Bad request: unsuported asset category.');
-            return response.sendStatus(400);
-        }
-
-        // Sanitize filename
-        const safe_input = checkAssetFileName(inputFilename);
-        if (safe_input == '')
-            return response.sendStatus(400);
-
-        const file_path = path.join(DIRECTORIES.assets, category, safe_input);
-        console.debug('Request received to delete', category, file_path);
-
-        try {
-            // Delete if previous download failed
-            if (fs.existsSync(file_path)) {
-                fs.unlink(file_path, (err) => {
-                    if (err) throw err;
-                });
-                console.debug('Asset deleted.');
-            }
-            else {
-                console.debug('Asset not found.');
-                response.sendStatus(400);
-            }
-            // Move into asset place
-            response.sendStatus(200);
-        }
-        catch (error) {
-            console.log(error);
-            response.sendStatus(500);
-        }
-    });
-
-    ///////////////////////////////
-    /**
-     * HTTP POST handler function to retrieve a character background music list.
-     *
-     * @param {Object} request - HTTP Request object, expects a character name in the query.
-     * @param {Object} response - HTTP Response object will contain a list of audio file path.
-     *
-     * @returns {void}
-     */
-    app.post('/api/assets/character', jsonParser, async (request, response) => {
-        if (request.query.name === undefined) return response.sendStatus(400);
-        const name = sanitize(request.query.name.toString());
-        const inputCategory = request.query.category;
-
-        // Check category
-        let category = null;
-        for (let i of VALID_CATEGORIES)
-            if (i == inputCategory)
-                category = i;
-
-        if (category === null) {
-            console.debug('Bad request: unsuported asset category.');
-            return response.sendStatus(400);
-        }
-
-        const folderPath = path.join(DIRECTORIES.characters, name, category);
-
-        let output = [];
-        try {
-            if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) {
+            for (const folder of folders) {
+                if (folder == 'temp')
+                    continue;
 
                 // Live2d assets
-                if (category == 'live2d') {
-                    const folders = fs.readdirSync(folderPath);
-                    for (let modelFolder of folders) {
-                        const live2dModelPath = path.join(folderPath, modelFolder);
-                        if (fs.statSync(live2dModelPath).isDirectory()) {
-                            for (let file of fs.readdirSync(live2dModelPath)) {
-                                //console.debug("Character live2d model found:", file)
-                                if (file.includes('model') && file.endsWith('.json'))
-                                    output.push(path.join('characters', name, category, modelFolder, file));
-                            }
+                if (folder == 'live2d') {
+                    output[folder] = [];
+                    const live2d_folder = path.normalize(path.join(folderPath, folder));
+                    const files = getFiles(live2d_folder);
+                    //console.debug("FILE FOUND:",files)
+                    for (let file of files) {
+                        file = path.normalize(file.replace('public' + path.sep, ''));
+                        if (file.includes('model') && file.endsWith('.json')) {
+                            //console.debug("Asset live2d model found:",file)
+                            output[folder].push(path.normalize(path.join(file)));
                         }
                     }
-                    return response.send(output);
+                    continue;
                 }
 
-                // Other assets
-                const files = fs.readdirSync(folderPath)
+                // Other assets (bgm/ambient/blip)
+                const files = fs.readdirSync(path.join(folderPath, folder))
                     .filter(filename => {
                         return filename != '.placeholder';
                     });
-
-                for (let i of files)
-                    output.push(`/characters/${name}/${category}/${i}`);
+                output[folder] = [];
+                for (const file of files) {
+                    output[folder].push(path.join('assets', folder, file));
+                }
             }
-            return response.send(output);
         }
-        catch (err) {
-            console.log(err);
-            return response.sendStatus(500);
+    }
+    catch (err) {
+        console.log(err);
+    }
+    return response.send(output);
+});
+
+/**
+ * HTTP POST handler function to download the requested asset.
+ *
+ * @param {Object} request - HTTP Request object, expects a url, a category and a filename.
+ * @param {Object} response - HTTP Response only gives status.
+ *
+ * @returns {void}
+ */
+router.post('/download', jsonParser, async (request, response) => {
+    const url = request.body.url;
+    const inputCategory = request.body.category;
+    const inputFilename = sanitize(request.body.filename);
+
+    // Check category
+    let category = null;
+    for (let i of VALID_CATEGORIES)
+        if (i == inputCategory)
+            category = i;
+
+    if (category === null) {
+        console.debug('Bad request: unsuported asset category.');
+        return response.sendStatus(400);
+    }
+
+    // Sanitize filename
+    const safe_input = checkAssetFileName(inputFilename);
+    if (safe_input == '')
+        return response.sendStatus(400);
+
+    const temp_path = path.join(DIRECTORIES.assets, 'temp', safe_input);
+    const file_path = path.join(DIRECTORIES.assets, category, safe_input);
+    console.debug('Request received to download', url, 'to', file_path);
+
+    try {
+        // Download to temp
+        const res = await fetch(url);
+        if (!res.ok || res.body === null) {
+            throw new Error(`Unexpected response ${res.statusText}`);
         }
-    });
-
-    app.post('/api/file/upload', jsonParser, async (request, response) => {
-        try {
-            if (!request.body.name) {
-                return response.status(400).send('No upload name specified');
-            }
-
-            if (!request.body.data) {
-                return response.status(400).send('No upload data specified');
-            }
-
-            const safeInput = checkAssetFileName(request.body.name);
-
-            if (!safeInput) {
-                return response.status(400).send('Invalid upload name');
-            }
-
-            const pathToUpload = path.join(DIRECTORIES.files, safeInput);
-            writeFileSyncAtomic(pathToUpload, request.body.data, 'base64');
-            const url = path.normalize(pathToUpload.replace('public' + path.sep, ''));
-            return response.send({ path: url });
-        } catch (error) {
-            console.log(error);
-            return response.sendStatus(500);
+        const destination = path.resolve(temp_path);
+        // Delete if previous download failed
+        if (fs.existsSync(temp_path)) {
+            fs.unlink(temp_path, (err) => {
+                if (err) throw err;
+            });
         }
-    });
-}
+        const fileStream = fs.createWriteStream(destination, { flags: 'wx' });
+        await finished(res.body.pipe(fileStream));
 
-module.exports = {
-    registerEndpoints,
-};
+        // Move into asset place
+        console.debug('Download finished, moving file from', temp_path, 'to', file_path);
+        fs.renameSync(temp_path, file_path);
+        response.sendStatus(200);
+    }
+    catch (error) {
+        console.log(error);
+        response.sendStatus(500);
+    }
+});
+
+/**
+ * HTTP POST handler function to delete the requested asset.
+ *
+ * @param {Object} request - HTTP Request object, expects a category and a filename
+ * @param {Object} response - HTTP Response only gives stats.
+ *
+ * @returns {void}
+ */
+router.post('/delete', jsonParser, async (request, response) => {
+    const inputCategory = request.body.category;
+    const inputFilename = sanitize(request.body.filename);
+
+    // Check category
+    let category = null;
+    for (let i of VALID_CATEGORIES)
+        if (i == inputCategory)
+            category = i;
+
+    if (category === null) {
+        console.debug('Bad request: unsuported asset category.');
+        return response.sendStatus(400);
+    }
+
+    // Sanitize filename
+    const safe_input = checkAssetFileName(inputFilename);
+    if (safe_input == '')
+        return response.sendStatus(400);
+
+    const file_path = path.join(DIRECTORIES.assets, category, safe_input);
+    console.debug('Request received to delete', category, file_path);
+
+    try {
+        // Delete if previous download failed
+        if (fs.existsSync(file_path)) {
+            fs.unlink(file_path, (err) => {
+                if (err) throw err;
+            });
+            console.debug('Asset deleted.');
+        }
+        else {
+            console.debug('Asset not found.');
+            response.sendStatus(400);
+        }
+        // Move into asset place
+        response.sendStatus(200);
+    }
+    catch (error) {
+        console.log(error);
+        response.sendStatus(500);
+    }
+});
+
+///////////////////////////////
+/**
+ * HTTP POST handler function to retrieve a character background music list.
+ *
+ * @param {Object} request - HTTP Request object, expects a character name in the query.
+ * @param {Object} response - HTTP Response object will contain a list of audio file path.
+ *
+ * @returns {void}
+ */
+router.post('/character', jsonParser, async (request, response) => {
+    if (request.query.name === undefined) return response.sendStatus(400);
+    const name = sanitize(request.query.name.toString());
+    const inputCategory = request.query.category;
+
+    // Check category
+    let category = null;
+    for (let i of VALID_CATEGORIES)
+        if (i == inputCategory)
+            category = i;
+
+    if (category === null) {
+        console.debug('Bad request: unsuported asset category.');
+        return response.sendStatus(400);
+    }
+
+    const folderPath = path.join(DIRECTORIES.characters, name, category);
+
+    let output = [];
+    try {
+        if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) {
+
+            // Live2d assets
+            if (category == 'live2d') {
+                const folders = fs.readdirSync(folderPath);
+                for (let modelFolder of folders) {
+                    const live2dModelPath = path.join(folderPath, modelFolder);
+                    if (fs.statSync(live2dModelPath).isDirectory()) {
+                        for (let file of fs.readdirSync(live2dModelPath)) {
+                            //console.debug("Character live2d model found:", file)
+                            if (file.includes('model') && file.endsWith('.json'))
+                                output.push(path.join('characters', name, category, modelFolder, file));
+                        }
+                    }
+                }
+                return response.send(output);
+            }
+
+            // Other assets
+            const files = fs.readdirSync(folderPath)
+                .filter(filename => {
+                    return filename != '.placeholder';
+                });
+
+            for (let i of files)
+                output.push(`/characters/${name}/${category}/${i}`);
+        }
+        return response.send(output);
+    }
+    catch (err) {
+        console.log(err);
+        return response.sendStatus(500);
+    }
+});
+
+router.post('/upload', jsonParser, async (request, response) => {
+    try {
+        if (!request.body.name) {
+            return response.status(400).send('No upload name specified');
+        }
+
+        if (!request.body.data) {
+            return response.status(400).send('No upload data specified');
+        }
+
+        const safeInput = checkAssetFileName(request.body.name);
+
+        if (!safeInput) {
+            return response.status(400).send('Invalid upload name');
+        }
+
+        const pathToUpload = path.join(DIRECTORIES.files, safeInput);
+        writeFileSyncAtomic(pathToUpload, request.body.data, 'base64');
+        const url = path.normalize(pathToUpload.replace('public' + path.sep, ''));
+        return response.send({ path: url });
+    } catch (error) {
+        console.log(error);
+        return response.sendStatus(500);
+    }
+});
+
+module.exports = { router, checkAssetFileName };
diff --git a/src/endpoints/caption.js b/src/endpoints/caption.js
index 81a8d029d..ac6e2b896 100644
--- a/src/endpoints/caption.js
+++ b/src/endpoints/caption.js
@@ -1,35 +1,32 @@
+const express = require('express');
+const { jsonParser } = require('../express-common');
+
 const TASK = 'image-to-text';
 
-/**
- * @param {import("express").Express} app
- * @param {any} jsonParser
- */
-function registerEndpoints(app, jsonParser) {
-    app.post('/api/extra/caption', jsonParser, async (req, res) => {
-        try {
-            const { image } = req.body;
+const router = express.Router();
 
-            const module = await import('../transformers.mjs');
-            const rawImage = await module.default.getRawImage(image);
+router.post('/', jsonParser, async (req, res) => {
+    try {
+        const { image } = req.body;
 
-            if (!rawImage) {
-                console.log('Failed to parse captioned image');
-                return res.sendStatus(400);
-            }
+        const module = await import('../transformers.mjs');
+        const rawImage = await module.default.getRawImage(image);
 
-            const pipe = await module.default.getPipeline(TASK);
-            const result = await pipe(rawImage);
-            const text = result[0].generated_text;
-            console.log('Image caption:', text);
-
-            return res.json({ caption: text });
-        } catch (error) {
-            console.error(error);
-            return res.sendStatus(500);
+        if (!rawImage) {
+            console.log('Failed to parse captioned image');
+            return res.sendStatus(400);
         }
-    });
-}
 
-module.exports = {
-    registerEndpoints,
-};
+        const pipe = await module.default.getPipeline(TASK);
+        const result = await pipe(rawImage);
+        const text = result[0].generated_text;
+        console.log('Image caption:', text);
+
+        return res.json({ caption: text });
+    } catch (error) {
+        console.error(error);
+        return res.sendStatus(500);
+    }
+});
+
+module.exports = { router };
diff --git a/src/endpoints/classify.js b/src/endpoints/classify.js
index dc0b8fb90..5a9772e1d 100644
--- a/src/endpoints/classify.js
+++ b/src/endpoints/classify.js
@@ -1,53 +1,50 @@
+const express = require('express');
+const { jsonParser } = require('../express-common');
+
 const TASK = 'text-classification';
 
-/**
- * @param {import("express").Express} app
- * @param {any} jsonParser
- */
-function registerEndpoints(app, jsonParser) {
-    const cacheObject = {};
+const router = express.Router();
 
-    app.post('/api/extra/classify/labels', jsonParser, async (req, res) => {
-        try {
-            const module = await import('../transformers.mjs');
-            const pipe = await module.default.getPipeline(TASK);
-            const result = Object.keys(pipe.model.config.label2id);
-            return res.json({ labels: result });
-        } catch (error) {
-            console.error(error);
-            return res.sendStatus(500);
-        }
-    });
+const cacheObject = {};
 
-    app.post('/api/extra/classify', jsonParser, async (req, res) => {
-        try {
-            const { text } = req.body;
+router.post('/labels', jsonParser, async (req, res) => {
+    try {
+        const module = await import('../transformers.mjs');
+        const pipe = await module.default.getPipeline(TASK);
+        const result = Object.keys(pipe.model.config.label2id);
+        return res.json({ labels: result });
+    } catch (error) {
+        console.error(error);
+        return res.sendStatus(500);
+    }
+});
 
-            async function getResult(text) {
-                if (Object.hasOwn(cacheObject, text)) {
-                    return cacheObject[text];
-                } else {
-                    const module = await import('../transformers.mjs');
-                    const pipe = await module.default.getPipeline(TASK);
-                    const result = await pipe(text, { topk: 5 });
-                    result.sort((a, b) => b.score - a.score);
-                    cacheObject[text] = result;
-                    return result;
-                }
+router.post('/', jsonParser, async (req, res) => {
+    try {
+        const { text } = req.body;
+
+        async function getResult(text) {
+            if (Object.hasOwn(cacheObject, text)) {
+                return cacheObject[text];
+            } else {
+                const module = await import('../transformers.mjs');
+                const pipe = await module.default.getPipeline(TASK);
+                const result = await pipe(text, { topk: 5 });
+                result.sort((a, b) => b.score - a.score);
+                cacheObject[text] = result;
+                return result;
             }
-
-            console.log('Classify input:', text);
-            const result = await getResult(text);
-            console.log('Classify output:', result);
-
-            return res.json({ classification: result });
-        } catch (error) {
-            console.error(error);
-            return res.sendStatus(500);
         }
-    });
-}
 
-module.exports = {
-    registerEndpoints,
-};
+        console.log('Classify input:', text);
+        const result = await getResult(text);
+        console.log('Classify output:', result);
+
+        return res.json({ classification: result });
+    } catch (error) {
+        console.error(error);
+        return res.sendStatus(500);
+    }
+});
+
+module.exports = { router };
diff --git a/src/endpoints/content-manager.js b/src/endpoints/content-manager.js
index cc4796980..191b4f4ce 100644
--- a/src/endpoints/content-manager.js
+++ b/src/endpoints/content-manager.js
@@ -1,8 +1,10 @@
 const fs = require('fs');
 const path = require('path');
+const express = require('express');
 const fetch = require('node-fetch').default;
 const sanitize = require('sanitize-filename');
 const { getConfigValue } = require('../util');
+const { jsonParser } = require('../express-common');
 const contentDirectory = path.join(process.cwd(), 'default/content');
 const contentLogPath = path.join(contentDirectory, 'content.log');
 const contentIndexPath = path.join(contentDirectory, 'index.json');
@@ -302,62 +304,57 @@ function parseJannyUrl(url) {
     return uuid;
 }
 
-/**
- * Registers endpoints for custom content management
- * @param {import('express').Express} app Express app
- * @param {any} jsonParser JSON parser middleware
- */
-function registerEndpoints(app, jsonParser) {
-    app.post('/api/content/import', jsonParser, async (request, response) => {
-        if (!request.body.url) {
-            return response.sendStatus(400);
-        }
+const router = express.Router();
 
-        try {
-            const url = request.body.url;
-            let result;
-            let type;
+router.post('/import', jsonParser, async (request, response) => {
+    if (!request.body.url) {
+        return response.sendStatus(400);
+    }
 
-            const isJannnyContent = url.includes('janitorai');
-            if (isJannnyContent) {
-                const uuid = parseJannyUrl(url);
-                if (!uuid) {
-                    return response.sendStatus(404);
-                }
+    try {
+        const url = request.body.url;
+        let result;
+        let type;
 
-                type = 'character';
-                result = await downloadJannyCharacter(uuid);
-            } else {
-                const chubParsed = parseChubUrl(url);
-                type = chubParsed?.type;
-
-                if (chubParsed?.type === 'character') {
-                    console.log('Downloading chub character:', chubParsed.id);
-                    result = await downloadChubCharacter(chubParsed.id);
-                }
-                else if (chubParsed?.type === 'lorebook') {
-                    console.log('Downloading chub lorebook:', chubParsed.id);
-                    result = await downloadChubLorebook(chubParsed.id);
-                }
-                else {
-                    return response.sendStatus(404);
-                }
+        const isJannnyContent = url.includes('janitorai');
+        if (isJannnyContent) {
+            const uuid = parseJannyUrl(url);
+            if (!uuid) {
+                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', type);
-            return response.send(result.buffer);
-        } catch (error) {
-            console.log('Importing custom content failed', error);
-            return response.sendStatus(500);
+            type = 'character';
+            result = await downloadJannyCharacter(uuid);
+        } else {
+            const chubParsed = parseChubUrl(url);
+            type = chubParsed?.type;
+
+            if (chubParsed?.type === 'character') {
+                console.log('Downloading chub character:', chubParsed.id);
+                result = await downloadChubCharacter(chubParsed.id);
+            }
+            else if (chubParsed?.type === 'lorebook') {
+                console.log('Downloading chub lorebook:', chubParsed.id);
+                result = await downloadChubLorebook(chubParsed.id);
+            }
+            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', type);
+        return response.send(result.buffer);
+    } catch (error) {
+        console.log('Importing custom content failed', error);
+        return response.sendStatus(500);
+    }
+});
 
 module.exports = {
     checkForNewContent,
-    registerEndpoints,
     getDefaultPresets,
     getDefaultPresetFile,
+    router,
 };
diff --git a/src/endpoints/extensions.js b/src/endpoints/extensions.js
index 4e3e54d39..9aaf93a3c 100644
--- a/src/endpoints/extensions.js
+++ b/src/endpoints/extensions.js
@@ -1,8 +1,10 @@
 const path = require('path');
 const fs = require('fs');
+const express = require('express');
 const { default: simpleGit } = require('simple-git');
 const sanitize = require('sanitize-filename');
 const { DIRECTORIES } = require('../constants');
+const { jsonParser } = require('../express-common');
 
 /**
  * This function extracts the extension information from the manifest file.
@@ -45,206 +47,198 @@ async function checkIfRepoIsUpToDate(extensionPath) {
     };
 }
 
+const router = express.Router();
+
 /**
- * Registers the endpoints for the third-party extensions API.
- * @param {import('express').Express} app - Express app
- * @param {any} jsonParser - JSON parser middleware
+ * HTTP POST handler function to clone a git repository from a provided URL, read the extension manifest,
+ * and return extension information and path.
+ *
+ * @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property.
+ * @param {Object} response - HTTP Response object used to respond to the HTTP request.
+ *
+ * @returns {void}
  */
-function registerEndpoints(app, jsonParser) {
-    /**
-     * HTTP POST handler function to clone a git repository from a provided URL, read the extension manifest,
-     * and return extension information and path.
-     *
-     * @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property.
-     * @param {Object} response - HTTP Response object used to respond to the HTTP request.
-     *
-     * @returns {void}
-     */
-    app.post('/api/extensions/install', jsonParser, async (request, response) => {
-        if (!request.body.url) {
-            return response.status(400).send('Bad Request: URL is required in the request body.');
-        }
+router.post('/install', jsonParser, async (request, response) => {
+    if (!request.body.url) {
+        return response.status(400).send('Bad Request: URL is required in the request body.');
+    }
 
-        try {
-            const git = simpleGit();
-
-            // make sure the third-party directory exists
-            if (!fs.existsSync(path.join(DIRECTORIES.extensions, 'third-party'))) {
-                fs.mkdirSync(path.join(DIRECTORIES.extensions, 'third-party'));
-            }
-
-            const url = request.body.url;
-            const extensionPath = path.join(DIRECTORIES.extensions, 'third-party', path.basename(url, '.git'));
-
-            if (fs.existsSync(extensionPath)) {
-                return response.status(409).send(`Directory already exists at ${extensionPath}`);
-            }
-
-            await git.clone(url, extensionPath, { '--depth': 1 });
-            console.log(`Extension has been cloned at ${extensionPath}`);
-
-
-            const { version, author, display_name } = await getManifest(extensionPath);
-
-
-            return response.send({ version, author, display_name, extensionPath });
-        } catch (error) {
-            console.log('Importing custom content failed', error);
-            return response.status(500).send(`Server Error: ${error.message}`);
-        }
-    });
-
-    /**
-     * HTTP POST handler function to pull the latest updates from a git repository
-     * based on the extension name provided in the request body. It returns the latest commit hash,
-     * the path of the extension, the status of the repository (whether it's up-to-date or not),
-     * and the remote URL of the repository.
-     *
-     * @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property.
-     * @param {Object} response - HTTP Response object used to respond to the HTTP request.
-     *
-     * @returns {void}
-     */
-    app.post('/api/extensions/update', jsonParser, async (request, response) => {
+    try {
         const git = simpleGit();
-        if (!request.body.extensionName) {
-            return response.status(400).send('Bad Request: extensionName is required in the request body.');
-        }
-
-        try {
-            const extensionName = request.body.extensionName;
-            const extensionPath = path.join(DIRECTORIES.extensions, 'third-party', extensionName);
-
-            if (!fs.existsSync(extensionPath)) {
-                return response.status(404).send(`Directory does not exist at ${extensionPath}`);
-            }
-
-            const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
-            const currentBranch = await git.cwd(extensionPath).branch();
-            if (!isUpToDate) {
-
-                await git.cwd(extensionPath).pull('origin', currentBranch.current);
-                console.log(`Extension has been updated at ${extensionPath}`);
-            } else {
-                console.log(`Extension is up to date at ${extensionPath}`);
-            }
-            await git.cwd(extensionPath).fetch('origin');
-            const fullCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);
-            const shortCommitHash = fullCommitHash.slice(0, 7);
-
-            return response.send({ shortCommitHash, extensionPath, isUpToDate, remoteUrl });
-
-        } catch (error) {
-            console.log('Updating custom content failed', error);
-            return response.status(500).send(`Server Error: ${error.message}`);
-        }
-    });
-
-    /**
-     * HTTP POST handler function to get the current git commit hash and branch name for a given extension.
-     * It checks whether the repository is up-to-date with the remote, and returns the status along with
-     * the remote URL of the repository.
-     *
-     * @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property.
-     * @param {Object} response - HTTP Response object used to respond to the HTTP request.
-     *
-     * @returns {void}
-     */
-    app.post('/api/extensions/version', jsonParser, async (request, response) => {
-        const git = simpleGit();
-        if (!request.body.extensionName) {
-            return response.status(400).send('Bad Request: extensionName is required in the request body.');
-        }
-
-        try {
-            const extensionName = request.body.extensionName;
-            const extensionPath = path.join(DIRECTORIES.extensions, 'third-party', extensionName);
-
-            if (!fs.existsSync(extensionPath)) {
-                return response.status(404).send(`Directory does not exist at ${extensionPath}`);
-            }
-
-            const currentBranch = await git.cwd(extensionPath).branch();
-            // get only the working branch
-            const currentBranchName = currentBranch.current;
-            await git.cwd(extensionPath).fetch('origin');
-            const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);
-            console.log(currentBranch, currentCommitHash);
-            const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
-
-            return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl });
-
-        } catch (error) {
-            console.log('Getting extension version failed', error);
-            return response.status(500).send(`Server Error: ${error.message}`);
-        }
-    });
-
-    /**
-     * HTTP POST handler function to delete a git repository based on the extension name provided in the request body.
-     *
-     * @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property.
-     * @param {Object} response - HTTP Response object used to respond to the HTTP request.
-     *
-     * @returns {void}
-     */
-    app.post('/api/extensions/delete', jsonParser, async (request, response) => {
-        if (!request.body.extensionName) {
-            return response.status(400).send('Bad Request: extensionName is required in the request body.');
-        }
-
-        // Sanatize the extension name to prevent directory traversal
-        const extensionName = sanitize(request.body.extensionName);
-
-        try {
-            const extensionPath = path.join(DIRECTORIES.extensions, 'third-party', extensionName);
-
-            if (!fs.existsSync(extensionPath)) {
-                return response.status(404).send(`Directory does not exist at ${extensionPath}`);
-            }
-
-            await fs.promises.rmdir(extensionPath, { recursive: true });
-            console.log(`Extension has been deleted at ${extensionPath}`);
-
-            return response.send(`Extension has been deleted at ${extensionPath}`);
-
-        } catch (error) {
-            console.log('Deleting custom content failed', error);
-            return response.status(500).send(`Server Error: ${error.message}`);
-        }
-    });
-
-    /**
-     * Discover the extension folders
-     * If the folder is called third-party, search for subfolders instead
-     */
-    app.get('/api/extensions/discover', jsonParser, function (_, response) {
-
-        // get all folders in the extensions folder, except third-party
-        const extensions = fs
-            .readdirSync(DIRECTORIES.extensions)
-            .filter(f => fs.statSync(path.join(DIRECTORIES.extensions, f)).isDirectory())
-            .filter(f => f !== 'third-party');
-
-        // get all folders in the third-party folder, if it exists
 
+        // make sure the third-party directory exists
         if (!fs.existsSync(path.join(DIRECTORIES.extensions, 'third-party'))) {
-            return response.send(extensions);
+            fs.mkdirSync(path.join(DIRECTORIES.extensions, 'third-party'));
         }
 
-        const thirdPartyExtensions = fs
-            .readdirSync(path.join(DIRECTORIES.extensions, 'third-party'))
-            .filter(f => fs.statSync(path.join(DIRECTORIES.extensions, 'third-party', f)).isDirectory());
+        const url = request.body.url;
+        const extensionPath = path.join(DIRECTORIES.extensions, 'third-party', path.basename(url, '.git'));
 
-        // add the third-party extensions to the extensions array
-        extensions.push(...thirdPartyExtensions.map(f => `third-party/${f}`));
-        console.log(extensions);
+        if (fs.existsSync(extensionPath)) {
+            return response.status(409).send(`Directory already exists at ${extensionPath}`);
+        }
+
+        await git.clone(url, extensionPath, { '--depth': 1 });
+        console.log(`Extension has been cloned at ${extensionPath}`);
 
 
+        const { version, author, display_name } = await getManifest(extensionPath);
+
+
+        return response.send({ version, author, display_name, extensionPath });
+    } catch (error) {
+        console.log('Importing custom content failed', error);
+        return response.status(500).send(`Server Error: ${error.message}`);
+    }
+});
+
+/**
+ * HTTP POST handler function to pull the latest updates from a git repository
+ * based on the extension name provided in the request body. It returns the latest commit hash,
+ * the path of the extension, the status of the repository (whether it's up-to-date or not),
+ * and the remote URL of the repository.
+ *
+ * @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property.
+ * @param {Object} response - HTTP Response object used to respond to the HTTP request.
+ *
+ * @returns {void}
+ */
+router.post('/update', jsonParser, async (request, response) => {
+    const git = simpleGit();
+    if (!request.body.extensionName) {
+        return response.status(400).send('Bad Request: extensionName is required in the request body.');
+    }
+
+    try {
+        const extensionName = request.body.extensionName;
+        const extensionPath = path.join(DIRECTORIES.extensions, 'third-party', extensionName);
+
+        if (!fs.existsSync(extensionPath)) {
+            return response.status(404).send(`Directory does not exist at ${extensionPath}`);
+        }
+
+        const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
+        const currentBranch = await git.cwd(extensionPath).branch();
+        if (!isUpToDate) {
+
+            await git.cwd(extensionPath).pull('origin', currentBranch.current);
+            console.log(`Extension has been updated at ${extensionPath}`);
+        } else {
+            console.log(`Extension is up to date at ${extensionPath}`);
+        }
+        await git.cwd(extensionPath).fetch('origin');
+        const fullCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);
+        const shortCommitHash = fullCommitHash.slice(0, 7);
+
+        return response.send({ shortCommitHash, extensionPath, isUpToDate, remoteUrl });
+
+    } catch (error) {
+        console.log('Updating custom content failed', error);
+        return response.status(500).send(`Server Error: ${error.message}`);
+    }
+});
+
+/**
+ * HTTP POST handler function to get the current git commit hash and branch name for a given extension.
+ * It checks whether the repository is up-to-date with the remote, and returns the status along with
+ * the remote URL of the repository.
+ *
+ * @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property.
+ * @param {Object} response - HTTP Response object used to respond to the HTTP request.
+ *
+ * @returns {void}
+ */
+router.post('/version', jsonParser, async (request, response) => {
+    const git = simpleGit();
+    if (!request.body.extensionName) {
+        return response.status(400).send('Bad Request: extensionName is required in the request body.');
+    }
+
+    try {
+        const extensionName = request.body.extensionName;
+        const extensionPath = path.join(DIRECTORIES.extensions, 'third-party', extensionName);
+
+        if (!fs.existsSync(extensionPath)) {
+            return response.status(404).send(`Directory does not exist at ${extensionPath}`);
+        }
+
+        const currentBranch = await git.cwd(extensionPath).branch();
+        // get only the working branch
+        const currentBranchName = currentBranch.current;
+        await git.cwd(extensionPath).fetch('origin');
+        const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);
+        console.log(currentBranch, currentCommitHash);
+        const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
+
+        return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl });
+
+    } catch (error) {
+        console.log('Getting extension version failed', error);
+        return response.status(500).send(`Server Error: ${error.message}`);
+    }
+});
+
+/**
+ * HTTP POST handler function to delete a git repository based on the extension name provided in the request body.
+ *
+ * @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property.
+ * @param {Object} response - HTTP Response object used to respond to the HTTP request.
+ *
+ * @returns {void}
+ */
+router.post('/delete', jsonParser, async (request, response) => {
+    if (!request.body.extensionName) {
+        return response.status(400).send('Bad Request: extensionName is required in the request body.');
+    }
+
+    // Sanatize the extension name to prevent directory traversal
+    const extensionName = sanitize(request.body.extensionName);
+
+    try {
+        const extensionPath = path.join(DIRECTORIES.extensions, 'third-party', extensionName);
+
+        if (!fs.existsSync(extensionPath)) {
+            return response.status(404).send(`Directory does not exist at ${extensionPath}`);
+        }
+
+        await fs.promises.rmdir(extensionPath, { recursive: true });
+        console.log(`Extension has been deleted at ${extensionPath}`);
+
+        return response.send(`Extension has been deleted at ${extensionPath}`);
+
+    } catch (error) {
+        console.log('Deleting custom content failed', error);
+        return response.status(500).send(`Server Error: ${error.message}`);
+    }
+});
+
+/**
+ * Discover the extension folders
+ * If the folder is called third-party, search for subfolders instead
+ */
+router.get('/discover', jsonParser, function (_, response) {
+    // get all folders in the extensions folder, except third-party
+    const extensions = fs
+        .readdirSync(DIRECTORIES.extensions)
+        .filter(f => fs.statSync(path.join(DIRECTORIES.extensions, f)).isDirectory())
+        .filter(f => f !== 'third-party');
+
+    // get all folders in the third-party folder, if it exists
+
+    if (!fs.existsSync(path.join(DIRECTORIES.extensions, 'third-party'))) {
         return response.send(extensions);
-    });
-}
+    }
 
-module.exports = {
-    registerEndpoints,
-};
+    const thirdPartyExtensions = fs
+        .readdirSync(path.join(DIRECTORIES.extensions, 'third-party'))
+        .filter(f => fs.statSync(path.join(DIRECTORIES.extensions, 'third-party', f)).isDirectory());
+
+    // add the third-party extensions to the extensions array
+    extensions.push(...thirdPartyExtensions.map(f => `third-party/${f}`));
+    console.log(extensions);
+
+
+    return response.send(extensions);
+});
+
+module.exports = { router };
diff --git a/src/endpoints/files.js b/src/endpoints/files.js
new file mode 100644
index 000000000..191b0f081
--- /dev/null
+++ b/src/endpoints/files.js
@@ -0,0 +1,35 @@
+const path = require('path');
+const writeFileSyncAtomic = require('write-file-atomic').sync;
+const express = require('express');
+const router = express.Router();
+const { checkAssetFileName } = require('./assets');
+const { jsonParser } = require('../express-common');
+const { DIRECTORIES } = require('../constants');
+
+router.post('/upload', jsonParser, async (request, response) => {
+    try {
+        if (!request.body.name) {
+            return response.status(400).send('No upload name specified');
+        }
+
+        if (!request.body.data) {
+            return response.status(400).send('No upload data specified');
+        }
+
+        const safeInput = checkAssetFileName(request.body.name);
+
+        if (!safeInput) {
+            return response.status(400).send('Invalid upload name');
+        }
+
+        const pathToUpload = path.join(DIRECTORIES.files, safeInput);
+        writeFileSyncAtomic(pathToUpload, request.body.data, 'base64');
+        const url = path.normalize(pathToUpload.replace('public' + path.sep, ''));
+        return response.send({ path: url });
+    } catch (error) {
+        console.log(error);
+        return response.sendStatus(500);
+    }
+});
+
+module.exports = { router };
diff --git a/src/endpoints/horde.js b/src/endpoints/horde.js
index c662be317..b8d3227cc 100644
--- a/src/endpoints/horde.js
+++ b/src/endpoints/horde.js
@@ -1,7 +1,9 @@
 const fetch = require('node-fetch').default;
+const express = require('express');
 const AIHorde = require('../ai_horde');
 const { getVersion, delay } = require('../util');
 const { readSecret, SECRET_KEYS } = require('./secrets');
+const { jsonParser } = require('../express-common');
 
 const ANONYMOUS_KEY = '0000000000';
 
@@ -52,221 +54,214 @@ function sanitizeHordeImagePrompt(prompt) {
     return prompt;
 }
 
-/**
- *
- * @param {import("express").Express} app
- * @param {any} jsonParser
- */
-function registerEndpoints(app, jsonParser) {
-    app.post('/api/horde/generate-text', jsonParser, async (request, response) => {
+const router = express.Router();
+
+router.post('/generate-text', jsonParser, async (request, response) => {
+    const api_key_horde = readSecret(SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
+    const url = 'https://horde.koboldai.net/api/v2/generate/text/async';
+
+    console.log(request.body);
+    try {
+        const result = await fetch(url, {
+            method: 'POST',
+            body: JSON.stringify(request.body),
+            headers: {
+                'Content-Type': 'application/json',
+                'apikey': api_key_horde,
+                'Client-Agent': String(request.header('Client-Agent')),
+            },
+        });
+
+        if (!result.ok) {
+            const message = await result.text();
+            console.log('Horde returned an error:', message);
+            return response.send({ error: { message } });
+        }
+
+        const data = await result.json();
+        return response.send(data);
+    } catch (error) {
+        console.log(error);
+        return response.send({ error: true });
+    }
+});
+
+router.post('/sd-samplers', jsonParser, async (_, response) => {
+    try {
+        const ai_horde = await getHordeClient();
+        const samplers = Object.values(ai_horde.ModelGenerationInputStableSamplers);
+        response.send(samplers);
+    } catch (error) {
+        console.error(error);
+        response.sendStatus(500);
+    }
+});
+
+router.post('/sd-models', jsonParser, async (_, response) => {
+    try {
+        const ai_horde = await getHordeClient();
+        const models = await ai_horde.getModels();
+        response.send(models);
+    } catch (error) {
+        console.error(error);
+        response.sendStatus(500);
+    }
+});
+
+router.post('/caption-image', jsonParser, async (request, response) => {
+    try {
         const api_key_horde = readSecret(SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
-        const url = 'https://horde.koboldai.net/api/v2/generate/text/async';
+        const ai_horde = await getHordeClient();
+        const result = await ai_horde.postAsyncInterrogate({
+            source_image: request.body.image,
+            forms: [{ name: AIHorde.ModelInterrogationFormTypes.caption }],
+        }, { token: api_key_horde });
 
-        console.log(request.body);
-        try {
-            const result = await fetch(url, {
-                method: 'POST',
-                body: JSON.stringify(request.body),
-                headers: {
-                    'Content-Type': 'application/json',
-                    'apikey': api_key_horde,
-                    'Client-Agent': String(request.header('Client-Agent')),
-                },
-            });
-
-            if (!result.ok) {
-                const message = await result.text();
-                console.log('Horde returned an error:', message);
-                return response.send({ error: { message } });
-            }
-
-            const data = await result.json();
-            return response.send(data);
-        } catch (error) {
-            console.log(error);
-            return response.send({ error: true });
-        }
-    });
-
-    app.post('/api/horde/sd-samplers', jsonParser, async (_, response) => {
-        try {
-            const ai_horde = await getHordeClient();
-            const samplers = Object.values(ai_horde.ModelGenerationInputStableSamplers);
-            response.send(samplers);
-        } catch (error) {
-            console.error(error);
-            response.sendStatus(500);
-        }
-    });
-
-    app.post('/api/horde/sd-models', jsonParser, async (_, response) => {
-        try {
-            const ai_horde = await getHordeClient();
-            const models = await ai_horde.getModels();
-            response.send(models);
-        } catch (error) {
-            console.error(error);
-            response.sendStatus(500);
-        }
-    });
-
-    app.post('/api/horde/caption-image', jsonParser, async (request, response) => {
-        try {
-            const api_key_horde = readSecret(SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
-            const ai_horde = await getHordeClient();
-            const result = await ai_horde.postAsyncInterrogate({
-                source_image: request.body.image,
-                forms: [{ name: AIHorde.ModelInterrogationFormTypes.caption }],
-            }, { token: api_key_horde });
-
-            if (!result.id) {
-                console.error('Image interrogation request is not satisfyable:', result.message || 'unknown error');
-                return response.sendStatus(400);
-            }
-
-            const MAX_ATTEMPTS = 200;
-            const CHECK_INTERVAL = 3000;
-
-            for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
-                await delay(CHECK_INTERVAL);
-                const status = await ai_horde.getInterrogationStatus(result.id);
-                console.log(status);
-
-                if (status.state === AIHorde.HordeAsyncRequestStates.done) {
-
-                    if (status.forms === undefined) {
-                        console.error('Image interrogation request failed: no forms found.');
-                        return response.sendStatus(500);
-                    }
-
-                    console.log('Image interrogation result:', status);
-                    const caption = status?.forms[0]?.result?.caption || '';
-
-                    if (!caption) {
-                        console.error('Image interrogation request failed: no caption found.');
-                        return response.sendStatus(500);
-                    }
-
-                    return response.send({ caption });
-                }
-
-                if (status.state === AIHorde.HordeAsyncRequestStates.faulted || status.state === AIHorde.HordeAsyncRequestStates.cancelled) {
-                    console.log('Image interrogation request is not successful.');
-                    return response.sendStatus(503);
-                }
-            }
-
-        } catch (error) {
-            console.error(error);
-            response.sendStatus(500);
-        }
-    });
-
-    app.post('/api/horde/user-info', jsonParser, async (_, response) => {
-        const api_key_horde = readSecret(SECRET_KEYS.HORDE);
-
-        if (!api_key_horde) {
-            return response.send({ anonymous: true });
-        }
-
-        try {
-            const ai_horde = await getHordeClient();
-            const user = await ai_horde.findUser({ token: api_key_horde });
-            return response.send(user);
-        } catch (error) {
-            console.error(error);
-            return response.sendStatus(500);
-        }
-    });
-
-    app.post('/api/horde/generate-image', jsonParser, async (request, response) => {
-        if (!request.body.prompt) {
+        if (!result.id) {
+            console.error('Image interrogation request is not satisfyable:', result.message || 'unknown error');
             return response.sendStatus(400);
         }
 
         const MAX_ATTEMPTS = 200;
         const CHECK_INTERVAL = 3000;
-        const PROMPT_THRESHOLD = 5000;
 
-        try {
-            const maxLength = PROMPT_THRESHOLD - String(request.body.negative_prompt).length - 5;
-            if (String(request.body.prompt).length > maxLength) {
-                console.log('Stable Horde prompt is too long, truncating...');
-                request.body.prompt = String(request.body.prompt).substring(0, maxLength);
-            }
+        for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
+            await delay(CHECK_INTERVAL);
+            const status = await ai_horde.getInterrogationStatus(result.id);
+            console.log(status);
 
-            // Sanitize prompt if requested
-            if (request.body.sanitize) {
-                const sanitized = sanitizeHordeImagePrompt(request.body.prompt);
+            if (status.state === AIHorde.HordeAsyncRequestStates.done) {
 
-                if (request.body.prompt !== sanitized) {
-                    console.log('Stable Horde prompt was sanitized.');
-                }
-
-                request.body.prompt = sanitized;
-            }
-
-            const api_key_horde = readSecret(SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
-            console.log('Stable Horde request:', request.body);
-
-            const ai_horde = await getHordeClient();
-            const generation = await ai_horde.postAsyncImageGenerate(
-                {
-                    prompt: `${request.body.prompt} ### ${request.body.negative_prompt}`,
-                    params:
-                    {
-                        sampler_name: request.body.sampler,
-                        hires_fix: request.body.enable_hr,
-                        // @ts-ignore - use_gfpgan param is not in the type definition, need to update to new ai_horde @ https://github.com/ZeldaFan0225/ai_horde/blob/main/index.ts
-                        use_gfpgan: request.body.restore_faces,
-                        cfg_scale: request.body.scale,
-                        steps: request.body.steps,
-                        width: request.body.width,
-                        height: request.body.height,
-                        karras: Boolean(request.body.karras),
-                        n: 1,
-                    },
-                    r2: false,
-                    nsfw: request.body.nfsw,
-                    models: [request.body.model],
-                },
-                { token: api_key_horde });
-
-            if (!generation.id) {
-                console.error('Image generation request is not satisfyable:', generation.message || 'unknown error');
-                return response.sendStatus(400);
-            }
-
-            for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
-                await delay(CHECK_INTERVAL);
-                const check = await ai_horde.getImageGenerationCheck(generation.id);
-                console.log(check);
-
-                if (check.done) {
-                    const result = await ai_horde.getImageGenerationStatus(generation.id);
-                    if (result.generations === undefined) return response.sendStatus(500);
-                    return response.send(result.generations[0].img);
-                }
-
-                /*
-                if (!check.is_possible) {
-                    return response.sendStatus(503);
-                }
-                */
-
-                if (check.faulted) {
+                if (status.forms === undefined) {
+                    console.error('Image interrogation request failed: no forms found.');
                     return response.sendStatus(500);
                 }
+
+                console.log('Image interrogation result:', status);
+                const caption = status?.forms[0]?.result?.caption || '';
+
+                if (!caption) {
+                    console.error('Image interrogation request failed: no caption found.');
+                    return response.sendStatus(500);
+                }
+
+                return response.send({ caption });
             }
 
-            return response.sendStatus(504);
-        } catch (error) {
-            console.error(error);
-            return response.sendStatus(500);
+            if (status.state === AIHorde.HordeAsyncRequestStates.faulted || status.state === AIHorde.HordeAsyncRequestStates.cancelled) {
+                console.log('Image interrogation request is not successful.');
+                return response.sendStatus(503);
+            }
         }
-    });
-}
 
-module.exports = {
-    registerEndpoints,
-};
+    } catch (error) {
+        console.error(error);
+        response.sendStatus(500);
+    }
+});
+
+router.post('/user-info', jsonParser, async (_, response) => {
+    const api_key_horde = readSecret(SECRET_KEYS.HORDE);
+
+    if (!api_key_horde) {
+        return response.send({ anonymous: true });
+    }
+
+    try {
+        const ai_horde = await getHordeClient();
+        const user = await ai_horde.findUser({ token: api_key_horde });
+        return response.send(user);
+    } catch (error) {
+        console.error(error);
+        return response.sendStatus(500);
+    }
+});
+
+router.post('/generate-image', jsonParser, async (request, response) => {
+    if (!request.body.prompt) {
+        return response.sendStatus(400);
+    }
+
+    const MAX_ATTEMPTS = 200;
+    const CHECK_INTERVAL = 3000;
+    const PROMPT_THRESHOLD = 5000;
+
+    try {
+        const maxLength = PROMPT_THRESHOLD - String(request.body.negative_prompt).length - 5;
+        if (String(request.body.prompt).length > maxLength) {
+            console.log('Stable Horde prompt is too long, truncating...');
+            request.body.prompt = String(request.body.prompt).substring(0, maxLength);
+        }
+
+        // Sanitize prompt if requested
+        if (request.body.sanitize) {
+            const sanitized = sanitizeHordeImagePrompt(request.body.prompt);
+
+            if (request.body.prompt !== sanitized) {
+                console.log('Stable Horde prompt was sanitized.');
+            }
+
+            request.body.prompt = sanitized;
+        }
+
+        const api_key_horde = readSecret(SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
+        console.log('Stable Horde request:', request.body);
+
+        const ai_horde = await getHordeClient();
+        const generation = await ai_horde.postAsyncImageGenerate(
+            {
+                prompt: `${request.body.prompt} ### ${request.body.negative_prompt}`,
+                params:
+                {
+                    sampler_name: request.body.sampler,
+                    hires_fix: request.body.enable_hr,
+                    // @ts-ignore - use_gfpgan param is not in the type definition, need to update to new ai_horde @ https://github.com/ZeldaFan0225/ai_horde/blob/main/index.ts
+                    use_gfpgan: request.body.restore_faces,
+                    cfg_scale: request.body.scale,
+                    steps: request.body.steps,
+                    width: request.body.width,
+                    height: request.body.height,
+                    karras: Boolean(request.body.karras),
+                    n: 1,
+                },
+                r2: false,
+                nsfw: request.body.nfsw,
+                models: [request.body.model],
+            },
+            { token: api_key_horde });
+
+        if (!generation.id) {
+            console.error('Image generation request is not satisfyable:', generation.message || 'unknown error');
+            return response.sendStatus(400);
+        }
+
+        for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
+            await delay(CHECK_INTERVAL);
+            const check = await ai_horde.getImageGenerationCheck(generation.id);
+            console.log(check);
+
+            if (check.done) {
+                const result = await ai_horde.getImageGenerationStatus(generation.id);
+                if (result.generations === undefined) return response.sendStatus(500);
+                return response.send(result.generations[0].img);
+            }
+
+            /*
+            if (!check.is_possible) {
+                return response.sendStatus(503);
+            }
+            */
+
+            if (check.faulted) {
+                return response.sendStatus(500);
+            }
+        }
+
+        return response.sendStatus(504);
+    } catch (error) {
+        console.error(error);
+        return response.sendStatus(500);
+    }
+});
+
+module.exports = { router };
diff --git a/src/endpoints/novelai.js b/src/endpoints/novelai.js
index 70a3305ce..2071c4c5b 100644
--- a/src/endpoints/novelai.js
+++ b/src/endpoints/novelai.js
@@ -1,8 +1,10 @@
 const fetch = require('node-fetch').default;
+const express = require('express');
 const util = require('util');
 const { Readable } = require('stream');
 const { readSecret, SECRET_KEYS } = require('./secrets');
 const { readAllChunks, extractFileFromZipBuffer } = require('../util');
+const { jsonParser } = require('../express-common');
 
 const API_NOVELAI = 'https://api.novelai.net';
 
@@ -60,312 +62,305 @@ function getBadWordsList(model) {
     return list.slice();
 }
 
-/**
- * Registers NovelAI API endpoints.
- * @param {import('express').Express} app - Express app
- * @param {any} jsonParser - JSON parser middleware
- */
-function registerEndpoints(app, jsonParser) {
-    app.post('/api/novelai/status', jsonParser, async function (req, res) {
-        if (!req.body) return res.sendStatus(400);
-        const api_key_novel = readSecret(SECRET_KEYS.NOVEL);
+const router = express.Router();
 
-        if (!api_key_novel) {
-            return res.sendStatus(401);
-        }
+router.post('/status', jsonParser, async function (req, res) {
+    if (!req.body) return res.sendStatus(400);
+    const api_key_novel = readSecret(SECRET_KEYS.NOVEL);
 
-        try {
-            const response = await fetch(API_NOVELAI + '/user/subscription', {
-                method: 'GET',
-                headers: {
-                    'Content-Type': 'application/json',
-                    'Authorization': 'Bearer ' + api_key_novel,
-                },
-            });
+    if (!api_key_novel) {
+        return res.sendStatus(401);
+    }
 
-            if (response.ok) {
-                const data = await response.json();
-                return res.send(data);
-            } else if (response.status == 401) {
-                console.log('NovelAI Access Token is incorrect.');
-                return res.send({ error: true });
-            }
-            else {
-                console.log('NovelAI returned an error:', response.statusText);
-                return res.send({ error: true });
-            }
-        } catch (error) {
-            console.log(error);
-            return res.send({ error: true });
-        }
-    });
-
-    app.post('/api/novelai/generate', jsonParser, async function (req, res) {
-        if (!req.body) return res.sendStatus(400);
-
-        const api_key_novel = readSecret(SECRET_KEYS.NOVEL);
-
-        if (!api_key_novel) {
-            return res.sendStatus(401);
-        }
-
-        const controller = new AbortController();
-        req.socket.removeAllListeners('close');
-        req.socket.on('close', function () {
-            controller.abort();
+    try {
+        const response = await fetch(API_NOVELAI + '/user/subscription', {
+            method: 'GET',
+            headers: {
+                'Content-Type': 'application/json',
+                'Authorization': 'Bearer ' + api_key_novel,
+            },
         });
 
-        const isNewModel = (req.body.model.includes('clio') || req.body.model.includes('kayra'));
-        const badWordsList = getBadWordsList(req.body.model);
-
-        // Add customized bad words for Clio and Kayra
-        if (isNewModel && Array.isArray(req.body.bad_words_ids)) {
-            for (const badWord of req.body.bad_words_ids) {
-                if (Array.isArray(badWord) && badWord.every(x => Number.isInteger(x))) {
-                    badWordsList.push(badWord);
-                }
-            }
-        }
-
-        // Remove empty arrays from bad words list
-        for (const badWord of badWordsList) {
-            if (badWord.length === 0) {
-                badWordsList.splice(badWordsList.indexOf(badWord), 1);
-            }
-        }
-
-        // Add default biases for dinkus and asterism
-        const logit_bias_exp = isNewModel ? logitBiasExp.slice() : [];
-
-        if (Array.isArray(logit_bias_exp) && Array.isArray(req.body.logit_bias_exp)) {
-            logit_bias_exp.push(...req.body.logit_bias_exp);
-        }
-
-        const data = {
-            'input': req.body.input,
-            'model': req.body.model,
-            'parameters': {
-                'use_string': req.body.use_string ?? true,
-                'temperature': req.body.temperature,
-                'max_length': req.body.max_length,
-                'min_length': req.body.min_length,
-                'tail_free_sampling': req.body.tail_free_sampling,
-                'repetition_penalty': req.body.repetition_penalty,
-                'repetition_penalty_range': req.body.repetition_penalty_range,
-                'repetition_penalty_slope': req.body.repetition_penalty_slope,
-                'repetition_penalty_frequency': req.body.repetition_penalty_frequency,
-                'repetition_penalty_presence': req.body.repetition_penalty_presence,
-                'repetition_penalty_whitelist': isNewModel ? repPenaltyAllowList : null,
-                'top_a': req.body.top_a,
-                'top_p': req.body.top_p,
-                'top_k': req.body.top_k,
-                'typical_p': req.body.typical_p,
-                'mirostat_lr': req.body.mirostat_lr,
-                'mirostat_tau': req.body.mirostat_tau,
-                'cfg_scale': req.body.cfg_scale,
-                'cfg_uc': req.body.cfg_uc,
-                'phrase_rep_pen': req.body.phrase_rep_pen,
-                'stop_sequences': req.body.stop_sequences,
-                'bad_words_ids': badWordsList.length ? badWordsList : null,
-                'logit_bias_exp': logit_bias_exp,
-                'generate_until_sentence': req.body.generate_until_sentence,
-                'use_cache': req.body.use_cache,
-                'return_full_text': req.body.return_full_text,
-                'prefix': req.body.prefix,
-                'order': req.body.order,
-            },
-        };
-
-        console.log(util.inspect(data, { depth: 4 }));
-
-        const args = {
-            body: JSON.stringify(data),
-            headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + api_key_novel },
-            signal: controller.signal,
-        };
-
-        try {
-            const url = req.body.streaming ? `${API_NOVELAI}/ai/generate-stream` : `${API_NOVELAI}/ai/generate`;
-            const response = await fetch(url, { method: 'POST', timeout: 0, ...args });
-
-            if (req.body.streaming) {
-                // Pipe remote SSE stream to Express response
-                response.body.pipe(res);
-
-                req.socket.on('close', function () {
-                    if (response.body instanceof Readable) response.body.destroy(); // Close the remote stream
-                    res.end(); // End the Express response
-                });
-
-                response.body.on('end', function () {
-                    console.log('Streaming request finished');
-                    res.end();
-                });
-            } else {
-                if (!response.ok) {
-                    const text = await response.text();
-                    let message = text;
-                    console.log(`Novel API returned error: ${response.status} ${response.statusText} ${text}`);
-
-                    try {
-                        const data = JSON.parse(text);
-                        message = data.message;
-                    }
-                    catch {
-                        // ignore
-                    }
-
-                    return res.status(response.status).send({ error: { message } });
-                }
-
-                const data = await response.json();
-                console.log(data);
-                return res.send(data);
-            }
-        } catch (error) {
+        if (response.ok) {
+            const data = await response.json();
+            return res.send(data);
+        } else if (response.status == 401) {
+            console.log('NovelAI Access Token is incorrect.');
             return res.send({ error: true });
         }
+        else {
+            console.log('NovelAI returned an error:', response.statusText);
+            return res.send({ error: true });
+        }
+    } catch (error) {
+        console.log(error);
+        return res.send({ error: true });
+    }
+});
+
+router.post('/generate', jsonParser, async function (req, res) {
+    if (!req.body) return res.sendStatus(400);
+
+    const api_key_novel = readSecret(SECRET_KEYS.NOVEL);
+
+    if (!api_key_novel) {
+        return res.sendStatus(401);
+    }
+
+    const controller = new AbortController();
+    req.socket.removeAllListeners('close');
+    req.socket.on('close', function () {
+        controller.abort();
     });
 
-    app.post('/api/novelai/generate-image', jsonParser, async (request, response) => {
-        if (!request.body) {
-            return response.sendStatus(400);
+    const isNewModel = (req.body.model.includes('clio') || req.body.model.includes('kayra'));
+    const badWordsList = getBadWordsList(req.body.model);
+
+    // Add customized bad words for Clio and Kayra
+    if (isNewModel && Array.isArray(req.body.bad_words_ids)) {
+        for (const badWord of req.body.bad_words_ids) {
+            if (Array.isArray(badWord) && badWord.every(x => Number.isInteger(x))) {
+                badWordsList.push(badWord);
+            }
+        }
+    }
+
+    // Remove empty arrays from bad words list
+    for (const badWord of badWordsList) {
+        if (badWord.length === 0) {
+            badWordsList.splice(badWordsList.indexOf(badWord), 1);
+        }
+    }
+
+    // Add default biases for dinkus and asterism
+    const logit_bias_exp = isNewModel ? logitBiasExp.slice() : [];
+
+    if (Array.isArray(logit_bias_exp) && Array.isArray(req.body.logit_bias_exp)) {
+        logit_bias_exp.push(...req.body.logit_bias_exp);
+    }
+
+    const data = {
+        'input': req.body.input,
+        'model': req.body.model,
+        'parameters': {
+            'use_string': req.body.use_string ?? true,
+            'temperature': req.body.temperature,
+            'max_length': req.body.max_length,
+            'min_length': req.body.min_length,
+            'tail_free_sampling': req.body.tail_free_sampling,
+            'repetition_penalty': req.body.repetition_penalty,
+            'repetition_penalty_range': req.body.repetition_penalty_range,
+            'repetition_penalty_slope': req.body.repetition_penalty_slope,
+            'repetition_penalty_frequency': req.body.repetition_penalty_frequency,
+            'repetition_penalty_presence': req.body.repetition_penalty_presence,
+            'repetition_penalty_whitelist': isNewModel ? repPenaltyAllowList : null,
+            'top_a': req.body.top_a,
+            'top_p': req.body.top_p,
+            'top_k': req.body.top_k,
+            'typical_p': req.body.typical_p,
+            'mirostat_lr': req.body.mirostat_lr,
+            'mirostat_tau': req.body.mirostat_tau,
+            'cfg_scale': req.body.cfg_scale,
+            'cfg_uc': req.body.cfg_uc,
+            'phrase_rep_pen': req.body.phrase_rep_pen,
+            'stop_sequences': req.body.stop_sequences,
+            'bad_words_ids': badWordsList.length ? badWordsList : null,
+            'logit_bias_exp': logit_bias_exp,
+            'generate_until_sentence': req.body.generate_until_sentence,
+            'use_cache': req.body.use_cache,
+            'return_full_text': req.body.return_full_text,
+            'prefix': req.body.prefix,
+            'order': req.body.order,
+        },
+    };
+
+    console.log(util.inspect(data, { depth: 4 }));
+
+    const args = {
+        body: JSON.stringify(data),
+        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + api_key_novel },
+        signal: controller.signal,
+    };
+
+    try {
+        const url = req.body.streaming ? `${API_NOVELAI}/ai/generate-stream` : `${API_NOVELAI}/ai/generate`;
+        const response = await fetch(url, { method: 'POST', timeout: 0, ...args });
+
+        if (req.body.streaming) {
+            // Pipe remote SSE stream to Express response
+            response.body.pipe(res);
+
+            req.socket.on('close', function () {
+                if (response.body instanceof Readable) response.body.destroy(); // Close the remote stream
+                res.end(); // End the Express response
+            });
+
+            response.body.on('end', function () {
+                console.log('Streaming request finished');
+                res.end();
+            });
+        } else {
+            if (!response.ok) {
+                const text = await response.text();
+                let message = text;
+                console.log(`Novel API returned error: ${response.status} ${response.statusText} ${text}`);
+
+                try {
+                    const data = JSON.parse(text);
+                    message = data.message;
+                }
+                catch {
+                    // ignore
+                }
+
+                return res.status(response.status).send({ error: { message } });
+            }
+
+            const data = await response.json();
+            console.log(data);
+            return res.send(data);
+        }
+    } catch (error) {
+        return res.send({ error: true });
+    }
+});
+
+router.post('/generate-image', jsonParser, async (request, response) => {
+    if (!request.body) {
+        return response.sendStatus(400);
+    }
+
+    const key = readSecret(SECRET_KEYS.NOVEL);
+
+    if (!key) {
+        return response.sendStatus(401);
+    }
+
+    try {
+        console.log('NAI Diffusion request:', request.body);
+        const generateUrl = `${API_NOVELAI}/ai/generate-image`;
+        const generateResult = await fetch(generateUrl, {
+            method: 'POST',
+            headers: {
+                'Authorization': `Bearer ${key}`,
+                'Content-Type': 'application/json',
+            },
+            body: JSON.stringify({
+                action: 'generate',
+                input: request.body.prompt,
+                model: request.body.model ?? 'nai-diffusion',
+                parameters: {
+                    negative_prompt: request.body.negative_prompt ?? '',
+                    height: request.body.height ?? 512,
+                    width: request.body.width ?? 512,
+                    scale: request.body.scale ?? 9,
+                    seed: Math.floor(Math.random() * 9999999999),
+                    sampler: request.body.sampler ?? 'k_dpmpp_2m',
+                    steps: request.body.steps ?? 28,
+                    n_samples: 1,
+                    // NAI handholding for prompts
+                    ucPreset: 0,
+                    qualityToggle: false,
+                    add_original_image: false,
+                    controlnet_strength: 1,
+                    dynamic_thresholding: false,
+                    legacy: false,
+                    sm: false,
+                    sm_dyn: false,
+                    uncond_scale: 1,
+                },
+            }),
+        });
+
+        if (!generateResult.ok) {
+            const text = await generateResult.text();
+            console.log('NovelAI returned an error.', generateResult.statusText, text);
+            return response.sendStatus(500);
         }
 
-        const key = readSecret(SECRET_KEYS.NOVEL);
+        const archiveBuffer = await generateResult.arrayBuffer();
+        const imageBuffer = await extractFileFromZipBuffer(archiveBuffer, '.png');
+        const originalBase64 = imageBuffer.toString('base64');
 
-        if (!key) {
-            return response.sendStatus(401);
+        // No upscaling
+        if (isNaN(request.body.upscale_ratio) || request.body.upscale_ratio <= 1) {
+            return response.send(originalBase64);
         }
 
         try {
-            console.log('NAI Diffusion request:', request.body);
-            const generateUrl = `${API_NOVELAI}/ai/generate-image`;
-            const generateResult = await fetch(generateUrl, {
+            console.debug('Upscaling image...');
+            const upscaleUrl = `${API_NOVELAI}/ai/upscale`;
+            const upscaleResult = await fetch(upscaleUrl, {
                 method: 'POST',
                 headers: {
                     'Authorization': `Bearer ${key}`,
                     'Content-Type': 'application/json',
                 },
                 body: JSON.stringify({
-                    action: 'generate',
-                    input: request.body.prompt,
-                    model: request.body.model ?? 'nai-diffusion',
-                    parameters: {
-                        negative_prompt: request.body.negative_prompt ?? '',
-                        height: request.body.height ?? 512,
-                        width: request.body.width ?? 512,
-                        scale: request.body.scale ?? 9,
-                        seed: Math.floor(Math.random() * 9999999999),
-                        sampler: request.body.sampler ?? 'k_dpmpp_2m',
-                        steps: request.body.steps ?? 28,
-                        n_samples: 1,
-                        // NAI handholding for prompts
-                        ucPreset: 0,
-                        qualityToggle: false,
-                        add_original_image: false,
-                        controlnet_strength: 1,
-                        dynamic_thresholding: false,
-                        legacy: false,
-                        sm: false,
-                        sm_dyn: false,
-                        uncond_scale: 1,
-                    },
+                    image: originalBase64,
+                    height: request.body.height,
+                    width: request.body.width,
+                    scale: request.body.upscale_ratio,
                 }),
             });
 
-            if (!generateResult.ok) {
-                const text = await generateResult.text();
-                console.log('NovelAI returned an error.', generateResult.statusText, text);
-                return response.sendStatus(500);
+            if (!upscaleResult.ok) {
+                throw new Error('NovelAI returned an error.');
             }
 
-            const archiveBuffer = await generateResult.arrayBuffer();
-            const imageBuffer = await extractFileFromZipBuffer(archiveBuffer, '.png');
-            const originalBase64 = imageBuffer.toString('base64');
+            const upscaledArchiveBuffer = await upscaleResult.arrayBuffer();
+            const upscaledImageBuffer = await extractFileFromZipBuffer(upscaledArchiveBuffer, '.png');
+            const upscaledBase64 = upscaledImageBuffer.toString('base64');
 
-            // No upscaling
-            if (isNaN(request.body.upscale_ratio) || request.body.upscale_ratio <= 1) {
-                return response.send(originalBase64);
-            }
-
-            try {
-                console.debug('Upscaling image...');
-                const upscaleUrl = `${API_NOVELAI}/ai/upscale`;
-                const upscaleResult = await fetch(upscaleUrl, {
-                    method: 'POST',
-                    headers: {
-                        'Authorization': `Bearer ${key}`,
-                        'Content-Type': 'application/json',
-                    },
-                    body: JSON.stringify({
-                        image: originalBase64,
-                        height: request.body.height,
-                        width: request.body.width,
-                        scale: request.body.upscale_ratio,
-                    }),
-                });
-
-                if (!upscaleResult.ok) {
-                    throw new Error('NovelAI returned an error.');
-                }
-
-                const upscaledArchiveBuffer = await upscaleResult.arrayBuffer();
-                const upscaledImageBuffer = await extractFileFromZipBuffer(upscaledArchiveBuffer, '.png');
-                const upscaledBase64 = upscaledImageBuffer.toString('base64');
-
-                return response.send(upscaledBase64);
-            } catch (error) {
-                console.warn('NovelAI generated an image, but upscaling failed. Returning original image.');
-                return response.send(originalBase64);
-            }
+            return response.send(upscaledBase64);
         } catch (error) {
-            console.log(error);
-            return response.sendStatus(500);
+            console.warn('NovelAI generated an image, but upscaling failed. Returning original image.');
+            return response.send(originalBase64);
         }
-    });
+    } catch (error) {
+        console.log(error);
+        return response.sendStatus(500);
+    }
+});
 
-    app.post('/api/novelai/generate-voice', jsonParser, async (request, response) => {
-        const token = readSecret(SECRET_KEYS.NOVEL);
+router.post('/generate-voice', jsonParser, async (request, response) => {
+    const token = readSecret(SECRET_KEYS.NOVEL);
 
-        if (!token) {
-            return response.sendStatus(401);
+    if (!token) {
+        return response.sendStatus(401);
+    }
+
+    const text = request.body.text;
+    const voice = request.body.voice;
+
+    if (!text || !voice) {
+        return response.sendStatus(400);
+    }
+
+    try {
+        const url = `${API_NOVELAI}/ai/generate-voice?text=${encodeURIComponent(text)}&voice=-1&seed=${encodeURIComponent(voice)}&opus=false&version=v2`;
+        const result = await fetch(url, {
+            method: 'GET',
+            headers: {
+                'Authorization': `Bearer ${token}`,
+                'Accept': 'audio/mpeg',
+            },
+            timeout: 0,
+        });
+
+        if (!result.ok) {
+            return response.sendStatus(result.status);
         }
 
-        const text = request.body.text;
-        const voice = request.body.voice;
+        const chunks = await readAllChunks(result.body);
+        const buffer = Buffer.concat(chunks);
+        response.setHeader('Content-Type', 'audio/mpeg');
+        return response.send(buffer);
+    }
+    catch (error) {
+        console.error(error);
+        return response.sendStatus(500);
+    }
+});
 
-        if (!text || !voice) {
-            return response.sendStatus(400);
-        }
-
-        try {
-            const url = `${API_NOVELAI}/ai/generate-voice?text=${encodeURIComponent(text)}&voice=-1&seed=${encodeURIComponent(voice)}&opus=false&version=v2`;
-            const result = await fetch(url, {
-                method: 'GET',
-                headers: {
-                    'Authorization': `Bearer ${token}`,
-                    'Accept': 'audio/mpeg',
-                },
-                timeout: 0,
-            });
-
-            if (!result.ok) {
-                return response.sendStatus(result.status);
-            }
-
-            const chunks = await readAllChunks(result.body);
-            const buffer = Buffer.concat(chunks);
-            response.setHeader('Content-Type', 'audio/mpeg');
-            return response.send(buffer);
-        }
-        catch (error) {
-            console.error(error);
-            return response.sendStatus(500);
-        }
-    });
-}
-
-module.exports = {
-    registerEndpoints,
-};
+module.exports = { router };
diff --git a/src/endpoints/openai.js b/src/endpoints/openai.js
index 974027b0d..25c91b8c6 100644
--- a/src/endpoints/openai.js
+++ b/src/endpoints/openai.js
@@ -1,216 +1,210 @@
 const { readSecret, SECRET_KEYS } = require('./secrets');
 const fetch = require('node-fetch').default;
+const express = require('express');
 const FormData = require('form-data');
 const fs = require('fs');
+const { jsonParser, urlencodedParser } = require('../express-common');
 
-/**
- * Registers the OpenAI endpoints.
- * @param {import("express").Express} app Express app
- * @param {any} jsonParser JSON parser
- * @param {any} urlencodedParser Form data parser
- */
-function registerEndpoints(app, jsonParser, urlencodedParser) {
-    app.post('/api/openai/caption-image', jsonParser, async (request, response) => {
-        try {
-            let key = '';
+const router = express.Router();
 
-            if (request.body.api === 'openai') {
-                key = readSecret(SECRET_KEYS.OPENAI);
-            }
+router.post('/caption-image', jsonParser, async (request, response) => {
+    try {
+        let key = '';
 
-            if (request.body.api === 'openrouter') {
-                key = readSecret(SECRET_KEYS.OPENROUTER);
-            }
+        if (request.body.api === 'openai') {
+            key = readSecret(SECRET_KEYS.OPENAI);
+        }
 
-            if (!key) {
-                console.log('No key found for API', request.body.api);
-                return response.sendStatus(401);
-            }
+        if (request.body.api === 'openrouter') {
+            key = readSecret(SECRET_KEYS.OPENROUTER);
+        }
 
-            const body = {
-                model: request.body.model,
-                messages: [
-                    {
-                        role: 'user',
-                        content: [
-                            { type: 'text', text: request.body.prompt },
-                            { type: 'image_url', image_url: { 'url': request.body.image } },
-                        ],
-                    },
-                ],
-                max_tokens: 500,
-            };
+        if (!key) {
+            console.log('No key found for API', request.body.api);
+            return response.sendStatus(401);
+        }
 
-            console.log('Multimodal captioning request', body);
-
-            let apiUrl = '';
-            let headers = {};
-
-            if (request.body.api === 'openrouter') {
-                apiUrl = 'https://openrouter.ai/api/v1/chat/completions';
-                headers['HTTP-Referer'] = request.headers.referer;
-            }
-
-            if (request.body.api === 'openai') {
-                apiUrl = 'https://api.openai.com/v1/chat/completions';
-            }
-
-            const result = await fetch(apiUrl, {
-                method: 'POST',
-                headers: {
-                    'Content-Type': 'application/json',
-                    Authorization: `Bearer ${key}`,
-                    ...headers,
+        const body = {
+            model: request.body.model,
+            messages: [
+                {
+                    role: 'user',
+                    content: [
+                        { type: 'text', text: request.body.prompt },
+                        { type: 'image_url', image_url: { 'url': request.body.image } },
+                    ],
                 },
-                body: JSON.stringify(body),
-                timeout: 0,
-            });
+            ],
+            max_tokens: 500,
+        };
 
-            if (!result.ok) {
-                const text = await result.text();
-                console.log('Multimodal captioning request failed', result.statusText, text);
-                return response.status(500).send(text);
-            }
+        console.log('Multimodal captioning request', body);
 
-            const data = await result.json();
-            console.log('Multimodal captioning response', data);
-            const caption = data?.choices[0]?.message?.content;
+        let apiUrl = '';
+        let headers = {};
 
-            if (!caption) {
-                return response.status(500).send('No caption found');
-            }
-
-            return response.json({ caption });
+        if (request.body.api === 'openrouter') {
+            apiUrl = 'https://openrouter.ai/api/v1/chat/completions';
+            headers['HTTP-Referer'] = request.headers.referer;
         }
-        catch (error) {
-            console.error(error);
-            response.status(500).send('Internal server error');
+
+        if (request.body.api === 'openai') {
+            apiUrl = 'https://api.openai.com/v1/chat/completions';
         }
-    });
 
-    app.post('/api/openai/transcribe-audio', urlencodedParser, async (request, response) => {
-        try {
-            const key = readSecret(SECRET_KEYS.OPENAI);
+        const result = await fetch(apiUrl, {
+            method: 'POST',
+            headers: {
+                'Content-Type': 'application/json',
+                Authorization: `Bearer ${key}`,
+                ...headers,
+            },
+            body: JSON.stringify(body),
+            timeout: 0,
+        });
 
-            if (!key) {
-                console.log('No OpenAI key found');
-                return response.sendStatus(401);
-            }
-
-            if (!request.file) {
-                console.log('No audio file found');
-                return response.sendStatus(400);
-            }
-
-            const formData = new FormData();
-            console.log('Processing audio file', request.file.path);
-            formData.append('file', fs.createReadStream(request.file.path), { filename: 'audio.wav', contentType: 'audio/wav' });
-            formData.append('model', request.body.model);
-
-            if (request.body.language) {
-                formData.append('language', request.body.language);
-            }
-
-            const result = await fetch('https://api.openai.com/v1/audio/transcriptions', {
-                method: 'POST',
-                headers: {
-                    'Authorization': `Bearer ${key}`,
-                    ...formData.getHeaders(),
-                },
-                body: formData,
-            });
-
-            if (!result.ok) {
-                const text = await result.text();
-                console.log('OpenAI request failed', result.statusText, text);
-                return response.status(500).send(text);
-            }
-
-            fs.rmSync(request.file.path);
-            const data = await result.json();
-            console.log('OpenAI transcription response', data);
-            return response.json(data);
-        } catch (error) {
-            console.error('OpenAI transcription failed', error);
-            response.status(500).send('Internal server error');
+        if (!result.ok) {
+            const text = await result.text();
+            console.log('Multimodal captioning request failed', result.statusText, text);
+            return response.status(500).send(text);
         }
-    });
 
-    app.post('/api/openai/generate-voice', jsonParser, async (request, response) => {
-        try {
-            const key = readSecret(SECRET_KEYS.OPENAI);
+        const data = await result.json();
+        console.log('Multimodal captioning response', data);
+        const caption = data?.choices[0]?.message?.content;
 
-            if (!key) {
-                console.log('No OpenAI key found');
-                return response.sendStatus(401);
-            }
-
-            const result = await fetch('https://api.openai.com/v1/audio/speech', {
-                method: 'POST',
-                headers: {
-                    'Content-Type': 'application/json',
-                    Authorization: `Bearer ${key}`,
-                },
-                body: JSON.stringify({
-                    input: request.body.text,
-                    response_format: 'mp3',
-                    voice: request.body.voice ?? 'alloy',
-                    speed: request.body.speed ?? 1,
-                    model: request.body.model ?? 'tts-1',
-                }),
-            });
-
-            if (!result.ok) {
-                const text = await result.text();
-                console.log('OpenAI request failed', result.statusText, text);
-                return response.status(500).send(text);
-            }
-
-            const buffer = await result.arrayBuffer();
-            response.setHeader('Content-Type', 'audio/mpeg');
-            return response.send(Buffer.from(buffer));
-        } catch (error) {
-            console.error('OpenAI TTS generation failed', error);
-            response.status(500).send('Internal server error');
+        if (!caption) {
+            return response.status(500).send('No caption found');
         }
-    });
 
-    app.post('/api/openai/generate-image', jsonParser, async (request, response) => {
-        try {
-            const key = readSecret(SECRET_KEYS.OPENAI);
+        return response.json({ caption });
+    }
+    catch (error) {
+        console.error(error);
+        response.status(500).send('Internal server error');
+    }
+});
 
-            if (!key) {
-                console.log('No OpenAI key found');
-                return response.sendStatus(401);
-            }
+router.post('/transcribe-audio', urlencodedParser, async (request, response) => {
+    try {
+        const key = readSecret(SECRET_KEYS.OPENAI);
 
-            console.log('OpenAI request', request.body);
-
-            const result = await fetch('https://api.openai.com/v1/images/generations', {
-                method: 'POST',
-                headers: {
-                    'Content-Type': 'application/json',
-                    Authorization: `Bearer ${key}`,
-                },
-                body: JSON.stringify(request.body),
-                timeout: 0,
-            });
-
-            if (!result.ok) {
-                const text = await result.text();
-                console.log('OpenAI request failed', result.statusText, text);
-                return response.status(500).send(text);
-            }
-
-            const data = await result.json();
-            return response.json(data);
-        } catch (error) {
-            console.error(error);
-            response.status(500).send('Internal server error');
+        if (!key) {
+            console.log('No OpenAI key found');
+            return response.sendStatus(401);
         }
-    });
-}
 
-module.exports = {
-    registerEndpoints,
-};
+        if (!request.file) {
+            console.log('No audio file found');
+            return response.sendStatus(400);
+        }
+
+        const formData = new FormData();
+        console.log('Processing audio file', request.file.path);
+        formData.append('file', fs.createReadStream(request.file.path), { filename: 'audio.wav', contentType: 'audio/wav' });
+        formData.append('model', request.body.model);
+
+        if (request.body.language) {
+            formData.append('language', request.body.language);
+        }
+
+        const result = await fetch('https://api.openai.com/v1/audio/transcriptions', {
+            method: 'POST',
+            headers: {
+                'Authorization': `Bearer ${key}`,
+                ...formData.getHeaders(),
+            },
+            body: formData,
+        });
+
+        if (!result.ok) {
+            const text = await result.text();
+            console.log('OpenAI request failed', result.statusText, text);
+            return response.status(500).send(text);
+        }
+
+        fs.rmSync(request.file.path);
+        const data = await result.json();
+        console.log('OpenAI transcription response', data);
+        return response.json(data);
+    } catch (error) {
+        console.error('OpenAI transcription failed', error);
+        response.status(500).send('Internal server error');
+    }
+});
+
+router.post('/generate-voice', jsonParser, async (request, response) => {
+    try {
+        const key = readSecret(SECRET_KEYS.OPENAI);
+
+        if (!key) {
+            console.log('No OpenAI key found');
+            return response.sendStatus(401);
+        }
+
+        const result = await fetch('https://api.openai.com/v1/audio/speech', {
+            method: 'POST',
+            headers: {
+                'Content-Type': 'application/json',
+                Authorization: `Bearer ${key}`,
+            },
+            body: JSON.stringify({
+                input: request.body.text,
+                response_format: 'mp3',
+                voice: request.body.voice ?? 'alloy',
+                speed: request.body.speed ?? 1,
+                model: request.body.model ?? 'tts-1',
+            }),
+        });
+
+        if (!result.ok) {
+            const text = await result.text();
+            console.log('OpenAI request failed', result.statusText, text);
+            return response.status(500).send(text);
+        }
+
+        const buffer = await result.arrayBuffer();
+        response.setHeader('Content-Type', 'audio/mpeg');
+        return response.send(Buffer.from(buffer));
+    } catch (error) {
+        console.error('OpenAI TTS generation failed', error);
+        response.status(500).send('Internal server error');
+    }
+});
+
+router.post('/generate-image', jsonParser, async (request, response) => {
+    try {
+        const key = readSecret(SECRET_KEYS.OPENAI);
+
+        if (!key) {
+            console.log('No OpenAI key found');
+            return response.sendStatus(401);
+        }
+
+        console.log('OpenAI request', request.body);
+
+        const result = await fetch('https://api.openai.com/v1/images/generations', {
+            method: 'POST',
+            headers: {
+                'Content-Type': 'application/json',
+                Authorization: `Bearer ${key}`,
+            },
+            body: JSON.stringify(request.body),
+            timeout: 0,
+        });
+
+        if (!result.ok) {
+            const text = await result.text();
+            console.log('OpenAI request failed', result.statusText, text);
+            return response.status(500).send(text);
+        }
+
+        const data = await result.json();
+        return response.json(data);
+    } catch (error) {
+        console.error(error);
+        response.status(500).send('Internal server error');
+    }
+});
+
+module.exports = { router };
diff --git a/src/endpoints/presets.js b/src/endpoints/presets.js
index 3b2ceba51..f67900aff 100644
--- a/src/endpoints/presets.js
+++ b/src/endpoints/presets.js
@@ -1,9 +1,11 @@
 const fs = require('fs');
 const path = require('path');
+const express = require('express');
 const sanitize = require('sanitize-filename');
 const writeFileAtomicSync = require('write-file-atomic').sync;
 const { DIRECTORIES } = require('../constants');
 const { getDefaultPresetFile, getDefaultPresets } = require('./content-manager');
+const { jsonParser } = require('../express-common');
 
 /**
  * Gets the folder and extension for the preset settings based on the API source ID.
@@ -30,105 +32,98 @@ function getPresetSettingsByAPI(apiId) {
     }
 }
 
-/**
- * Registers the preset management endpoints.
- * @param {import('express').Express} app Express app
- * @param {any} jsonParser JSON parser middleware
- */
-function registerEndpoints(app, jsonParser) {
-    app.post('/api/presets/save', jsonParser, function (request, response) {
-        const name = sanitize(request.body.name);
-        if (!request.body.preset || !name) {
-            return response.sendStatus(400);
-        }
+const router = express.Router();
 
+router.post('/save', jsonParser, function (request, response) {
+    const name = sanitize(request.body.name);
+    if (!request.body.preset || !name) {
+        return response.sendStatus(400);
+    }
+
+    const settings = getPresetSettingsByAPI(request.body.apiId);
+    const filename = name + settings.extension;
+
+    if (!settings.folder) {
+        return response.sendStatus(400);
+    }
+
+    const fullpath = path.join(settings.folder, filename);
+    writeFileAtomicSync(fullpath, JSON.stringify(request.body.preset, null, 4), 'utf-8');
+    return response.send({ name });
+});
+
+router.post('/delete', jsonParser, function (request, response) {
+    const name = sanitize(request.body.name);
+    if (!name) {
+        return response.sendStatus(400);
+    }
+
+    const settings = getPresetSettingsByAPI(request.body.apiId);
+    const filename = name + settings.extension;
+
+    if (!settings.folder) {
+        return response.sendStatus(400);
+    }
+
+    const fullpath = path.join(settings.folder, filename);
+
+    if (fs.existsSync(fullpath)) {
+        fs.unlinkSync(fullpath);
+        return response.sendStatus(200);
+    } else {
+        return response.sendStatus(404);
+    }
+});
+
+router.post('/restore', jsonParser, function (request, response) {
+    try {
         const settings = getPresetSettingsByAPI(request.body.apiId);
-        const filename = name + settings.extension;
-
-        if (!settings.folder) {
-            return response.sendStatus(400);
-        }
-
-        const fullpath = path.join(settings.folder, filename);
-        writeFileAtomicSync(fullpath, JSON.stringify(request.body.preset, null, 4), 'utf-8');
-        return response.send({ name });
-    });
-
-    app.post('/api/presets/delete', jsonParser, function (request, response) {
         const name = sanitize(request.body.name);
-        if (!name) {
-            return response.sendStatus(400);
+        const defaultPresets = getDefaultPresets();
+
+        const defaultPreset = defaultPresets.find(p => p.name === name && p.folder === settings.folder);
+
+        const result = { isDefault: false, preset: {} };
+
+        if (defaultPreset) {
+            result.isDefault = true;
+            result.preset = getDefaultPresetFile(defaultPreset.filename) || {};
         }
 
-        const settings = getPresetSettingsByAPI(request.body.apiId);
-        const filename = name + settings.extension;
+        return response.send(result);
+    } catch (error) {
+        console.log(error);
+        return response.sendStatus(500);
+    }
+});
 
-        if (!settings.folder) {
-            return response.sendStatus(400);
-        }
+// TODO: Merge with /api/presets/save
+router.post('/save-openai', jsonParser, function (request, response) {
+    if (!request.body || typeof request.query.name !== 'string') return response.sendStatus(400);
+    const name = sanitize(request.query.name);
+    if (!name) return response.sendStatus(400);
 
-        const fullpath = path.join(settings.folder, filename);
+    const filename = `${name}.settings`;
+    const fullpath = path.join(DIRECTORIES.openAI_Settings, filename);
+    writeFileAtomicSync(fullpath, JSON.stringify(request.body, null, 4), 'utf-8');
+    return response.send({ name });
+});
 
-        if (fs.existsSync(fullpath)) {
-            fs.unlinkSync(fullpath);
-            return response.sendStatus(200);
-        } else {
-            return response.sendStatus(404);
-        }
-    });
+// TODO: Merge with /api/presets/delete
+router.post('/delete-openai', jsonParser, function (request, response) {
+    if (!request.body || !request.body.name) {
+        return response.sendStatus(400);
+    }
 
-    app.post('/api/presets/restore', jsonParser, function (request, response) {
-        try {
-            const settings = getPresetSettingsByAPI(request.body.apiId);
-            const name = sanitize(request.body.name);
-            const defaultPresets = getDefaultPresets();
+    const name = request.body.name;
+    const pathToFile = path.join(DIRECTORIES.openAI_Settings, `${name}.settings`);
 
-            const defaultPreset = defaultPresets.find(p => p.name === name && p.folder === settings.folder);
+    if (fs.existsSync(pathToFile)) {
+        fs.rmSync(pathToFile);
+        return response.send({ ok: true });
+    }
 
-            const result = { isDefault: false, preset: {} };
+    return response.send({ error: true });
+});
 
-            if (defaultPreset) {
-                result.isDefault = true;
-                result.preset = getDefaultPresetFile(defaultPreset.filename) || {};
-            }
-
-            return response.send(result);
-        } catch (error) {
-            console.log(error);
-            return response.sendStatus(500);
-        }
-    });
-
-    // TODO: Merge with /api/presets/save
-    app.post('/api/presets/save-openai', jsonParser, function (request, response) {
-        if (!request.body || typeof request.query.name !== 'string') return response.sendStatus(400);
-        const name = sanitize(request.query.name);
-        if (!name) return response.sendStatus(400);
-
-        const filename = `${name}.settings`;
-        const fullpath = path.join(DIRECTORIES.openAI_Settings, filename);
-        writeFileAtomicSync(fullpath, JSON.stringify(request.body, null, 4), 'utf-8');
-        return response.send({ name });
-    });
-
-    // TODO: Merge with /api/presets/delete
-    app.post('/api/presets/delete-openai', jsonParser, function (request, response) {
-        if (!request.body || !request.body.name) {
-            return response.sendStatus(400);
-        }
-
-        const name = request.body.name;
-        const pathToFile = path.join(DIRECTORIES.openAI_Settings, `${name}.settings`);
-
-        if (fs.existsSync(pathToFile)) {
-            fs.rmSync(pathToFile);
-            return response.send({ ok: true });
-        }
-
-        return response.send({ error: true });
-    });
-}
-
-module.exports = {
-    registerEndpoints,
-};
+module.exports = { router };
diff --git a/src/endpoints/secrets.js b/src/endpoints/secrets.js
index a0282c5e1..54687cbeb 100644
--- a/src/endpoints/secrets.js
+++ b/src/endpoints/secrets.js
@@ -1,7 +1,9 @@
 const fs = require('fs');
 const path = require('path');
+const express = require('express');
 const { getConfigValue } = require('../util');
 const writeFileAtomicSync = require('write-file-atomic').sync;
+const { jsonParser } = require('../express-common');
 
 const SECRETS_FILE = path.join(process.cwd(), './secrets.json');
 const SECRET_KEYS = {
@@ -143,78 +145,71 @@ function getAllSecrets() {
     return secrets;
 }
 
-/**
- * Registers endpoints for the secret management API
- * @param {import('express').Express} app Express app
- * @param {any} jsonParser JSON parser middleware
- */
-function registerEndpoints(app, jsonParser) {
+const router = express.Router();
 
-    app.post('/api/secrets/write', jsonParser, (request, response) => {
-        const key = request.body.key;
-        const value = request.body.value;
+router.post('/write', jsonParser, (request, response) => {
+    const key = request.body.key;
+    const value = request.body.value;
 
-        writeSecret(key, value);
-        return response.send('ok');
-    });
+    writeSecret(key, value);
+    return response.send('ok');
+});
 
-    app.post('/api/secrets/read', jsonParser, (_, response) => {
+router.post('/read', jsonParser, (_, response) => {
+    try {
+        const state = readSecretState();
+        return response.send(state);
+    } catch (error) {
+        console.error(error);
+        return response.send({});
+    }
+});
 
-        try {
-            const state = readSecretState();
-            return response.send(state);
-        } catch (error) {
-            console.error(error);
-            return response.send({});
-        }
-    });
+router.post('/view', jsonParser, async (_, response) => {
+    const allowKeysExposure = getConfigValue('allowKeysExposure', false);
 
-    app.post('/api/secrets/view', jsonParser, async (_, response) => {
-        const allowKeysExposure = getConfigValue('allowKeysExposure', false);
+    if (!allowKeysExposure) {
+        console.error('secrets.json could not be viewed unless the value of allowKeysExposure in config.yaml is set to true');
+        return response.sendStatus(403);
+    }
 
-        if (!allowKeysExposure) {
-            console.error('secrets.json could not be viewed unless the value of allowKeysExposure in config.yaml is set to true');
-            return response.sendStatus(403);
+    try {
+        const secrets = getAllSecrets();
+
+        if (!secrets) {
+            return response.sendStatus(404);
         }
 
-        try {
-            const secrets = getAllSecrets();
+        return response.send(secrets);
+    } catch (error) {
+        console.error(error);
+        return response.sendStatus(500);
+    }
+});
 
-            if (!secrets) {
-                return response.sendStatus(404);
-            }
+router.post('/find', jsonParser, (request, response) => {
+    const allowKeysExposure = getConfigValue('allowKeysExposure', false);
 
-            return response.send(secrets);
-        } catch (error) {
-            console.error(error);
-            return response.sendStatus(500);
-        }
-    });
+    if (!allowKeysExposure) {
+        console.error('Cannot fetch secrets unless allowKeysExposure in config.yaml is set to true');
+        return response.sendStatus(403);
+    }
 
-    app.post('/api/secrets/find', jsonParser, (request, response) => {
-        const allowKeysExposure = getConfigValue('allowKeysExposure', false);
+    const key = request.body.key;
 
-        if (!allowKeysExposure) {
-            console.error('Cannot fetch secrets unless allowKeysExposure in config.yaml is set to true');
-            return response.sendStatus(403);
+    try {
+        const secret = readSecret(key);
+
+        if (!secret) {
+            response.sendStatus(404);
         }
 
-        const key = request.body.key;
-
-        try {
-            const secret = readSecret(key);
-
-            if (!secret) {
-                response.sendStatus(404);
-            }
-
-            return response.send({ value: secret });
-        } catch (error) {
-            console.error(error);
-            return response.sendStatus(500);
-        }
-    });
-}
+        return response.send({ value: secret });
+    } catch (error) {
+        console.error(error);
+        return response.sendStatus(500);
+    }
+});
 
 module.exports = {
     writeSecret,
@@ -222,6 +217,6 @@ module.exports = {
     readSecretState,
     migrateSecrets,
     getAllSecrets,
-    registerEndpoints,
     SECRET_KEYS,
+    router,
 };
diff --git a/src/endpoints/serpapi.js b/src/endpoints/serpapi.js
index 6dd12062f..1a6c9926b 100644
--- a/src/endpoints/serpapi.js
+++ b/src/endpoints/serpapi.js
@@ -1,39 +1,34 @@
 const fetch = require('node-fetch').default;
+const express = require('express');
 const { readSecret, SECRET_KEYS } = require('./secrets');
+const { jsonParser } = require('../express-common');
 
-/**
- * Registers the SerpApi endpoints.
- * @param {import("express").Express} app
- * @param {any} jsonParser
- */
-function registerEndpoints(app, jsonParser) {
-    app.post('/api/serpapi/search', jsonParser, async (request, response) => {
-        try {
-            const key = readSecret(SECRET_KEYS.SERPAPI);
+const router = express.Router();
 
-            if (!key) {
-                console.log('No SerpApi key found');
-                return response.sendStatus(401);
-            }
+router.post('/search', jsonParser, async (request, response) => {
+    try {
+        const key = readSecret(SECRET_KEYS.SERPAPI);
 
-            const { query } = request.body;
-            const result = await fetch(`https://serpapi.com/search.json?q=${encodeURIComponent(query)}&api_key=${key}`);
-
-            if (!result.ok) {
-                const text = await result.text();
-                console.log('SerpApi request failed', result.statusText, text);
-                return response.status(500).send(text);
-            }
-
-            const data = await result.json();
-            return response.json(data);
-        } catch (error) {
-            console.log(error);
-            return response.sendStatus(500);
+        if (!key) {
+            console.log('No SerpApi key found');
+            return response.sendStatus(401);
         }
-    });
-}
 
-module.exports = {
-    registerEndpoints,
-};
+        const { query } = request.body;
+        const result = await fetch(`https://serpapi.com/search.json?q=${encodeURIComponent(query)}&api_key=${key}`);
+
+        if (!result.ok) {
+            const text = await result.text();
+            console.log('SerpApi request failed', result.statusText, text);
+            return response.status(500).send(text);
+        }
+
+        const data = await result.json();
+        return response.json(data);
+    } catch (error) {
+        console.log(error);
+        return response.sendStatus(500);
+    }
+});
+
+module.exports = { router };
diff --git a/src/endpoints/sprites.js b/src/endpoints/sprites.js
index 67b282e9d..d43efefc4 100644
--- a/src/endpoints/sprites.js
+++ b/src/endpoints/sprites.js
@@ -1,11 +1,13 @@
 
 const fs = require('fs');
 const path = require('path');
+const express = require('express');
 const mime = require('mime-types');
 const sanitize = require('sanitize-filename');
 const writeFileAtomicSync = require('write-file-atomic').sync;
 const { DIRECTORIES, UPLOADS_PATH } = require('../constants');
 const { getImageBuffers } = require('../util');
+const { jsonParser, urlencodedParser } = require('../express-common');
 
 /**
  * Gets the path to the sprites folder for the provided character name
@@ -101,167 +103,161 @@ function importRisuSprites(data) {
     }
 }
 
-/**
- * Registers the endpoints for the sprite management.
- * @param {import('express').Express} app Express app
- * @param {any} jsonParser JSON parser middleware
- * @param {any} urlencodedParser URL encoded parser middleware
- */
-function registerEndpoints(app, jsonParser, urlencodedParser) {
-    app.get('/api/sprites/get', jsonParser, function (request, response) {
-        const name = String(request.query.name);
-        const isSubfolder = name.includes('/');
-        const spritesPath = getSpritesPath(name, isSubfolder);
-        let sprites = [];
+const router = express.Router();
 
-        try {
-            if (spritesPath && fs.existsSync(spritesPath) && fs.statSync(spritesPath).isDirectory()) {
-                sprites = fs.readdirSync(spritesPath)
-                    .filter(file => {
-                        const mimeType = mime.lookup(file);
-                        return mimeType && mimeType.startsWith('image/');
-                    })
-                    .map((file) => {
-                        const pathToSprite = path.join(spritesPath, file);
-                        return {
-                            label: path.parse(pathToSprite).name.toLowerCase(),
-                            path: `/characters/${name}/${file}`,
-                        };
-                    });
+router.get('/get', jsonParser, function (request, response) {
+    const name = String(request.query.name);
+    const isSubfolder = name.includes('/');
+    const spritesPath = getSpritesPath(name, isSubfolder);
+    let sprites = [];
+
+    try {
+        if (spritesPath && fs.existsSync(spritesPath) && fs.statSync(spritesPath).isDirectory()) {
+            sprites = fs.readdirSync(spritesPath)
+                .filter(file => {
+                    const mimeType = mime.lookup(file);
+                    return mimeType && mimeType.startsWith('image/');
+                })
+                .map((file) => {
+                    const pathToSprite = path.join(spritesPath, file);
+                    return {
+                        label: path.parse(pathToSprite).name.toLowerCase(),
+                        path: `/characters/${name}/${file}`,
+                    };
+                });
+        }
+    }
+    catch (err) {
+        console.log(err);
+    }
+    return response.send(sprites);
+});
+
+router.post('/delete', jsonParser, async (request, response) => {
+    const label = request.body.label;
+    const name = request.body.name;
+
+    if (!label || !name) {
+        return response.sendStatus(400);
+    }
+
+    try {
+        const spritesPath = path.join(DIRECTORIES.characters, name);
+
+        // No sprites folder exists, or not a directory
+        if (!fs.existsSync(spritesPath) || !fs.statSync(spritesPath).isDirectory()) {
+            return response.sendStatus(404);
+        }
+
+        const files = fs.readdirSync(spritesPath);
+
+        // Remove existing sprite with the same label
+        for (const file of files) {
+            if (path.parse(file).name === label) {
+                fs.rmSync(path.join(spritesPath, file));
             }
         }
-        catch (err) {
-            console.log(err);
-        }
-        return response.send(sprites);
-    });
 
-    app.post('/api/sprites/delete', jsonParser, async (request, response) => {
-        const label = request.body.label;
-        const name = request.body.name;
+        return response.sendStatus(200);
+    } catch (error) {
+        console.error(error);
+        return response.sendStatus(500);
+    }
+});
 
-        if (!label || !name) {
-            return response.sendStatus(400);
+router.post('/upload-zip', urlencodedParser, async (request, response) => {
+    const file = request.file;
+    const name = request.body.name;
+
+    if (!file || !name) {
+        return response.sendStatus(400);
+    }
+
+    try {
+        const spritesPath = path.join(DIRECTORIES.characters, name);
+
+        // Create sprites folder if it doesn't exist
+        if (!fs.existsSync(spritesPath)) {
+            fs.mkdirSync(spritesPath);
         }
 
-        try {
-            const spritesPath = path.join(DIRECTORIES.characters, name);
+        // Path to sprites is not a directory. This should never happen.
+        if (!fs.statSync(spritesPath).isDirectory()) {
+            return response.sendStatus(404);
+        }
 
-            // No sprites folder exists, or not a directory
-            if (!fs.existsSync(spritesPath) || !fs.statSync(spritesPath).isDirectory()) {
-                return response.sendStatus(404);
-            }
-
-            const files = fs.readdirSync(spritesPath);
+        const spritePackPath = path.join(UPLOADS_PATH, file.filename);
+        const sprites = await getImageBuffers(spritePackPath);
+        const files = fs.readdirSync(spritesPath);
 
+        for (const [filename, buffer] of sprites) {
             // Remove existing sprite with the same label
-            for (const file of files) {
-                if (path.parse(file).name === label) {
-                    fs.rmSync(path.join(spritesPath, file));
-                }
+            const existingFile = files.find(file => path.parse(file).name === path.parse(filename).name);
+
+            if (existingFile) {
+                fs.rmSync(path.join(spritesPath, existingFile));
             }
 
-            return response.sendStatus(200);
-        } catch (error) {
-            console.error(error);
-            return response.sendStatus(500);
-        }
-    });
-
-    app.post('/api/sprites/upload-zip', urlencodedParser, async (request, response) => {
-        const file = request.file;
-        const name = request.body.name;
-
-        if (!file || !name) {
-            return response.sendStatus(400);
+            // Write sprite buffer to disk
+            const pathToSprite = path.join(spritesPath, filename);
+            writeFileAtomicSync(pathToSprite, buffer);
         }
 
-        try {
-            const spritesPath = path.join(DIRECTORIES.characters, name);
+        // Remove uploaded ZIP file
+        fs.rmSync(spritePackPath);
+        return response.send({ count: sprites.length });
+    } catch (error) {
+        console.error(error);
+        return response.sendStatus(500);
+    }
+});
 
-            // Create sprites folder if it doesn't exist
-            if (!fs.existsSync(spritesPath)) {
-                fs.mkdirSync(spritesPath);
-            }
+router.post('/upload', urlencodedParser, async (request, response) => {
+    const file = request.file;
+    const label = request.body.label;
+    const name = request.body.name;
 
-            // Path to sprites is not a directory. This should never happen.
-            if (!fs.statSync(spritesPath).isDirectory()) {
-                return response.sendStatus(404);
-            }
+    if (!file || !label || !name) {
+        return response.sendStatus(400);
+    }
 
-            const spritePackPath = path.join(UPLOADS_PATH, file.filename);
-            const sprites = await getImageBuffers(spritePackPath);
-            const files = fs.readdirSync(spritesPath);
+    try {
+        const spritesPath = path.join(DIRECTORIES.characters, name);
 
-            for (const [filename, buffer] of sprites) {
-                // Remove existing sprite with the same label
-                const existingFile = files.find(file => path.parse(file).name === path.parse(filename).name);
-
-                if (existingFile) {
-                    fs.rmSync(path.join(spritesPath, existingFile));
-                }
-
-                // Write sprite buffer to disk
-                const pathToSprite = path.join(spritesPath, filename);
-                writeFileAtomicSync(pathToSprite, buffer);
-            }
-
-            // Remove uploaded ZIP file
-            fs.rmSync(spritePackPath);
-            return response.send({ count: sprites.length });
-        } catch (error) {
-            console.error(error);
-            return response.sendStatus(500);
-        }
-    });
-
-    app.post('/api/sprites/upload', urlencodedParser, async (request, response) => {
-        const file = request.file;
-        const label = request.body.label;
-        const name = request.body.name;
-
-        if (!file || !label || !name) {
-            return response.sendStatus(400);
+        // Create sprites folder if it doesn't exist
+        if (!fs.existsSync(spritesPath)) {
+            fs.mkdirSync(spritesPath);
         }
 
-        try {
-            const spritesPath = path.join(DIRECTORIES.characters, name);
-
-            // Create sprites folder if it doesn't exist
-            if (!fs.existsSync(spritesPath)) {
-                fs.mkdirSync(spritesPath);
-            }
-
-            // Path to sprites is not a directory. This should never happen.
-            if (!fs.statSync(spritesPath).isDirectory()) {
-                return response.sendStatus(404);
-            }
-
-            const files = fs.readdirSync(spritesPath);
-
-            // Remove existing sprite with the same label
-            for (const file of files) {
-                if (path.parse(file).name === label) {
-                    fs.rmSync(path.join(spritesPath, file));
-                }
-            }
-
-            const filename = label + path.parse(file.originalname).ext;
-            const spritePath = path.join(UPLOADS_PATH, file.filename);
-            const pathToFile = path.join(spritesPath, filename);
-            // Copy uploaded file to sprites folder
-            fs.cpSync(spritePath, pathToFile);
-            // Remove uploaded file
-            fs.rmSync(spritePath);
-            return response.sendStatus(200);
-        } catch (error) {
-            console.error(error);
-            return response.sendStatus(500);
+        // Path to sprites is not a directory. This should never happen.
+        if (!fs.statSync(spritesPath).isDirectory()) {
+            return response.sendStatus(404);
         }
-    });
-}
+
+        const files = fs.readdirSync(spritesPath);
+
+        // Remove existing sprite with the same label
+        for (const file of files) {
+            if (path.parse(file).name === label) {
+                fs.rmSync(path.join(spritesPath, file));
+            }
+        }
+
+        const filename = label + path.parse(file.originalname).ext;
+        const spritePath = path.join(UPLOADS_PATH, file.filename);
+        const pathToFile = path.join(spritesPath, filename);
+        // Copy uploaded file to sprites folder
+        fs.cpSync(spritePath, pathToFile);
+        // Remove uploaded file
+        fs.rmSync(spritePath);
+        return response.sendStatus(200);
+    } catch (error) {
+        console.error(error);
+        return response.sendStatus(500);
+    }
+});
 
 module.exports = {
-    registerEndpoints,
+    router,
     importRisuSprites,
 };
diff --git a/src/endpoints/thumbnails.js b/src/endpoints/thumbnails.js
index d150f0cfb..26b585b76 100644
--- a/src/endpoints/thumbnails.js
+++ b/src/endpoints/thumbnails.js
@@ -1,10 +1,12 @@
 const fs = require('fs');
 const path = require('path');
+const express = require('express');
 const sanitize = require('sanitize-filename');
 const jimp = require('jimp');
 const writeFileAtomicSync = require('write-file-atomic').sync;
 const { DIRECTORIES } = require('../constants');
 const { getConfigValue } = require('../util');
+const { jsonParser } = require('../express-common');
 
 /**
  * Gets a path to thumbnail folder based on the type.
@@ -150,53 +152,46 @@ async function ensureThumbnailCache() {
     console.log(`Done! Generated: ${bgFiles.length} preview images`);
 }
 
+const router = express.Router();
 
-/**
- * Registers the endpoints for the thumbnail management.
- * @param {import('express').Express} app Express app
- * @param {any} jsonParser JSON parser middleware
- */
-function registerEndpoints(app, jsonParser) {
-    // Important: Do not change a path to this endpoint. It is used in the client code and saved to chat files.
-    app.get('/thumbnail', jsonParser, async function (request, response) {
-        if (typeof request.query.file !== 'string' || typeof request.query.type !== 'string') return response.sendStatus(400);
+// Important: This route must be mounted as '/thumbnail'. It is used in the client code and saved to chat files.
+router.get('/', jsonParser, async function (request, response) {
+    if (typeof request.query.file !== 'string' || typeof request.query.type !== 'string') return response.sendStatus(400);
 
-        const type = request.query.type;
-        const file = sanitize(request.query.file);
+    const type = request.query.type;
+    const file = sanitize(request.query.file);
 
-        if (!type || !file) {
-            return response.sendStatus(400);
-        }
+    if (!type || !file) {
+        return response.sendStatus(400);
+    }
 
-        if (!(type == 'bg' || type == 'avatar')) {
-            return response.sendStatus(400);
-        }
+    if (!(type == 'bg' || type == 'avatar')) {
+        return response.sendStatus(400);
+    }
 
-        if (sanitize(file) !== file) {
-            console.error('Malicious filename prevented');
-            return response.sendStatus(403);
-        }
+    if (sanitize(file) !== file) {
+        console.error('Malicious filename prevented');
+        return response.sendStatus(403);
+    }
 
-        if (getConfigValue('disableThumbnails', false) == true) {
-            let folder = getOriginalFolder(type);
-            if (folder === undefined) return response.sendStatus(400);
-            const pathToOriginalFile = path.join(folder, file);
-            return response.sendFile(pathToOriginalFile, { root: process.cwd() });
-        }
+    if (getConfigValue('disableThumbnails', false) == true) {
+        let folder = getOriginalFolder(type);
+        if (folder === undefined) return response.sendStatus(400);
+        const pathToOriginalFile = path.join(folder, file);
+        return response.sendFile(pathToOriginalFile, { root: process.cwd() });
+    }
 
-        const pathToCachedFile = await generateThumbnail(type, file);
+    const pathToCachedFile = await generateThumbnail(type, file);
 
-        if (!pathToCachedFile) {
-            return response.sendStatus(404);
-        }
+    if (!pathToCachedFile) {
+        return response.sendStatus(404);
+    }
 
-        return response.sendFile(pathToCachedFile, { root: process.cwd() });
-    });
-
-}
+    return response.sendFile(pathToCachedFile, { root: process.cwd() });
+});
 
 module.exports = {
     invalidateThumbnail,
-    registerEndpoints,
     ensureThumbnailCache,
+    router,
 };
diff --git a/src/endpoints/translate.js b/src/endpoints/translate.js
index a7155c5e4..8d942f28e 100644
--- a/src/endpoints/translate.js
+++ b/src/endpoints/translate.js
@@ -1,25 +1,65 @@
 const fetch = require('node-fetch').default;
 const https = require('https');
+const express = require('express');
 const { readSecret, SECRET_KEYS } = require('./secrets');
 const { getConfigValue } = require('../util');
+const { jsonParser } = require('../express-common');
 
 const DEEPLX_URL_DEFAULT = 'http://127.0.0.1:1188/translate';
 const ONERING_URL_DEFAULT = 'http://127.0.0.1:4990/translate';
 
-/**
- * @param {import("express").Express} app
- * @param {any} jsonParser
- */
-function registerEndpoints(app, jsonParser) {
-    app.post('/api/translate/libre', jsonParser, async (request, response) => {
-        const key = readSecret(SECRET_KEYS.LIBRE);
-        const url = readSecret(SECRET_KEYS.LIBRE_URL);
+const router = express.Router();
 
-        if (!url) {
-            console.log('LibreTranslate URL is not configured.');
-            return response.sendStatus(401);
+router.post('/libre', jsonParser, async (request, response) => {
+    const key = readSecret(SECRET_KEYS.LIBRE);
+    const url = readSecret(SECRET_KEYS.LIBRE_URL);
+
+    if (!url) {
+        console.log('LibreTranslate URL is not configured.');
+        return response.sendStatus(401);
+    }
+
+    const text = request.body.text;
+    const lang = request.body.lang;
+
+    if (!text || !lang) {
+        return response.sendStatus(400);
+    }
+
+    console.log('Input text: ' + text);
+
+    try {
+        const result = await fetch(url, {
+            method: 'POST',
+            body: JSON.stringify({
+                q: text,
+                source: 'auto',
+                target: lang,
+                format: 'text',
+                api_key: key,
+            }),
+            headers: { 'Content-Type': 'application/json' },
+        });
+
+        if (!result.ok) {
+            const error = await result.text();
+            console.log('LibreTranslate error: ', result.statusText, error);
+            return response.sendStatus(result.status);
         }
 
+        const json = await result.json();
+        console.log('Translated text: ' + json.translatedText);
+
+        return response.send(json.translatedText);
+    } catch (error) {
+        console.log('Translation error: ' + error.message);
+        return response.sendStatus(500);
+    }
+});
+
+router.post('/google', jsonParser, async (request, response) => {
+    try {
+        const { generateRequestUrl, normaliseResponse } = require('google-translate-api-browser');
         const text = request.body.text;
         const lang = request.body.lang;
 
@@ -29,264 +69,220 @@ function registerEndpoints(app, jsonParser) {
 
         console.log('Input text: ' + text);
 
-        try {
-            const result = await fetch(url, {
-                method: 'POST',
-                body: JSON.stringify({
-                    q: text,
-                    source: 'auto',
-                    target: lang,
-                    format: 'text',
-                    api_key: key,
-                }),
-                headers: { 'Content-Type': 'application/json' },
+        const url = generateRequestUrl(text, { to: lang });
+
+        https.get(url, (resp) => {
+            let data = '';
+
+            resp.on('data', (chunk) => {
+                data += chunk;
             });
 
-            if (!result.ok) {
-                const error = await result.text();
-                console.log('LibreTranslate error: ', result.statusText, error);
-                return response.sendStatus(result.status);
-            }
-
-            const json = await result.json();
-            console.log('Translated text: ' + json.translatedText);
-
-            return response.send(json.translatedText);
-        } catch (error) {
-            console.log('Translation error: ' + error.message);
-            return response.sendStatus(500);
-        }
-    });
-
-    app.post('/api/translate/google', jsonParser, async (request, response) => {
-        try {
-            const { generateRequestUrl, normaliseResponse } = require('google-translate-api-browser');
-            const text = request.body.text;
-            const lang = request.body.lang;
-
-            if (!text || !lang) {
-                return response.sendStatus(400);
-            }
-
-            console.log('Input text: ' + text);
-
-            const url = generateRequestUrl(text, { to: lang });
-
-            https.get(url, (resp) => {
-                let data = '';
-
-                resp.on('data', (chunk) => {
-                    data += chunk;
-                });
-
-                resp.on('end', () => {
-                    try {
-                        const result = normaliseResponse(JSON.parse(data));
-                        console.log('Translated text: ' + result.text);
-                        return response.send(result.text);
-                    } catch (error) {
-                        console.log('Translation error', error);
-                        return response.sendStatus(500);
-                    }
-                });
-            }).on('error', (err) => {
-                console.log('Translation error: ' + err.message);
-                return response.sendStatus(500);
+            resp.on('end', () => {
+                try {
+                    const result = normaliseResponse(JSON.parse(data));
+                    console.log('Translated text: ' + result.text);
+                    return response.send(result.text);
+                } catch (error) {
+                    console.log('Translation error', error);
+                    return response.sendStatus(500);
+                }
             });
-        } catch (error) {
-            console.log('Translation error', error);
-            return response.sendStatus(500);
-        }
-    });
-
-    app.post('/api/translate/deepl', jsonParser, async (request, response) => {
-        const key = readSecret(SECRET_KEYS.DEEPL);
-
-        if (!key) {
-            return response.sendStatus(401);
-        }
-
-        const text = request.body.text;
-        const lang = request.body.lang;
-        const formality = getConfigValue('deepl.formality', 'default');
-
-        if (!text || !lang) {
-            return response.sendStatus(400);
-        }
-
-        console.log('Input text: ' + text);
-
-        const params = new URLSearchParams();
-        params.append('text', text);
-        params.append('target_lang', lang);
-
-        if (['de', 'fr', 'it', 'es', 'nl', 'ja', 'ru'].includes(lang)) {
-            // We don't specify a Portuguese variant, so ignore formality for it.
-            params.append('formality', formality);
-        }
-
-        try {
-            const result = await fetch('https://api-free.deepl.com/v2/translate', {
-                method: 'POST',
-                body: params,
-                headers: {
-                    'Accept': 'application/json',
-                    'Authorization': `DeepL-Auth-Key ${key}`,
-                    'Content-Type': 'application/x-www-form-urlencoded',
-                },
-                timeout: 0,
-            });
-
-            if (!result.ok) {
-                const error = await result.text();
-                console.log('DeepL error: ', result.statusText, error);
-                return response.sendStatus(result.status);
-            }
-
-            const json = await result.json();
-            console.log('Translated text: ' + json.translations[0].text);
-
-            return response.send(json.translations[0].text);
-        } catch (error) {
-            console.log('Translation error: ' + error.message);
-            return response.sendStatus(500);
-        }
-    });
-
-    app.post('/api/translate/onering', jsonParser, async (request, response) => {
-        const secretUrl = readSecret(SECRET_KEYS.ONERING_URL);
-        const url = secretUrl || ONERING_URL_DEFAULT;
-
-        if (!url) {
-            console.log('OneRing URL is not configured.');
-            return response.sendStatus(401);
-        }
-
-        if (!secretUrl && url === ONERING_URL_DEFAULT) {
-            console.log('OneRing URL is using default value.', ONERING_URL_DEFAULT);
-        }
-
-        const text = request.body.text;
-        const from_lang = request.body.from_lang;
-        const to_lang = request.body.to_lang;
-
-        if (!text || !from_lang || !to_lang) {
-            return response.sendStatus(400);
-        }
-
-        const params = new URLSearchParams();
-        params.append('text', text);
-        params.append('from_lang', from_lang);
-        params.append('to_lang', to_lang);
-
-        console.log('Input text: ' + text);
-
-        try {
-            const fetchUrl = new URL(url);
-            fetchUrl.search = params.toString();
-
-            const result = await fetch(fetchUrl, {
-                method: 'GET',
-                timeout: 0,
-            });
-
-            if (!result.ok) {
-                const error = await result.text();
-                console.log('OneRing error: ', result.statusText, error);
-                return response.sendStatus(result.status);
-            }
-
-            const data = await result.json();
-            console.log('Translated text: ' + data.result);
-
-            return response.send(data.result);
-        } catch (error) {
-            console.log('Translation error: ' + error.message);
-            return response.sendStatus(500);
-        }
-    });
-
-    app.post('/api/translate/deeplx', jsonParser, async (request, response) => {
-        const secretUrl = readSecret(SECRET_KEYS.DEEPLX_URL);
-        const url = secretUrl || DEEPLX_URL_DEFAULT;
-
-        if (!url) {
-            console.log('DeepLX URL is not configured.');
-            return response.sendStatus(401);
-        }
-
-        if (!secretUrl && url === DEEPLX_URL_DEFAULT) {
-            console.log('DeepLX URL is using default value.', DEEPLX_URL_DEFAULT);
-        }
-
-        const text = request.body.text;
-        let lang = request.body.lang;
-        if (request.body.lang === 'zh-CN') {
-            lang = 'ZH';
-        }
-
-        if (!text || !lang) {
-            return response.sendStatus(400);
-        }
-
-        console.log('Input text: ' + text);
-
-        try {
-            const result = await fetch(url, {
-                method: 'POST',
-                body: JSON.stringify({
-                    text: text,
-                    source_lang: 'auto',
-                    target_lang: lang,
-                }),
-                headers: {
-                    'Accept': 'application/json',
-                    'Content-Type': 'application/json',
-                },
-                timeout: 0,
-            });
-
-            if (!result.ok) {
-                const error = await result.text();
-                console.log('DeepLX error: ', result.statusText, error);
-                return response.sendStatus(result.status);
-            }
-
-            const json = await result.json();
-            console.log('Translated text: ' + json.data);
-
-            return response.send(json.data);
-        } catch (error) {
-            console.log('DeepLX translation error: ' + error.message);
-            return response.sendStatus(500);
-        }
-    });
-
-    app.post('/api/translate/bing', jsonParser, async (request, response) => {
-        const bingTranslateApi = require('bing-translate-api');
-        const text = request.body.text;
-        let lang = request.body.lang;
-
-        if (request.body.lang === 'zh-CN') {
-            lang = 'zh-Hans';
-        }
-
-        if (!text || !lang) {
-            return response.sendStatus(400);
-        }
-
-        console.log('Input text: ' + text);
-
-        bingTranslateApi.translate(text, null, lang).then(result => {
-            console.log('Translated text: ' + result.translation);
-            return response.send(result.translation);
-        }).catch(err => {
+        }).on('error', (err) => {
             console.log('Translation error: ' + err.message);
             return response.sendStatus(500);
         });
-    });
-}
+    } catch (error) {
+        console.log('Translation error', error);
+        return response.sendStatus(500);
+    }
+});
 
-module.exports = {
-    registerEndpoints,
-};
+router.post('/deepl', jsonParser, async (request, response) => {
+    const key = readSecret(SECRET_KEYS.DEEPL);
+
+    if (!key) {
+        return response.sendStatus(401);
+    }
+
+    const text = request.body.text;
+    const lang = request.body.lang;
+    const formality = getConfigValue('deepl.formality', 'default');
+
+    if (!text || !lang) {
+        return response.sendStatus(400);
+    }
+
+    console.log('Input text: ' + text);
+
+    const params = new URLSearchParams();
+    params.append('text', text);
+    params.append('target_lang', lang);
+
+    if (['de', 'fr', 'it', 'es', 'nl', 'ja', 'ru'].includes(lang)) {
+        // We don't specify a Portuguese variant, so ignore formality for it.
+        params.append('formality', formality);
+    }
+
+    try {
+        const result = await fetch('https://api-free.deepl.com/v2/translate', {
+            method: 'POST',
+            body: params,
+            headers: {
+                'Accept': 'application/json',
+                'Authorization': `DeepL-Auth-Key ${key}`,
+                'Content-Type': 'application/x-www-form-urlencoded',
+            },
+            timeout: 0,
+        });
+
+        if (!result.ok) {
+            const error = await result.text();
+            console.log('DeepL error: ', result.statusText, error);
+            return response.sendStatus(result.status);
+        }
+
+        const json = await result.json();
+        console.log('Translated text: ' + json.translations[0].text);
+
+        return response.send(json.translations[0].text);
+    } catch (error) {
+        console.log('Translation error: ' + error.message);
+        return response.sendStatus(500);
+    }
+});
+
+router.post('/onering', jsonParser, async (request, response) => {
+    const secretUrl = readSecret(SECRET_KEYS.ONERING_URL);
+    const url = secretUrl || ONERING_URL_DEFAULT;
+
+    if (!url) {
+        console.log('OneRing URL is not configured.');
+        return response.sendStatus(401);
+    }
+
+    if (!secretUrl && url === ONERING_URL_DEFAULT) {
+        console.log('OneRing URL is using default value.', ONERING_URL_DEFAULT);
+    }
+
+    const text = request.body.text;
+    const from_lang = request.body.from_lang;
+    const to_lang = request.body.to_lang;
+
+    if (!text || !from_lang || !to_lang) {
+        return response.sendStatus(400);
+    }
+
+    const params = new URLSearchParams();
+    params.append('text', text);
+    params.append('from_lang', from_lang);
+    params.append('to_lang', to_lang);
+
+    console.log('Input text: ' + text);
+
+    try {
+        const fetchUrl = new URL(url);
+        fetchUrl.search = params.toString();
+
+        const result = await fetch(fetchUrl, {
+            method: 'GET',
+            timeout: 0,
+        });
+
+        if (!result.ok) {
+            const error = await result.text();
+            console.log('OneRing error: ', result.statusText, error);
+            return response.sendStatus(result.status);
+        }
+
+        const data = await result.json();
+        console.log('Translated text: ' + data.result);
+
+        return response.send(data.result);
+    } catch (error) {
+        console.log('Translation error: ' + error.message);
+        return response.sendStatus(500);
+    }
+});
+
+router.post('/deeplx', jsonParser, async (request, response) => {
+    const secretUrl = readSecret(SECRET_KEYS.DEEPLX_URL);
+    const url = secretUrl || DEEPLX_URL_DEFAULT;
+
+    if (!url) {
+        console.log('DeepLX URL is not configured.');
+        return response.sendStatus(401);
+    }
+
+    if (!secretUrl && url === DEEPLX_URL_DEFAULT) {
+        console.log('DeepLX URL is using default value.', DEEPLX_URL_DEFAULT);
+    }
+
+    const text = request.body.text;
+    let lang = request.body.lang;
+    if (request.body.lang === 'zh-CN') {
+        lang = 'ZH';
+    }
+
+    if (!text || !lang) {
+        return response.sendStatus(400);
+    }
+
+    console.log('Input text: ' + text);
+
+    try {
+        const result = await fetch(url, {
+            method: 'POST',
+            body: JSON.stringify({
+                text: text,
+                source_lang: 'auto',
+                target_lang: lang,
+            }),
+            headers: {
+                'Accept': 'application/json',
+                'Content-Type': 'application/json',
+            },
+            timeout: 0,
+        });
+
+        if (!result.ok) {
+            const error = await result.text();
+            console.log('DeepLX error: ', result.statusText, error);
+            return response.sendStatus(result.status);
+        }
+
+        const json = await result.json();
+        console.log('Translated text: ' + json.data);
+
+        return response.send(json.data);
+    } catch (error) {
+        console.log('DeepLX translation error: ' + error.message);
+        return response.sendStatus(500);
+    }
+});
+
+router.post('/bing', jsonParser, async (request, response) => {
+    const bingTranslateApi = require('bing-translate-api');
+    const text = request.body.text;
+    let lang = request.body.lang;
+
+    if (request.body.lang === 'zh-CN') {
+        lang = 'zh-Hans';
+    }
+
+    if (!text || !lang) {
+        return response.sendStatus(400);
+    }
+
+    console.log('Input text: ' + text);
+
+    bingTranslateApi.translate(text, null, lang).then(result => {
+        console.log('Translated text: ' + result.translation);
+        return response.send(result.translation);
+    }).catch(err => {
+        console.log('Translation error: ' + err.message);
+        return response.sendStatus(500);
+    });
+});
+
+module.exports = { router };