Spaces:
Running
Running
File size: 24,793 Bytes
1307964 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 |
// Native Node Modules
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const os = require('os');
// Express and other dependencies
const storage = require('node-persist');
const express = require('express');
const mime = require('mime-types');
const archiver = require('archiver');
const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, DEFAULT_AVATAR, SETTINGS_FILE } = require('./constants');
const { getConfigValue, color, delay, setConfigValue, generateTimestamp } = require('./util');
const { readSecret, writeSecret } = require('./endpoints/secrets');
const KEY_PREFIX = 'user:';
const AVATAR_PREFIX = 'avatar:';
const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);
const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64');
/**
* Cache for user directories.
* @type {Map<string, UserDirectoryList>}
*/
const DIRECTORIES_CACHE = new Map();
const STORAGE_KEYS = {
csrfSecret: 'csrfSecret',
cookieSecret: 'cookieSecret',
};
/**
* @typedef {Object} User
* @property {string} handle - The user's short handle. Used for directories and other references
* @property {string} name - The user's name. Displayed in the UI
* @property {number} created - The timestamp when the user was created
* @property {string} password - Scrypt hash of the user's password
* @property {string} salt - Salt used for hashing the password
* @property {boolean} enabled - Whether the user is enabled
* @property {boolean} admin - Whether the user is an admin (can manage other users)
*/
/**
* @typedef {Object} UserViewModel
* @property {string} handle - The user's short handle. Used for directories and other references
* @property {string} name - The user's name. Displayed in the UI
* @property {string} avatar - The user's avatar image
* @property {boolean} [admin] - Whether the user is an admin (can manage other users)
* @property {boolean} password - Whether the user is password protected
* @property {boolean} [enabled] - Whether the user is enabled
* @property {number} [created] - The timestamp when the user was created
*/
/**
* @typedef {Object} UserDirectoryList
* @property {string} root - The root directory for the user
* @property {string} thumbnails - The directory where the thumbnails are stored
* @property {string} thumbnailsBg - The directory where the background thumbnails are stored
* @property {string} thumbnailsAvatar - The directory where the avatar thumbnails are stored
* @property {string} worlds - The directory where the WI are stored
* @property {string} user - The directory where the user's public data is stored
* @property {string} avatars - The directory where the avatars are stored
* @property {string} userImages - The directory where the images are stored
* @property {string} groups - The directory where the groups are stored
* @property {string} groupChats - The directory where the group chats are stored
* @property {string} chats - The directory where the chats are stored
* @property {string} characters - The directory where the characters are stored
* @property {string} backgrounds - The directory where the backgrounds are stored
* @property {string} novelAI_Settings - The directory where the NovelAI settings are stored
* @property {string} koboldAI_Settings - The directory where the KoboldAI settings are stored
* @property {string} openAI_Settings - The directory where the OpenAI settings are stored
* @property {string} textGen_Settings - The directory where the TextGen settings are stored
* @property {string} themes - The directory where the themes are stored
* @property {string} movingUI - The directory where the moving UI data is stored
* @property {string} extensions - The directory where the extensions are stored
* @property {string} instruct - The directory where the instruct templates is stored
* @property {string} context - The directory where the context templates is stored
* @property {string} quickreplies - The directory where the quick replies are stored
* @property {string} assets - The directory where the assets are stored
* @property {string} comfyWorkflows - The directory where the ComfyUI workflows are stored
* @property {string} files - The directory where the uploaded files are stored
* @property {string} vectors - The directory where the vectors are stored
* @property {string} backups - The directory where the backups are stored
*/
/**
* Ensures that the content directories exist.
* @returns {Promise<import('./users').UserDirectoryList[]>} - The list of user directories
*/
async function ensurePublicDirectoriesExist() {
for (const dir of Object.values(PUBLIC_DIRECTORIES)) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
const userHandles = await getAllUserHandles();
const directoriesList = userHandles.map(handle => getUserDirectories(handle));
for (const userDirectories of directoriesList) {
for (const dir of Object.values(userDirectories)) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
}
return directoriesList;
}
/**
* Gets a list of all user directories.
* @returns {Promise<import('./users').UserDirectoryList[]>} - The list of user directories
*/
async function getUserDirectoriesList() {
const userHandles = await getAllUserHandles();
const directoriesList = userHandles.map(handle => getUserDirectories(handle));
return directoriesList;
}
/**
* Perform migration from the old user data format to the new one.
*/
async function migrateUserData() {
const publicDirectory = path.join(process.cwd(), 'public');
// No need to migrate if the characters directory doesn't exists
if (!fs.existsSync(path.join(publicDirectory, 'characters'))) {
return;
}
const TIMEOUT = 10;
console.log();
console.log(color.magenta('Preparing to migrate user data...'));
console.log(`All public data will be moved to the ${global.DATA_ROOT} directory.`);
console.log('This process may take a while depending on the amount of data to move.');
console.log(`Backups will be placed in the ${PUBLIC_DIRECTORIES.backups} directory.`);
console.log(`The process will start in ${TIMEOUT} seconds. Press Ctrl+C to cancel.`);
for (let i = TIMEOUT; i > 0; i--) {
console.log(`${i}...`);
await delay(1000);
}
console.log(color.magenta('Starting migration... Do not interrupt the process!'));
const userDirectories = getUserDirectories(DEFAULT_USER.handle);
const dataMigrationMap = [
{
old: path.join(publicDirectory, 'assets'),
new: userDirectories.assets,
file: false,
},
{
old: path.join(publicDirectory, 'backgrounds'),
new: userDirectories.backgrounds,
file: false,
},
{
old: path.join(publicDirectory, 'characters'),
new: userDirectories.characters,
file: false,
},
{
old: path.join(publicDirectory, 'chats'),
new: userDirectories.chats,
file: false,
},
{
old: path.join(publicDirectory, 'context'),
new: userDirectories.context,
file: false,
},
{
old: path.join(publicDirectory, 'group chats'),
new: userDirectories.groupChats,
file: false,
},
{
old: path.join(publicDirectory, 'groups'),
new: userDirectories.groups,
file: false,
},
{
old: path.join(publicDirectory, 'instruct'),
new: userDirectories.instruct,
file: false,
},
{
old: path.join(publicDirectory, 'KoboldAI Settings'),
new: userDirectories.koboldAI_Settings,
file: false,
},
{
old: path.join(publicDirectory, 'movingUI'),
new: userDirectories.movingUI,
file: false,
},
{
old: path.join(publicDirectory, 'NovelAI Settings'),
new: userDirectories.novelAI_Settings,
file: false,
},
{
old: path.join(publicDirectory, 'OpenAI Settings'),
new: userDirectories.openAI_Settings,
file: false,
},
{
old: path.join(publicDirectory, 'QuickReplies'),
new: userDirectories.quickreplies,
file: false,
},
{
old: path.join(publicDirectory, 'TextGen Settings'),
new: userDirectories.textGen_Settings,
file: false,
},
{
old: path.join(publicDirectory, 'themes'),
new: userDirectories.themes,
file: false,
},
{
old: path.join(publicDirectory, 'user'),
new: userDirectories.user,
file: false,
},
{
old: path.join(publicDirectory, 'User Avatars'),
new: userDirectories.avatars,
file: false,
},
{
old: path.join(publicDirectory, 'worlds'),
new: userDirectories.worlds,
file: false,
},
{
old: path.join(publicDirectory, 'scripts/extensions/third-party'),
new: userDirectories.extensions,
file: false,
},
{
old: path.join(process.cwd(), 'thumbnails'),
new: userDirectories.thumbnails,
file: false,
},
{
old: path.join(process.cwd(), 'vectors'),
new: userDirectories.vectors,
file: false,
},
{
old: path.join(process.cwd(), 'secrets.json'),
new: path.join(userDirectories.root, 'secrets.json'),
file: true,
},
{
old: path.join(publicDirectory, 'settings.json'),
new: path.join(userDirectories.root, 'settings.json'),
file: true,
},
{
old: path.join(publicDirectory, 'stats.json'),
new: path.join(userDirectories.root, 'stats.json'),
file: true,
},
];
const currentDate = new Date().toISOString().split('T')[0];
const backupDirectory = path.join(process.cwd(), PUBLIC_DIRECTORIES.backups, '_migration', currentDate);
if (!fs.existsSync(backupDirectory)) {
fs.mkdirSync(backupDirectory, { recursive: true });
}
const errors = [];
for (const migration of dataMigrationMap) {
console.log(`Migrating ${migration.old} to ${migration.new}...`);
try {
if (!fs.existsSync(migration.old)) {
console.log(color.yellow(`Skipping migration of ${migration.old} as it does not exist.`));
continue;
}
if (migration.file) {
// Copy the file to the new location
fs.cpSync(migration.old, migration.new, { force: true });
// Move the file to the backup location
fs.cpSync(
migration.old,
path.join(backupDirectory, path.basename(migration.old)),
{ recursive: true, force: true },
);
fs.rmSync(migration.old, { recursive: true, force: true });
} else {
// Copy the directory to the new location
fs.cpSync(migration.old, migration.new, { recursive: true, force: true });
// Move the directory to the backup location
fs.cpSync(
migration.old,
path.join(backupDirectory, path.basename(migration.old)),
{ recursive: true, force: true },
);
fs.rmSync(migration.old, { recursive: true, force: true });
}
} catch (error) {
console.error(color.red(`Error migrating ${migration.old} to ${migration.new}:`), error.message);
errors.push(migration.old);
}
}
if (errors.length > 0) {
console.log(color.red('Migration completed with errors. Move the following files manually:'));
errors.forEach(error => console.error(error));
}
console.log(color.green('Migration completed!'));
}
/**
* Converts a user handle to a storage key.
* @param {string} handle User handle
* @returns {string} The key for the user storage
*/
function toKey(handle) {
return `${KEY_PREFIX}${handle}`;
}
/**
* Converts a user handle to a storage key for avatars.
* @param {string} handle User handle
* @returns {string} The key for the avatar storage
*/
function toAvatarKey(handle) {
return `${AVATAR_PREFIX}${handle}`;
}
/**
* Initializes the user storage.
* @param {string} dataRoot The root directory for user data
* @returns {Promise<void>}
*/
async function initUserStorage(dataRoot) {
global.DATA_ROOT = dataRoot;
console.log('Using data root:', color.green(global.DATA_ROOT));
console.log();
await storage.init({
dir: path.join(global.DATA_ROOT, '_storage'),
ttl: false, // Never expire
});
const keys = await getAllUserHandles();
// If there are no users, create the default user
if (keys.length === 0) {
await storage.setItem(toKey(DEFAULT_USER.handle), DEFAULT_USER);
}
}
/**
* Get the cookie secret from the config. If it doesn't exist, generate a new one.
* @returns {string} The cookie secret
*/
function getCookieSecret() {
let secret = getConfigValue(STORAGE_KEYS.cookieSecret);
if (!secret) {
console.warn(color.yellow('Cookie secret is missing from config.yaml. Generating a new one...'));
secret = crypto.randomBytes(64).toString('base64');
setConfigValue(STORAGE_KEYS.cookieSecret, secret);
}
return secret;
}
/**
* Generates a random password salt.
* @returns {string} The password salt
*/
function getPasswordSalt() {
return crypto.randomBytes(16).toString('base64');
}
/**
* Get the session name for the current server.
* @returns {string} The session name
*/
function getCookieSessionName() {
// Get server hostname and hash it to generate a session suffix
const suffix = crypto.createHash('sha256').update(os.hostname()).digest('hex').slice(0, 8);
return `session-${suffix}`;
}
/**
* Hashes a password using scrypt with the provided salt.
* @param {string} password Password to hash
* @param {string} salt Salt to use for hashing
* @returns {string} Hashed password
*/
function getPasswordHash(password, salt) {
return crypto.scryptSync(password.normalize(), salt, 64).toString('base64');
}
/**
* Get the CSRF secret from the storage.
* @param {import('express').Request} [request] HTTP request object
* @returns {string} The CSRF secret
*/
function getCsrfSecret(request) {
if (!request || !request.user) {
return ANON_CSRF_SECRET;
}
let csrfSecret = readSecret(request.user.directories, STORAGE_KEYS.csrfSecret);
if (!csrfSecret) {
csrfSecret = crypto.randomBytes(64).toString('base64');
writeSecret(request.user.directories, STORAGE_KEYS.csrfSecret, csrfSecret);
}
return csrfSecret;
}
/**
* Gets a list of all user handles.
* @returns {Promise<string[]>} - The list of user handles
*/
async function getAllUserHandles() {
const keys = await storage.keys(x => x.key.startsWith(KEY_PREFIX));
const handles = keys.map(x => x.replace(KEY_PREFIX, ''));
return handles;
}
/**
* Gets the directories listing for the provided user.
* @param {string} handle User handle
* @returns {UserDirectoryList} User directories
*/
function getUserDirectories(handle) {
if (DIRECTORIES_CACHE.has(handle)) {
const cache = DIRECTORIES_CACHE.get(handle);
if (cache) {
return cache;
}
}
const directories = structuredClone(USER_DIRECTORY_TEMPLATE);
for (const key in directories) {
directories[key] = path.join(global.DATA_ROOT, handle, USER_DIRECTORY_TEMPLATE[key]);
}
DIRECTORIES_CACHE.set(handle, directories);
return directories;
}
/**
* Gets the avatar URL for the provided user.
* @param {string} handle User handle
* @returns {Promise<string>} User avatar URL
*/
async function getUserAvatar(handle) {
try {
// Check if the user has a custom avatar
const avatarKey = toAvatarKey(handle);
const avatar = await storage.getItem(avatarKey);
if (avatar) {
return avatar;
}
// Fallback to reading from files if custom avatar is not set
const directory = getUserDirectories(handle);
const pathToSettings = path.join(directory.root, SETTINGS_FILE);
const settings = fs.existsSync(pathToSettings) ? JSON.parse(fs.readFileSync(pathToSettings, 'utf8')) : {};
const avatarFile = settings?.power_user?.default_persona || settings?.user_avatar;
if (!avatarFile) {
return DEFAULT_AVATAR;
}
const avatarPath = path.join(directory.avatars, avatarFile);
if (!fs.existsSync(avatarPath)) {
return DEFAULT_AVATAR;
}
const mimeType = mime.lookup(avatarPath);
const base64Content = fs.readFileSync(avatarPath, 'base64');
return `data:${mimeType};base64,${base64Content}`;
}
catch {
// Ignore errors
return DEFAULT_AVATAR;
}
}
/**
* Checks if the user should be redirected to the login page.
* @param {import('express').Request} request Request object
* @returns {boolean} Whether the user should be redirected to the login page
*/
function shouldRedirectToLogin(request) {
return ENABLE_ACCOUNTS && !request.user;
}
/**
* Tries auto-login if there is only one user and it's not password protected.
* @param {import('express').Request} request Request object
* @returns {Promise<boolean>} Whether auto-login was performed
*/
async function tryAutoLogin(request) {
if (!ENABLE_ACCOUNTS || request.user || !request.session) {
return false;
}
const userHandles = await getAllUserHandles();
if (userHandles.length === 1) {
const user = await storage.getItem(toKey(userHandles[0]));
if (user && !user.password) {
request.session.handle = userHandles[0];
return true;
}
}
return false;
}
/**
* Middleware to add user data to the request object.
* @param {import('express').Request} request Request object
* @param {import('express').Response} response Response object
* @param {import('express').NextFunction} next Next function
*/
async function setUserDataMiddleware(request, response, next) {
// If user accounts are disabled, use the default user
if (!ENABLE_ACCOUNTS) {
const handle = DEFAULT_USER.handle;
const directories = getUserDirectories(handle);
request.user = {
profile: DEFAULT_USER,
directories: directories,
};
return next();
}
if (!request.session) {
console.error('Session not available');
return response.sendStatus(500);
}
// If user accounts are enabled, get the user from the session
let handle = request.session?.handle;
// If we have the only user and it's not password protected, use it
if (!handle) {
return next();
}
/** @type {User} */
const user = await storage.getItem(toKey(handle));
if (!user) {
console.error('User not found:', handle);
return next();
}
if (!user.enabled) {
console.error('User is disabled:', handle);
return next();
}
const directories = getUserDirectories(handle);
request.user = {
profile: user,
directories: directories,
};
// Touch the session if loading the home page
if (request.method === 'GET' && request.path === '/') {
request.session.touch = Date.now();
}
return next();
}
/**
* Middleware to add user data to the request object.
* @param {import('express').Request} request Request object
* @param {import('express').Response} response Response object
* @param {import('express').NextFunction} next Next function
*/
function requireLoginMiddleware(request, response, next) {
if (!request.user) {
return response.sendStatus(403);
}
return next();
}
/**
* Creates a route handler for serving files from a specific directory.
* @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from
* @returns {import('express').RequestHandler}
*/
function createRouteHandler(directoryFn) {
return async (req, res) => {
try {
const directory = directoryFn(req);
const filePath = decodeURIComponent(req.params[0]);
const exists = fs.existsSync(path.join(directory, filePath));
if (!exists) {
return res.sendStatus(404);
}
return res.sendFile(filePath, { root: directory });
} catch (error) {
return res.sendStatus(500);
}
};
}
/**
* Verifies that the current user is an admin.
* @param {import('express').Request} request Request object
* @param {import('express').Response} response Response object
* @param {import('express').NextFunction} next Next function
* @returns {any}
*/
function requireAdminMiddleware(request, response, next) {
if (!request.user) {
return response.sendStatus(403);
}
if (request.user.profile.admin) {
return next();
}
console.warn('Unauthorized access to admin endpoint:', request.originalUrl);
return response.sendStatus(403);
}
/**
* Creates an archive of the user's data root directory.
* @param {string} handle User handle
* @param {import('express').Response} response Express response object to write to
* @returns {Promise<void>} Promise that resolves when the archive is created
*/
async function createBackupArchive(handle, response) {
const directories = getUserDirectories(handle);
console.log('Backup requested for', handle);
const archive = archiver('zip');
archive.on('error', function (err) {
response.status(500).send({ error: err.message });
});
// On stream closed we can end the request
archive.on('end', function () {
console.log('Archive wrote %d bytes', archive.pointer());
response.end(); // End the Express response
});
const timestamp = generateTimestamp();
// Set the archive name
response.attachment(`${handle}-${timestamp}.zip`);
// This is the streaming magic
// @ts-ignore
archive.pipe(response);
// Append files from a sub-directory, putting its contents at the root of archive
archive.directory(directories.root, false);
archive.finalize();
}
/**
* Gets all of the users.
* @returns {Promise<User[]>}
*/
async function getAllUsers() {
if (!ENABLE_ACCOUNTS) {
return [];
}
/**
* @type {User[]}
*/
const users = await storage.values();
return users;
}
/**
* Gets all of the enabled users.
* @returns {Promise<User[]>}
*/
async function getAllEnabledUsers() {
const users = await getAllUsers();
return users.filter(x => x.enabled);
}
/**
* Express router for serving files from the user's directories.
*/
const router = express.Router();
router.use('/backgrounds/*', createRouteHandler(req => req.user.directories.backgrounds));
router.use('/characters/*', createRouteHandler(req => req.user.directories.characters));
router.use('/User%20Avatars/*', createRouteHandler(req => req.user.directories.avatars));
router.use('/assets/*', createRouteHandler(req => req.user.directories.assets));
router.use('/user/images/*', createRouteHandler(req => req.user.directories.userImages));
router.use('/user/files/*', createRouteHandler(req => req.user.directories.files));
router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.user.directories.extensions));
module.exports = {
KEY_PREFIX,
toKey,
toAvatarKey,
initUserStorage,
ensurePublicDirectoriesExist,
getUserDirectoriesList,
getAllUserHandles,
getUserDirectories,
setUserDataMiddleware,
requireLoginMiddleware,
requireAdminMiddleware,
migrateUserData,
getPasswordSalt,
getPasswordHash,
getCsrfSecret,
getCookieSecret,
getCookieSessionName,
getUserAvatar,
shouldRedirectToLogin,
createBackupArchive,
tryAutoLogin,
getAllUsers,
getAllEnabledUsers,
router,
};
|