Spaces:
Runtime error
Runtime error
const path = require('path'); | |
const fs = require('fs'); | |
const mime = require('mime-types'); | |
const express = require('express'); | |
const sanitize = require('sanitize-filename'); | |
const fetch = require('node-fetch').default; | |
const { finished } = require('stream/promises'); | |
const { UNSAFE_EXTENSIONS } = require('../constants'); | |
const { jsonParser } = require('../express-common'); | |
const { clientRelativePath } = require('../util'); | |
const VALID_CATEGORIES = ['bgm', 'ambient', 'blip', 'live2d', 'vrm', 'character', 'temp']; | |
/** | |
* Validates the input filename for the asset. | |
* @param {string} inputFilename Input filename | |
* @returns {{error: boolean, message?: string}} Whether validation failed, and why if so | |
*/ | |
function validateAssetFileName(inputFilename) { | |
if (!/^[a-zA-Z0-9_\-.]+$/.test(inputFilename)) { | |
return { | |
error: true, | |
message: 'Illegal character in filename; only alphanumeric, \'_\', \'-\' are accepted.', | |
}; | |
} | |
const inputExtension = path.extname(inputFilename).toLowerCase(); | |
if (UNSAFE_EXTENSIONS.some(ext => ext === inputExtension)) { | |
return { | |
error: true, | |
message: 'Forbidden file extension.', | |
}; | |
} | |
if (inputFilename.startsWith('.')) { | |
return { | |
error: true, | |
message: 'Filename cannot start with \'.\'', | |
}; | |
} | |
if (sanitize(inputFilename) !== inputFilename) { | |
return { | |
error: true, | |
message: 'Reserved or long filename.', | |
}; | |
} | |
return { error: false }; | |
} | |
/** | |
* Recursive function to get files | |
* @param {string} dir - The directory to search for files | |
* @param {string[]} files - The array of files to return | |
* @returns {string[]} - The array of files | |
*/ | |
function getFiles(dir, files = []) { | |
if (!fs.existsSync(dir)) return files; | |
// Get an array of all files and directories in the passed directory using fs.readdirSync | |
const fileList = fs.readdirSync(dir, { withFileTypes: true }); | |
// Create the full path of the file/directory by concatenating the passed directory and file/directory name | |
for (const file of fileList) { | |
const name = path.join(dir, file.name); | |
// Check if the current file/directory is a directory using fs.statSync | |
if (file.isDirectory()) { | |
// If it is a directory, recursively call the getFiles function with the directory path and the files array | |
getFiles(name, files); | |
} else { | |
// If it is a file, push the full path to the files array | |
files.push(name); | |
} | |
} | |
return files; | |
} | |
/** | |
* Ensure that the asset folders exist. | |
* @param {import('../users').UserDirectoryList} directories - The user's directories | |
*/ | |
function ensureFoldersExist(directories) { | |
const folderPath = path.join(directories.assets); | |
for (const category of VALID_CATEGORIES) { | |
const assetCategoryPath = path.join(folderPath, category); | |
if (fs.existsSync(assetCategoryPath) && !fs.statSync(assetCategoryPath).isDirectory()) { | |
fs.unlinkSync(assetCategoryPath); | |
} | |
if (!fs.existsSync(assetCategoryPath)) { | |
fs.mkdirSync(assetCategoryPath, { recursive: true }); | |
} | |
} | |
} | |
const router = express.Router(); | |
/** | |
* 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} | |
*/ | |
router.post('/get', jsonParser, async (request, response) => { | |
const folderPath = path.join(request.user.directories.assets); | |
let output = {}; | |
try { | |
if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) { | |
ensureFoldersExist(request.user.directories); | |
const folders = fs.readdirSync(folderPath, { withFileTypes: true }) | |
.filter(file => file.isDirectory()); | |
for (const { name: 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) { | |
if (file.includes('model') && file.endsWith('.json')) { | |
//console.debug("Asset live2d model found:",file) | |
output[folder].push(clientRelativePath(request.user.directories.root, file)); | |
} | |
} | |
continue; | |
} | |
// VRM assets | |
if (folder == 'vrm') { | |
output[folder] = { 'model': [], 'animation': [] }; | |
// Extract models | |
const vrm_model_folder = path.normalize(path.join(folderPath, 'vrm', 'model')); | |
let files = getFiles(vrm_model_folder); | |
//console.debug("FILE FOUND:",files) | |
for (let file of files) { | |
if (!file.endsWith('.placeholder')) { | |
//console.debug("Asset VRM model found:",file) | |
output['vrm']['model'].push(clientRelativePath(request.user.directories.root, file)); | |
} | |
} | |
// Extract models | |
const vrm_animation_folder = path.normalize(path.join(folderPath, 'vrm', 'animation')); | |
files = getFiles(vrm_animation_folder); | |
//console.debug("FILE FOUND:",files) | |
for (let file of files) { | |
if (!file.endsWith('.placeholder')) { | |
//console.debug("Asset VRM animation found:",file) | |
output['vrm']['animation'].push(clientRelativePath(request.user.directories.root, 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(`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} | |
*/ | |
router.post('/download', jsonParser, async (request, response) => { | |
const url = request.body.url; | |
const inputCategory = request.body.category; | |
// Check category | |
let category = null; | |
for (let i of VALID_CATEGORIES) | |
if (i == inputCategory) | |
category = i; | |
if (category === null) { | |
console.debug('Bad request: unsupported asset category.'); | |
return response.sendStatus(400); | |
} | |
// Validate filename | |
ensureFoldersExist(request.user.directories); | |
const validation = validateAssetFileName(request.body.filename); | |
if (validation.error) | |
return response.status(400).send(validation.message); | |
const temp_path = path.join(request.user.directories.assets, 'temp', request.body.filename); | |
const file_path = path.join(request.user.directories.assets, category, request.body.filename); | |
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; | |
}); | |
} | |
const fileStream = fs.createWriteStream(destination, { flags: 'wx' }); | |
// @ts-ignore | |
await finished(res.body.pipe(fileStream)); | |
if (category === 'character') { | |
const fileContent = fs.readFileSync(temp_path); | |
const contentType = mime.lookup(temp_path) || 'application/octet-stream'; | |
response.setHeader('Content-Type', contentType); | |
response.send(fileContent); | |
fs.rmSync(temp_path); | |
return; | |
} | |
// Move into asset place | |
console.debug('Download finished, moving file from', temp_path, 'to', file_path); | |
fs.copyFileSync(temp_path, file_path); | |
fs.rmSync(temp_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; | |
// Check category | |
let category = null; | |
for (let i of VALID_CATEGORIES) | |
if (i == inputCategory) | |
category = i; | |
if (category === null) { | |
console.debug('Bad request: unsupported asset category.'); | |
return response.sendStatus(400); | |
} | |
// Validate filename | |
const validation = validateAssetFileName(request.body.filename); | |
if (validation.error) | |
return response.status(400).send(validation.message); | |
const file_path = path.join(request.user.directories.assets, category, request.body.filename); | |
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); | |
// For backwards compatibility, don't reject invalid character names, just sanitize them | |
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: unsupported asset category.'); | |
return response.sendStatus(400); | |
} | |
const folderPath = path.join(request.user.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, { withFileTypes: true }); | |
for (const folderInfo of folders) { | |
if (!folderInfo.isDirectory()) continue; | |
const modelFolder = folderInfo.name; | |
const live2dModelPath = path.join(folderPath, modelFolder); | |
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); | |
} | |
}); | |
module.exports = { router, validateAssetFileName }; | |