Blame Raw
Cohee · e3f41666 · · 289 lines (9.6 KB)
1 contributor
1import fs from 'node:fs';
2import path from 'node:path';
3
4import express from 'express';
5import mime from 'mime-types';
6import sanitize from 'sanitize-filename';
7import { sync as writeFileAtomicSync } from 'write-file-atomic';
8
9import { getImageBuffers } from '../util.js';
10
11/**
12 * Gets the path to the sprites folder for the provided character name
13 * @param {import('../users.js').UserDirectoryList} directories - User directories
14 * @param {string} name - The name of the character
15 * @param {boolean} isSubfolder - Whether the name contains a subfolder
16 * @returns {string | null} The path to the sprites folder. Null if the name is invalid.
17 */
18function getSpritesPath(directories, name, isSubfolder) {
19 if (isSubfolder) {
20 const nameParts = name.split('/');
21 const characterName = sanitize(nameParts[0]);
22 const subfolderName = sanitize(nameParts[1]);
23
24 if (!characterName || !subfolderName) {
25 return null;
26 }
27
28 return path.join(directories.characters, characterName, subfolderName);
29 }
30
31 name = sanitize(name);
32
33 if (!name) {
34 return null;
35 }
36
37 return path.join(directories.characters, name);
38}
39
40/**
41 * Imports base64 encoded sprites from RisuAI character data.
42 * The sprites are saved in the character's sprites folder.
43 * The additionalAssets and emotions are removed from the data.
44 * @param {import('../users.js').UserDirectoryList} directories User directories
45 * @param {object} data RisuAI character data
46 * @returns {void}
47 */
48export function importRisuSprites(directories, data) {
49 try {
50 const name = data?.data?.name;
51 const risuData = data?.data?.extensions?.risuai;
52
53 // Not a Risu AI character
54 if (!risuData || !name) {
55 return;
56 }
57
58 let images = [];
59
60 if (Array.isArray(risuData.additionalAssets)) {
61 images = images.concat(risuData.additionalAssets);
62 }
63
64 if (Array.isArray(risuData.emotions)) {
65 images = images.concat(risuData.emotions);
66 }
67
68 // No sprites to import
69 if (images.length === 0) {
70 return;
71 }
72
73 // Create sprites folder if it doesn't exist
74 const spritesPath = getSpritesPath(directories, name, false);
75
76 // Invalid sprites path
77 if (!spritesPath) {
78 return;
79 }
80
81 // Create sprites folder if it doesn't exist
82 if (!fs.existsSync(spritesPath)) {
83 fs.mkdirSync(spritesPath, { recursive: true });
84 }
85
86 // Path to sprites is not a directory. This should never happen.
87 if (!fs.statSync(spritesPath).isDirectory()) {
88 return;
89 }
90
91 console.info(`RisuAI: Found ${images.length} sprites for ${name}. Writing to disk.`);
92 const files = fs.readdirSync(spritesPath);
93
94 outer: for (const [label, fileBase64] of images) {
95 // Remove existing sprite with the same label
96 for (const file of files) {
97 if (path.parse(file).name === label) {
98 console.warn(`RisuAI: The sprite ${label} for ${name} already exists. Skipping.`);
99 continue outer;
100 }
101 }
102
103 const filename = label + '.png';
104 const pathToFile = path.join(spritesPath, sanitize(filename));
105 writeFileAtomicSync(pathToFile, fileBase64, { encoding: 'base64' });
106 }
107
108 // Remove additionalAssets and emotions from data (they are now in the sprites folder)
109 delete data.data.extensions.risuai.additionalAssets;
110 delete data.data.extensions.risuai.emotions;
111 } catch (error) {
112 console.error(error);
113 }
114}
115
116export const router = express.Router();
117
118router.get('/get', function (request, response) {
119 const name = String(request.query.name);
120 const isSubfolder = name.includes('/');
121 const spritesPath = getSpritesPath(request.user.directories, name, isSubfolder);
122 let sprites = [];
123
124 try {
125 if (spritesPath && fs.existsSync(spritesPath) && fs.statSync(spritesPath).isDirectory()) {
126 sprites = fs.readdirSync(spritesPath)
127 .filter(file => {
128 const mimeType = mime.lookup(file);
129 return mimeType && mimeType.startsWith('image/');
130 })
131 .map((file) => {
132 const pathToSprite = path.join(spritesPath, file);
133 const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14);
134
135 const fileName = path.parse(pathToSprite).name.toLowerCase();
136 // Extract the label from the filename via regex, which can be suffixed with a sub-name, either connected with a dash or a dot.
137 // Examples: joy.png, joy-1.png, joy.expressive.png
138 const label = fileName.match(/^(.+?)(?:[-\\.].*?)?$/)?.[1] ?? fileName;
139
140 return {
141 label: label,
142 path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),
143 };
144 });
145 }
146 } catch (err) {
147 console.error(err);
148 }
149 return response.send(sprites);
150});
151
152router.post('/delete', async (request, response) => {
153 const label = request.body.label;
154 const name = String(request.body.name);
155 const isSubfolder = name.includes('/');
156 const spriteName = request.body.spriteName || label;
157
158 if (!spriteName || !name) {
159 return response.sendStatus(400);
160 }
161
162 try {
163 const spritesPath = getSpritesPath(request.user.directories, name, isSubfolder);
164
165 // No sprites folder exists, or not a directory
166 if (!spritesPath || !fs.existsSync(spritesPath) || !fs.statSync(spritesPath).isDirectory()) {
167 return response.sendStatus(404);
168 }
169
170 const files = fs.readdirSync(spritesPath);
171
172 // Remove existing sprite with the same label
173 for (const file of files) {
174 if (path.parse(file).name === spriteName) {
175 fs.unlinkSync(path.join(spritesPath, file));
176 }
177 }
178
179 return response.sendStatus(200);
180 } catch (error) {
181 console.error(error);
182 return response.sendStatus(500);
183 }
184});
185
186router.post('/upload-zip', async (request, response) => {
187 const file = request.file;
188 const name = String(request.body.name);
189 const isSubfolder = name.includes('/');
190
191 if (!file || !name) {
192 return response.sendStatus(400);
193 }
194
195 try {
196 const spritesPath = getSpritesPath(request.user.directories, name, isSubfolder);
197
198 // Invalid sprites path
199 if (!spritesPath) {
200 return response.sendStatus(400);
201 }
202
203 // Create sprites folder if it doesn't exist
204 if (!fs.existsSync(spritesPath)) {
205 fs.mkdirSync(spritesPath, { recursive: true });
206 }
207
208 // Path to sprites is not a directory. This should never happen.
209 if (!fs.statSync(spritesPath).isDirectory()) {
210 return response.sendStatus(404);
211 }
212
213 const spritePackPath = path.join(file.destination, file.filename);
214 const sprites = await getImageBuffers(spritePackPath);
215 const files = fs.readdirSync(spritesPath);
216
217 for (const [filename, buffer] of sprites) {
218 // Remove existing sprite with the same label
219 const existingFile = files.find(file => path.parse(file).name === path.parse(filename).name);
220
221 if (existingFile) {
222 fs.unlinkSync(path.join(spritesPath, existingFile));
223 }
224
225 // Write sprite buffer to disk
226 const pathToSprite = path.join(spritesPath, sanitize(filename));
227 writeFileAtomicSync(pathToSprite, buffer);
228 }
229
230 // Remove uploaded ZIP file
231 fs.unlinkSync(spritePackPath);
232 return response.send({ ok: true, count: sprites.length });
233 } catch (error) {
234 console.error(error);
235 return response.sendStatus(500);
236 }
237});
238
239router.post('/upload', async (request, response) => {
240 const file = request.file;
241 const label = request.body.label;
242 const name = String(request.body.name);
243 const isSubfolder = name.includes('/');
244 const spriteName = request.body.spriteName || label;
245
246 if (!file || !label || !name) {
247 return response.sendStatus(400);
248 }
249
250 try {
251 const spritesPath = getSpritesPath(request.user.directories, name, isSubfolder);
252
253 // Invalid sprites path
254 if (!spritesPath) {
255 return response.sendStatus(400);
256 }
257
258 // Create sprites folder if it doesn't exist
259 if (!fs.existsSync(spritesPath)) {
260 fs.mkdirSync(spritesPath, { recursive: true });
261 }
262
263 // Path to sprites is not a directory. This should never happen.
264 if (!fs.statSync(spritesPath).isDirectory()) {
265 return response.sendStatus(404);
266 }
267
268 const files = fs.readdirSync(spritesPath);
269
270 // Remove existing sprite with the same label
271 for (const file of files) {
272 if (path.parse(file).name === spriteName) {
273 fs.unlinkSync(path.join(spritesPath, file));
274 }
275 }
276
277 const filename = spriteName + path.parse(file.originalname).ext;
278 const spritePath = path.join(file.destination, file.filename);
279 const pathToFile = path.join(spritesPath, sanitize(filename));
280 // Copy uploaded file to sprites folder
281 fs.cpSync(spritePath, pathToFile);
282 // Remove uploaded file
283 fs.unlinkSync(spritePath);
284 return response.send({ ok: true });
285 } catch (error) {
286 console.error(error);
287 return response.sendStatus(500);
288 }
289});