Blame Raw
Cohee · e3f41666 · · 369 lines (13.3 KB)
2 contributors
1import path from 'node:path';
2import fs from 'node:fs';
3import { finished } from 'node:stream/promises';
4
5import mime from 'mime-types';
6import express from 'express';
7import sanitize from 'sanitize-filename';
8import fetch from 'node-fetch';
9
10import { UNSAFE_EXTENSIONS } from '../constants.js';
11import { clientRelativePath, isValidUrl } from '../util.js';
12import { getHostFromUrl, isHostWhitelisted } from './content-manager.js';
13
14const VALID_CATEGORIES = ['bgm', 'ambient', 'blip', 'live2d', 'vrm', 'character', 'temp'];
15
16/**
17 * Validates the input filename for the asset.
18 * @param {string} inputFilename Input filename
19 * @returns {{error: boolean, message?: string}} Whether validation failed, and why if so
20 */
21export function validateAssetFileName(inputFilename) {
22 if (!/^[a-zA-Z0-9_\-.]+$/.test(inputFilename)) {
23 return {
24 error: true,
25 message: 'Illegal character in filename; only alphanumeric, \'_\', \'-\' are accepted.',
26 };
27 }
28
29 const inputExtension = path.extname(inputFilename).toLowerCase();
30 if (UNSAFE_EXTENSIONS.some(ext => ext === inputExtension)) {
31 return {
32 error: true,
33 message: 'Forbidden file extension.',
34 };
35 }
36
37 if (inputFilename.startsWith('.')) {
38 return {
39 error: true,
40 message: 'Filename cannot start with \'.\'',
41 };
42 }
43
44 if (sanitize(inputFilename) !== inputFilename) {
45 return {
46 error: true,
47 message: 'Reserved or long filename.',
48 };
49 }
50
51 return { error: false };
52}
53
54/**
55 * Recursive function to get files
56 * @param {string} dir - The directory to search for files
57 * @param {string[]} files - The array of files to return
58 * @returns {string[]} - The array of files
59 */
60function getFiles(dir, files = []) {
61 if (!fs.existsSync(dir)) return files;
62
63 // Get an array of all files and directories in the passed directory using fs.readdirSync
64 const fileList = fs.readdirSync(dir, { withFileTypes: true });
65 // Create the full path of the file/directory by concatenating the passed directory and file/directory name
66 for (const file of fileList) {
67 const name = path.join(dir, file.name);
68 // Check if the current file/directory is a directory using fs.statSync
69 if (file.isDirectory()) {
70 // If it is a directory, recursively call the getFiles function with the directory path and the files array
71 getFiles(name, files);
72 } else {
73 // If it is a file, push the full path to the files array
74 files.push(name);
75 }
76 }
77 return files;
78}
79
80/**
81 * Ensure that the asset folders exist.
82 * @param {import('../users.js').UserDirectoryList} directories - The user's directories
83 */
84function ensureFoldersExist(directories) {
85 const folderPath = path.join(directories.assets);
86
87 for (const category of VALID_CATEGORIES) {
88 const assetCategoryPath = path.join(folderPath, category);
89 if (fs.existsSync(assetCategoryPath) && !fs.statSync(assetCategoryPath).isDirectory()) {
90 fs.unlinkSync(assetCategoryPath);
91 }
92 if (!fs.existsSync(assetCategoryPath)) {
93 fs.mkdirSync(assetCategoryPath, { recursive: true });
94 }
95 }
96}
97
98export const router = express.Router();
99
100/**
101 * HTTP POST handler function to retrieve name of all files of a given folder path.
102 *
103 * @param {Object} request - HTTP Request object. Require folder path in query
104 * @param {Object} response - HTTP Response object will contain a list of file path.
105 *
106 * @returns {void}
107 */
108router.post('/get', async (request, response) => {
109 const folderPath = path.join(request.user.directories.assets);
110 let output = {};
111
112 try {
113 if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) {
114 ensureFoldersExist(request.user.directories);
115
116 const folders = fs.readdirSync(folderPath, { withFileTypes: true })
117 .filter(file => file.isDirectory());
118
119 for (const { name: folder } of folders) {
120 if (folder == 'temp')
121 continue;
122
123 // Live2d assets
124 if (folder == 'live2d') {
125 output[folder] = [];
126 const live2d_folder = path.normalize(path.join(folderPath, folder));
127 const files = getFiles(live2d_folder);
128 //console.debug("FILE FOUND:",files)
129 for (let file of files) {
130 if (file.includes('model') && file.endsWith('.json')) {
131 //console.debug("Asset live2d model found:",file)
132 output[folder].push(clientRelativePath(request.user.directories.root, file));
133 }
134 }
135 continue;
136 }
137
138 // VRM assets
139 if (folder == 'vrm') {
140 output[folder] = { 'model': [], 'animation': [] };
141 // Extract models
142 const vrm_model_folder = path.normalize(path.join(folderPath, 'vrm', 'model'));
143 let files = getFiles(vrm_model_folder);
144 //console.debug("FILE FOUND:",files)
145 for (let file of files) {
146 if (!file.endsWith('.placeholder')) {
147 //console.debug("Asset VRM model found:",file)
148 output.vrm.model.push(clientRelativePath(request.user.directories.root, file));
149 }
150 }
151
152 // Extract models
153 const vrm_animation_folder = path.normalize(path.join(folderPath, 'vrm', 'animation'));
154 files = getFiles(vrm_animation_folder);
155 //console.debug("FILE FOUND:",files)
156 for (let file of files) {
157 if (!file.endsWith('.placeholder')) {
158 //console.debug("Asset VRM animation found:",file)
159 output.vrm.animation.push(clientRelativePath(request.user.directories.root, file));
160 }
161 }
162 continue;
163 }
164
165 // Other assets (bgm/ambient/blip)
166 const files = fs.readdirSync(path.join(folderPath, folder))
167 .filter(filename => {
168 return filename != '.placeholder';
169 });
170 output[folder] = [];
171 for (const file of files) {
172 output[folder].push(`assets/${folder}/${file}`);
173 }
174 }
175 }
176 } catch (err) {
177 console.error(err);
178 }
179 return response.send(output);
180});
181
182/**
183 * HTTP POST handler function to download the requested asset.
184 *
185 * @param {Object} request - HTTP Request object, expects a url, a category and a filename.
186 * @param {Object} response - HTTP Response only gives status.
187 *
188 * @returns {void}
189 */
190router.post('/download', async (request, response) => {
191 try {
192 if (!isValidUrl(request.body.url)) {
193 console.warn('Asset download failed: Must be a valid URL');
194 return response.sendStatus(400);
195 }
196
197 const url = String(request.body.url);
198 const inputCategory = request.body.category;
199
200 const host = getHostFromUrl(url);
201 if (!isHostWhitelisted(host)) {
202 console.error(`Received an import for "${host}", but site is not whitelisted. This domain must be added to the config key "whitelistImportDomains" to allow import from this source.`);
203 return response.sendStatus(404);
204 }
205
206 // Check category
207 let category = null;
208 for (let i of VALID_CATEGORIES)
209 if (i == inputCategory)
210 category = i;
211
212 if (category === null) {
213 console.error('Bad request: unsupported asset category.');
214 return response.sendStatus(400);
215 }
216
217 // Validate filename
218 ensureFoldersExist(request.user.directories);
219 const validation = validateAssetFileName(request.body.filename);
220 if (validation.error)
221 return response.status(400).send(validation.message);
222
223 const temp_path = path.join(request.user.directories.assets, 'temp', request.body.filename);
224 const file_path = path.join(request.user.directories.assets, category, request.body.filename);
225 console.info('Request received to download', url, 'to', file_path);
226
227 // Download to temp
228 const res = await fetch(url);
229 if (!res.ok || res.body === null) {
230 throw new Error(`Unexpected response ${res.statusText}`);
231 }
232 const destination = path.resolve(temp_path);
233 // Delete if previous download failed
234 if (fs.existsSync(temp_path)) {
235 await fs.promises.unlink(temp_path);
236 }
237 const fileStream = fs.createWriteStream(destination, { flags: 'wx' });
238 // @ts-ignore
239 await finished(res.body.pipe(fileStream));
240
241 if (category === 'character') {
242 const fileContent = fs.readFileSync(temp_path);
243 const contentType = mime.lookup(temp_path) || 'application/octet-stream';
244 response.setHeader('Content-Type', contentType);
245 response.send(fileContent);
246 fs.unlinkSync(temp_path);
247 return;
248 }
249
250 // Move into asset place
251 console.info('Download finished, moving file from', temp_path, 'to', file_path);
252 fs.copyFileSync(temp_path, file_path);
253 fs.unlinkSync(temp_path);
254 response.sendStatus(200);
255 } catch (error) {
256 console.error(error);
257 response.sendStatus(500);
258 }
259});
260
261/**
262 * HTTP POST handler function to delete the requested asset.
263 *
264 * @param {Object} request - HTTP Request object, expects a category and a filename
265 * @param {Object} response - HTTP Response only gives stats.
266 *
267 * @returns {void}
268 */
269router.post('/delete', async (request, response) => {
270 const inputCategory = request.body.category;
271
272 // Check category
273 let category = null;
274 for (let i of VALID_CATEGORIES)
275 if (i == inputCategory)
276 category = i;
277
278 if (category === null) {
279 console.error('Bad request: unsupported asset category.');
280 return response.sendStatus(400);
281 }
282
283 // Validate filename
284 const validation = validateAssetFileName(request.body.filename);
285 if (validation.error)
286 return response.status(400).send(validation.message);
287
288 const file_path = path.join(request.user.directories.assets, category, request.body.filename);
289 console.info('Request received to delete', category, file_path);
290
291 try {
292 if (!fs.existsSync(file_path)) {
293 console.error('Asset not found.');
294 return response.sendStatus(400);
295 }
296
297 await fs.promises.unlink(file_path);
298 console.info('Asset deleted.');
299 return response.sendStatus(200);
300 } catch (error) {
301 console.error(error);
302 return response.sendStatus(500);
303 }
304});
305
306///////////////////////////////
307/**
308 * HTTP POST handler function to retrieve a character background music list.
309 *
310 * @param {Object} request - HTTP Request object, expects a character name in the query.
311 * @param {Object} response - HTTP Response object will contain a list of audio file path.
312 *
313 * @returns {void}
314 */
315router.post('/character', async (request, response) => {
316 if (request.query.name === undefined) return response.sendStatus(400);
317
318 // For backwards compatibility, don't reject invalid character names, just sanitize them
319 const name = sanitize(request.query.name.toString());
320 const inputCategory = request.query.category;
321
322 // Check category
323 let category = null;
324 for (let i of VALID_CATEGORIES)
325 if (i == inputCategory)
326 category = i;
327
328 if (category === null) {
329 console.error('Bad request: unsupported asset category.');
330 return response.sendStatus(400);
331 }
332
333 const folderPath = path.join(request.user.directories.characters, name, category);
334
335 let output = [];
336 try {
337 if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) {
338 // Live2d assets
339 if (category == 'live2d') {
340 const folders = fs.readdirSync(folderPath, { withFileTypes: true });
341 for (const folderInfo of folders) {
342 if (!folderInfo.isDirectory()) continue;
343
344 const modelFolder = folderInfo.name;
345 const live2dModelPath = path.join(folderPath, modelFolder);
346 for (let file of fs.readdirSync(live2dModelPath)) {
347 //console.debug("Character live2d model found:", file)
348 if (file.includes('model') && file.endsWith('.json'))
349 output.push(path.join('characters', name, category, modelFolder, file));
350 }
351 }
352 return response.send(output);
353 }
354
355 // Other assets
356 const files = fs.readdirSync(folderPath)
357 .filter(filename => {
358 return filename != '.placeholder';
359 });
360
361 for (let i of files)
362 output.push(`/characters/${name}/${category}/${i}`);
363 }
364 return response.send(output);
365 } catch (err) {
366 console.error(err);
367 return response.sendStatus(500);
368 }
369});