diff --git a/.dockerignore b/.dockerignore index daf6041ae..e4995fe58 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,7 @@ npm-debug.log readme* Start.bat /dist -/backups/ +/backups cloudflared.exe access.log +/data diff --git a/.gitignore b/.gitignore index 64b33ddb2..d6a5061bb 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ public/stats.json /docker/config /docker/user /docker/extensions +/docker/data .DS_Store public/settings.json /thumbnails diff --git a/Dockerfile b/Dockerfile index efae20b0e..64a92173a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:19.1.0-alpine3.16 +FROM node:lts-alpine3.18 # Arguments ARG APP_HOME=/home/node/app @@ -26,19 +26,9 @@ COPY . ./ # Copy default chats, characters and user avatars to .default folder RUN \ - IFS="," RESOURCES="assets,backgrounds,user,context,instruct,QuickReplies,movingUI,themes,characters,chats,groups,group chats,User Avatars,worlds,OpenAI Settings,NovelAI Settings,KoboldAI Settings,TextGen Settings" && \ - \ - echo "*** Store default $RESOURCES in .default ***" && \ - for R in $RESOURCES; do mv "public/$R" "public/$R.default"; done || true && \ - \ - echo "*** Create symbolic links to config directory ***" && \ - for R in $RESOURCES; do ln -s "../config/$R" "public/$R"; done || true && \ - \ - rm -f "config.yaml" "public/settings.json" || true && \ + rm -f "config.yaml" || true && \ ln -s "./config/config.yaml" "config.yaml" || true && \ - ln -s "../config/settings.json" "public/settings.json" || true && \ - mkdir "config" || true && \ - mkdir -p "public/user" || true + mkdir "config" || true # Cleanup unnecessary files RUN \ diff --git a/public/KoboldAI Settings/.gitkeep b/data/.gitkeep similarity index 100% rename from public/KoboldAI Settings/.gitkeep rename to data/.gitkeep diff --git a/default/config.yaml b/default/config.yaml index dedb5ac5f..f0d8e7bf6 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -1,8 +1,12 @@ -# -- NETWORK CONFIGURATION -- +# -- DATA CONFIGURATION -- +# Root directory for user data storage +dataRoot: ./data +# -- SERVER CONFIGURATION -- # Listen for incoming connections listen: false # Server port port: 8000 +# -- SECURITY CONFIGURATION -- # Toggle whitelist mode whitelistMode: true # Whitelist of allowed IP addresses @@ -16,6 +20,12 @@ basicAuthUser: password: "password" # Enables CORS proxy middleware enableCorsProxy: false +# Enable multi-user mode +enableUserAccounts: false +# Enable discreet login mode: hides user list on the login screen +enableDiscreetLogin: false +# Used to sign session cookies. Will be auto-generated if not set +cookieSecret: '' # Disable security checks - NOT RECOMMENDED securityOverride: false # -- ADVANCED CONFIGURATION -- diff --git a/public/backgrounds/__transparent.png b/default/content/backgrounds/__transparent.png similarity index 100% rename from public/backgrounds/__transparent.png rename to default/content/backgrounds/__transparent.png diff --git a/public/backgrounds/_black.jpg b/default/content/backgrounds/_black.jpg similarity index 100% rename from public/backgrounds/_black.jpg rename to default/content/backgrounds/_black.jpg diff --git a/public/backgrounds/_white.jpg b/default/content/backgrounds/_white.jpg similarity index 100% rename from public/backgrounds/_white.jpg rename to default/content/backgrounds/_white.jpg diff --git a/public/backgrounds/bedroom clean.jpg b/default/content/backgrounds/bedroom clean.jpg similarity index 100% rename from public/backgrounds/bedroom clean.jpg rename to default/content/backgrounds/bedroom clean.jpg diff --git a/public/backgrounds/bedroom cyberpunk.jpg b/default/content/backgrounds/bedroom cyberpunk.jpg similarity index 100% rename from public/backgrounds/bedroom cyberpunk.jpg rename to default/content/backgrounds/bedroom cyberpunk.jpg diff --git a/public/backgrounds/bedroom red.jpg b/default/content/backgrounds/bedroom red.jpg similarity index 100% rename from public/backgrounds/bedroom red.jpg rename to default/content/backgrounds/bedroom red.jpg diff --git a/public/backgrounds/bedroom tatami.jpg b/default/content/backgrounds/bedroom tatami.jpg similarity index 100% rename from public/backgrounds/bedroom tatami.jpg rename to default/content/backgrounds/bedroom tatami.jpg diff --git a/public/backgrounds/cityscape medieval market.jpg b/default/content/backgrounds/cityscape medieval market.jpg similarity index 100% rename from public/backgrounds/cityscape medieval market.jpg rename to default/content/backgrounds/cityscape medieval market.jpg diff --git a/public/backgrounds/cityscape medieval night.jpg b/default/content/backgrounds/cityscape medieval night.jpg similarity index 100% rename from public/backgrounds/cityscape medieval night.jpg rename to default/content/backgrounds/cityscape medieval night.jpg diff --git a/public/backgrounds/cityscape postapoc.jpg b/default/content/backgrounds/cityscape postapoc.jpg similarity index 100% rename from public/backgrounds/cityscape postapoc.jpg rename to default/content/backgrounds/cityscape postapoc.jpg diff --git a/public/backgrounds/forest treehouse fireworks air baloons (by kallmeflocc).jpg b/default/content/backgrounds/forest treehouse fireworks air baloons (by kallmeflocc).jpg similarity index 100% rename from public/backgrounds/forest treehouse fireworks air baloons (by kallmeflocc).jpg rename to default/content/backgrounds/forest treehouse fireworks air baloons (by kallmeflocc).jpg diff --git a/public/backgrounds/japan classroom side.jpg b/default/content/backgrounds/japan classroom side.jpg similarity index 100% rename from public/backgrounds/japan classroom side.jpg rename to default/content/backgrounds/japan classroom side.jpg diff --git a/public/backgrounds/japan classroom.jpg b/default/content/backgrounds/japan classroom.jpg similarity index 100% rename from public/backgrounds/japan classroom.jpg rename to default/content/backgrounds/japan classroom.jpg diff --git a/public/backgrounds/japan path cherry blossom.jpg b/default/content/backgrounds/japan path cherry blossom.jpg similarity index 100% rename from public/backgrounds/japan path cherry blossom.jpg rename to default/content/backgrounds/japan path cherry blossom.jpg diff --git a/public/backgrounds/japan university.jpg b/default/content/backgrounds/japan university.jpg similarity index 100% rename from public/backgrounds/japan university.jpg rename to default/content/backgrounds/japan university.jpg diff --git a/public/backgrounds/landscape autumn great tree.jpg b/default/content/backgrounds/landscape autumn great tree.jpg similarity index 100% rename from public/backgrounds/landscape autumn great tree.jpg rename to default/content/backgrounds/landscape autumn great tree.jpg diff --git a/public/backgrounds/landscape beach day.png b/default/content/backgrounds/landscape beach day.png similarity index 100% rename from public/backgrounds/landscape beach day.png rename to default/content/backgrounds/landscape beach day.png diff --git a/public/backgrounds/landscape beach night.jpg b/default/content/backgrounds/landscape beach night.jpg similarity index 100% rename from public/backgrounds/landscape beach night.jpg rename to default/content/backgrounds/landscape beach night.jpg diff --git a/public/backgrounds/landscape mountain lake.jpg b/default/content/backgrounds/landscape mountain lake.jpg similarity index 100% rename from public/backgrounds/landscape mountain lake.jpg rename to default/content/backgrounds/landscape mountain lake.jpg diff --git a/public/backgrounds/landscape postapoc.jpg b/default/content/backgrounds/landscape postapoc.jpg similarity index 100% rename from public/backgrounds/landscape postapoc.jpg rename to default/content/backgrounds/landscape postapoc.jpg diff --git a/public/backgrounds/landscape winter lake house.jpg b/default/content/backgrounds/landscape winter lake house.jpg similarity index 100% rename from public/backgrounds/landscape winter lake house.jpg rename to default/content/backgrounds/landscape winter lake house.jpg diff --git a/public/backgrounds/royal.jpg b/default/content/backgrounds/royal.jpg similarity index 100% rename from public/backgrounds/royal.jpg rename to default/content/backgrounds/royal.jpg diff --git a/public/backgrounds/tavern day.jpg b/default/content/backgrounds/tavern day.jpg similarity index 100% rename from public/backgrounds/tavern day.jpg rename to default/content/backgrounds/tavern day.jpg diff --git a/default/content/index.json b/default/content/index.json index 8a914b959..d81d37552 100644 --- a/default/content/index.json +++ b/default/content/index.json @@ -1,4 +1,108 @@ [ + { + "filename": "settings.json", + "type": "settings" + }, + { + "filename": "themes/Dark Lite.json", + "type": "theme" + }, + { + "filename": "themes/Cappuccino.json", + "type": "theme" + }, + { + "filename": "backgrounds/__transparent.png", + "type": "background" + }, + { + "filename": "backgrounds/_black.jpg", + "type": "background" + }, + { + "filename": "backgrounds/_white.jpg", + "type": "background" + }, + { + "filename": "backgrounds/bedroom clean.jpg", + "type": "background" + }, + { + "filename": "backgrounds/bedroom cyberpunk.jpg", + "type": "background" + }, + { + "filename": "backgrounds/bedroom red.jpg", + "type": "background" + }, + { + "filename": "backgrounds/bedroom tatami.jpg", + "type": "background" + }, + { + "filename": "backgrounds/cityscape medieval market.jpg", + "type": "background" + }, + { + "filename": "backgrounds/cityscape medieval night.jpg", + "type": "background" + }, + { + "filename": "backgrounds/cityscape postapoc.jpg", + "type": "background" + }, + { + "filename": "backgrounds/forest treehouse fireworks air baloons (by kallmeflocc).jpg", + "type": "background" + }, + { + "filename": "backgrounds/japan classroom side.jpg", + "type": "background" + }, + { + "filename": "backgrounds/japan classroom.jpg", + "type": "background" + }, + { + "filename": "backgrounds/japan path cherry blossom.jpg", + "type": "background" + }, + { + "filename": "backgrounds/japan university.jpg", + "type": "background" + }, + { + "filename": "backgrounds/landscape autumn great tree.jpg", + "type": "background" + }, + { + "filename": "backgrounds/landscape beach day.png", + "type": "background" + }, + { + "filename": "backgrounds/landscape beach night.jpg", + "type": "background" + }, + { + "filename": "backgrounds/landscape mountain lake.jpg", + "type": "background" + }, + { + "filename": "backgrounds/landscape postapoc.jpg", + "type": "background" + }, + { + "filename": "backgrounds/landscape winter lake house.jpg", + "type": "background" + }, + { + "filename": "backgrounds/royal.jpg", + "type": "background" + }, + { + "filename": "backgrounds/tavern day.jpg", + "type": "background" + }, { "filename": "default_Seraphina.png", "type": "character" @@ -211,7 +315,6 @@ "filename": "presets/novel/Writers-Daemon-Kayra.json", "type": "novel_preset" }, - { "filename": "presets/textgen/Asterism.json", "type": "textgen_preset" @@ -511,5 +614,17 @@ { "filename": "presets/instruct/simple-proxy-for-tavern.json", "type": "instruct" + }, + { + "filename": "presets/moving-ui/Default.json", + "type": "moving_ui" + }, + { + "filename": "presets/moving-ui/Black Magic Time.json", + "type": "moving_ui" + }, + { + "filename": "presets/quick-replies/Default.json", + "type": "quick_replies" } ] diff --git a/public/movingUI/Black Magic Time.json b/default/content/presets/moving-ui/Black Magic Time.json similarity index 100% rename from public/movingUI/Black Magic Time.json rename to default/content/presets/moving-ui/Black Magic Time.json diff --git a/public/movingUI/Default.json b/default/content/presets/moving-ui/Default.json similarity index 100% rename from public/movingUI/Default.json rename to default/content/presets/moving-ui/Default.json diff --git a/public/QuickReplies/Default.json b/default/content/presets/quick-replies/Default.json similarity index 100% rename from public/QuickReplies/Default.json rename to default/content/presets/quick-replies/Default.json diff --git a/default/settings.json b/default/content/settings.json similarity index 99% rename from default/settings.json rename to default/content/settings.json index dbd731c45..ef7f4f0b1 100644 --- a/default/settings.json +++ b/default/content/settings.json @@ -95,7 +95,7 @@ "user_prompt_bias": "", "show_user_prompt_bias": true, "markdown_escape_strings": "", - "fast_ui_mode": false, + "fast_ui_mode": true, "avatar_style": 0, "chat_display": 0, "chat_width": 50, @@ -115,16 +115,17 @@ "italics_text_color": "rgba(145, 145, 145, 1)", "underline_text_color": "rgba(188, 231, 207, 1)", "quote_text_color": "rgba(225, 138, 36, 1)", + "chat_tint_color": "rgba(23, 23, 23, 1)", "blur_tint_color": "rgba(23, 23, 23, 1)", - "user_mes_blur_tint_color": "rgba(0, 0, 0, 0.9)", - "bot_mes_blur_tint_color": "rgba(0, 0, 0, 0.9)", + "user_mes_blur_tint_color": "rgba(30, 30, 30, 0.9)", + "bot_mes_blur_tint_color": "rgba(30, 30, 30, 0.9)", "shadow_color": "rgba(0, 0, 0, 1)", "waifuMode": false, "movingUI": false, "movingUIState": {}, "movingUIPreset": "Default", "noShadows": true, - "theme": "Default (Dark) 1.7.1", + "theme": "Dark Lite", "auto_swipe": false, "auto_swipe_minimum_length": 0, "auto_swipe_blacklist": [], @@ -139,7 +140,7 @@ "hotswap_enabled": true, "timer_enabled": false, "timestamps_enabled": true, - "timestamp_model_icon": false, + "timestamp_model_icon": true, "mesIDDisplay_enabled": false, "max_context_unlocked": false, "prefer_character_prompt": true, diff --git a/default/content/themes/Cappuccino.json b/default/content/themes/Cappuccino.json new file mode 100644 index 000000000..daf12dcd2 --- /dev/null +++ b/default/content/themes/Cappuccino.json @@ -0,0 +1,35 @@ +{ + "name": "Cappuccino", + "blur_strength": 3, + "main_text_color": "rgba(255, 255, 255, 1)", + "italics_text_color": "rgba(230, 210, 190, 1)", + "underline_text_color": "rgba(205, 180, 160, 1)", + "quote_text_color": "rgba(165, 140, 115, 1)", + "blur_tint_color": "rgba(34, 30, 32, 0.95)", + "chat_tint_color": "rgba(50, 45, 50, 0.75)", + "user_mes_blur_tint_color": "rgba(34, 30, 32, 0.75)", + "bot_mes_blur_tint_color": "rgba(34, 30, 32, 0.75)", + "shadow_color": "rgba(0, 0, 0, 0.3)", + "shadow_width": 1, + "border_color": "rgba(80, 80, 80, 0.89)", + "font_scale": 1, + "fast_ui_mode": false, + "waifuMode": false, + "avatar_style": 0, + "chat_display": 1, + "noShadows": false, + "chat_width": 50, + "timer_enabled": false, + "timestamps_enabled": true, + "timestamp_model_icon": true, + "mesIDDisplay_enabled": true, + "message_token_count_enabled": false, + "expand_message_actions": false, + "enableZenSliders": false, + "enableLabMode": false, + "hotswap_enabled": true, + "custom_css": "", + "bogus_folders": true, + "reduced_motion": false, + "compact_input_area": true +} diff --git a/default/content/themes/Dark Lite.json b/default/content/themes/Dark Lite.json new file mode 100644 index 000000000..452d7c882 --- /dev/null +++ b/default/content/themes/Dark Lite.json @@ -0,0 +1,35 @@ +{ + "name": "Dark Lite", + "blur_strength": 10, + "main_text_color": "rgba(220, 220, 210, 1)", + "italics_text_color": "rgba(145, 145, 145, 1)", + "underline_text_color": "rgba(188, 231, 207, 1)", + "quote_text_color": "rgba(225, 138, 36, 1)", + "blur_tint_color": "rgba(23, 23, 23, 1)", + "chat_tint_color": "rgba(23, 23, 23, 1)", + "user_mes_blur_tint_color": "rgba(30, 30, 30, 0.9)", + "bot_mes_blur_tint_color": "rgba(30, 30, 30, 0.9)", + "shadow_color": "rgba(0, 0, 0, 1)", + "shadow_width": 2, + "border_color": "rgba(0, 0, 0, 1)", + "font_scale": 1, + "fast_ui_mode": true, + "waifuMode": false, + "avatar_style": 0, + "chat_display": 0, + "noShadows": true, + "chat_width": 50, + "timer_enabled": false, + "timestamps_enabled": true, + "timestamp_model_icon": true, + "mesIDDisplay_enabled": false, + "message_token_count_enabled": false, + "expand_message_actions": false, + "enableZenSliders": "", + "enableLabMode": "", + "hotswap_enabled": true, + "custom_css": "", + "bogus_folders": true, + "reduced_motion": false, + "compact_input_area": true +} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d96c21fe4..2f1f68676 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -8,7 +8,6 @@ services: ports: - "8000:8000" volumes: - - "./extensions:/home/node/app/public/scripts/extensions/third-party" - "./config:/home/node/app/config" - - "./user:/home/node/app/public/user" + - "./data:/home/node/app/data" restart: unless-stopped diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index bafbdaf62..6432f40c1 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -1,38 +1,14 @@ #!/bin/sh -# Initialize missing user files -IFS="," RESOURCES="assets,backgrounds,user,context,instruct,QuickReplies,movingUI,themes,characters,chats,groups,group chats,User Avatars,worlds,OpenAI Settings,NovelAI Settings,KoboldAI Settings,TextGen Settings" -for R in $RESOURCES; do - if [ ! -e "config/$R" ]; then - echo "Resource not found, copying from defaults: $R" - cp -r "public/$R.default" "config/$R" - fi -done - if [ ! -e "config/config.yaml" ]; then echo "Resource not found, copying from defaults: config.yaml" cp -r "default/config.yaml" "config/config.yaml" fi -if [ ! -e "config/settings.json" ]; then - echo "Resource not found, copying from defaults: settings.json" - cp -r "default/settings.json" "config/settings.json" -fi - CONFIG_FILE="config.yaml" echo "Starting with the following config:" cat $CONFIG_FILE -if grep -q "listen: false" $CONFIG_FILE; then - echo -e "\033[1;31mThe listen parameter is set to false. If you can't connect to the server, edit the \"docker/config/config.yaml\" file and restart the container.\033[0m" - sleep 5 -fi - -if grep -q "whitelistMode: true" $CONFIG_FILE; then - echo -e "\033[1;31mThe whitelistMode parameter is set to true. If you can't connect to the server, edit the \"docker/config/config.yaml\" file and restart the container.\033[0m" - sleep 5 -fi - # Start the server -exec node server.js +exec node server.js --listen diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 000000000..8e30e75e6 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,19 @@ +import { UserDirectoryList, User } from "./src/users"; + +declare global { + namespace Express { + export interface Request { + user: { + profile: User; + directories: UserDirectoryList; + }; + } + } +} + +declare module 'express-session' { + export interface SessionData { + handle: string; + // other properties... + } + } diff --git a/jsconfig.json b/jsconfig.json index 652e04b1c..e7691789d 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -12,6 +12,9 @@ }, "exclude": [ "node_modules", - "**/node_modules/*" + "**/node_modules/*", + "public/lib", + "backups/*", + "data/*" ] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index dca9e969d..04b122d5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sillytavern", - "version": "1.11.7", + "version": "1.12.0-preview", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sillytavern", - "version": "1.11.7", + "version": "1.12.0-preview", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -14,17 +14,20 @@ "@agnai/web-tokenizers": "^0.1.3", "@dqbd/tiktoken": "^1.0.13", "@zeldafan0225/ai_horde": "^4.0.1", + "archiver": "^7.0.1", "bing-translate-api": "^2.9.1", "body-parser": "^1.20.2", "command-exists": "^1.2.9", "compression": "^1", "cookie-parser": "^1.4.6", + "cookie-session": "^2.1.0", "cors": "^2.8.5", "csrf-csrf": "^2.2.3", "express": "^4.19.2", "form-data": "^4.0.0", "google-translate-api-browser": "^3.0.1", "gpt3-tokenizer": "^1.1.5", + "helmet": "^7.1.0", "ip-matching": "^2.1.2", "ipaddr.js": "^2.0.1", "jimp": "^0.22.10", @@ -32,10 +35,12 @@ "mime-types": "^2.1.35", "multer": "^1.4.5-lts.1", "node-fetch": "^2.6.11", + "node-persist": "^4.0.1", "open": "^8.4.2", "png-chunk-text": "^1.0.0", "png-chunks-encode": "^1.0.0", "png-chunks-extract": "^1.0.0", + "rate-limiter-flexible": "^5.0.0", "response-time": "^2.3.2", "sanitize-filename": "^1.6.3", "sillytavern-transformers": "^2.14.6", @@ -52,6 +57,7 @@ "sillytavern": "server.js" }, "devDependencies": { + "@types/jquery": "^3.5.29", "eslint": "^8.55.0", "jquery": "^3.6.4" } @@ -221,6 +227,95 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@jimp/bmp": { "version": "0.22.10", "license": "MIT", @@ -640,6 +735,15 @@ "node": ">= 8" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "license": "BSD-3-Clause" @@ -726,6 +830,15 @@ "version": "4.0.2", "license": "MIT" }, + "node_modules/@types/jquery": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.29.tgz", + "integrity": "sha512-oXQQC9X9MOPRrMhPHHOsXqeQDnWeCDT3PelUIg/Oy8FAbzSZtFHRjc7IpbfFVmpLtJ+UOoywpRsuO5Jxjybyeg==", + "dev": true, + "dependencies": { + "@types/sizzle": "*" + } + }, "node_modules/@types/keyv": { "version": "3.1.4", "license": "MIT", @@ -756,6 +869,12 @@ "@types/node": "*" } }, + "node_modules/@types/sizzle": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz", + "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", + "dev": true + }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "dev": true, @@ -870,6 +989,221 @@ "version": "1.0.0", "license": "MIT" }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.3.12", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", + "integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.6", + "minimatch": "^9.0.1", + "minipass": "^7.0.4", + "path-scurry": "^1.10.2" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver/node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/archiver/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "dev": true, @@ -883,6 +1217,11 @@ "version": "2.1.3", "license": "ISC" }, + "node_modules/async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" + }, "node_modules/asynckit": { "version": "0.4.0", "license": "MIT" @@ -896,11 +1235,21 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/b4a": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz", + "integrity": "sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==" + }, "node_modules/balanced-match": { "version": "1.0.2", - "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.2.2.tgz", + "integrity": "sha512-h7z00dWdG0PYOQEvChhOSWvOfkIKsdZGkWr083FgN/HyoQuebSew/cgirYqh9SCuy/hRvxc5Vy6Fw8xAmYHLkQ==", + "optional": true + }, "node_modules/base-64": { "version": "0.1.0" }, @@ -1074,8 +1423,12 @@ } }, "node_modules/centra": { - "version": "2.6.0", - "license": "MIT" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/centra/-/centra-2.7.0.tgz", + "integrity": "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg==", + "dependencies": { + "follow-redirects": "^1.15.6" + } }, "node_modules/chalk": { "version": "4.1.2", @@ -1190,6 +1543,97 @@ "version": "1.2.9", "license": "MIT" }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/compress-commons/node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/compress-commons/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/compress-commons/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/compressible": { "version": "2.0.18", "license": "MIT", @@ -1287,10 +1731,68 @@ "node": ">= 0.8.0" } }, + "node_modules/cookie-session": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.0.tgz", + "integrity": "sha512-u73BDmR8QLGcs+Lprs0cfbcAPKl2HnPcjpwRXT41sEV4DRJ2+W0vJEEZkG31ofkx+HZflA70siRIjiTdIodmOQ==", + "dependencies": { + "cookies": "0.9.1", + "debug": "3.2.7", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cookie-session/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/cookie-session/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/cookie-session/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/cookie-signature": { "version": "1.0.6", "license": "MIT" }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "license": "MIT" @@ -1313,9 +1815,96 @@ "node": ">=0.8" } }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/crc32-stream/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/crc32-stream/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1501,6 +2090,11 @@ "node": ">=10" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, "node_modules/ee-first": { "version": "1.1.1", "license": "MIT" @@ -1748,6 +2342,14 @@ "node": ">=6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/exif-parser": { "version": "0.1.12" }, @@ -1823,6 +2425,11 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "dev": true, @@ -1946,6 +2553,21 @@ } } }, + "node_modules/foreground-child": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", + "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.0", "license": "MIT", @@ -2126,6 +2748,11 @@ "node": ">=12" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, "node_modules/graphemer": { "version": "1.4.0", "dev": true, @@ -2173,6 +2800,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/helmet": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.1.0.tgz", + "integrity": "sha512-g+HZqgfbpXdCkme/Cd/mZkV0aV3BZZZSugecH03kl38m/Kmdx8jKjBikpDj2cr+Iynv4KpYEviojNdTJActJAg==", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "funding": [ @@ -2370,6 +3005,17 @@ "node": ">=8" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-wsl": { "version": "2.2.0", "license": "MIT", @@ -2386,7 +3032,6 @@ }, "node_modules/isexe": { "version": "2.0.0", - "dev": true, "license": "ISC" }, "node_modules/isomorphic-fetch": { @@ -2397,6 +3042,23 @@ "whatwg-fetch": "^3.4.1" } }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jimp": { "version": "0.22.10", "license": "MIT", @@ -2499,6 +3161,17 @@ "dev": true, "license": "MIT" }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/keyv": { "version": "4.5.3", "license": "MIT", @@ -2506,6 +3179,17 @@ "json-buffer": "3.0.1" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, "node_modules/levn": { "version": "0.4.1", "dev": true, @@ -2570,6 +3254,14 @@ "node": ">=8" } }, + "node_modules/lru-cache": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", + "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "engines": { + "node": "14 || >=16.14" + } + }, "node_modules/md5": { "version": "2.3.0", "license": "BSD-3-Clause", @@ -2658,6 +3350,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", + "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.0.0", "license": "MIT" @@ -2735,6 +3435,22 @@ } } }, + "node_modules/node-persist": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/node-persist/-/node-persist-4.0.1.tgz", + "integrity": "sha512-QtRjwAlcOQChQpfG6odtEhxYmA3nS5XYr+bx9JRjwahl1TM3sm9J3CCn51/MI0eoHRb2DrkEsCOFo8sq8jG5sQ==", + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/normalize-url": { "version": "6.1.0", "license": "MIT", @@ -2992,12 +3708,26 @@ }, "node_modules/path-key": { "version": "3.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.2.tgz", + "integrity": "sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-to-regexp": { "version": "0.1.7", "license": "MIT" @@ -3018,8 +3748,15 @@ "license": "MIT" }, "node_modules/phin": { - "version": "2.9.3", - "license": "MIT" + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/phin/-/phin-3.7.1.tgz", + "integrity": "sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ==", + "dependencies": { + "centra": "^2.7.0" + }, + "engines": { + "node": ">= 8" + } }, "node_modules/pixelmatch": { "version": "4.0.2", @@ -3181,6 +3918,11 @@ ], "license": "MIT" }, + "node_modules/queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" + }, "node_modules/quick-lru": { "version": "5.1.1", "license": "MIT", @@ -3198,6 +3940,11 @@ "node": ">= 0.6" } }, + "node_modules/rate-limiter-flexible": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-5.0.0.tgz", + "integrity": "sha512-ivCyLBwPtR5IRrz+aZnztVwX16ZK3iAjdlW21I/vjHq56at5Zb8eIefDzODg8R7hwPOHpBtb6Pj9Zdmn0nRb8g==" + }, "node_modules/raw-body": { "version": "2.5.2", "license": "MIT", @@ -3257,6 +4004,33 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/regenerator-runtime": { "version": "0.13.11", "license": "MIT" @@ -3417,7 +4191,6 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -3428,7 +4201,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3514,6 +4286,18 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.16.1.tgz", + "integrity": "sha512-m9QYj6WygWyWa3H1YY69amr4nVgy61xfjys7xO7kviL5rfIEc2naf+ewFiOA+aEJD7y0JO3h2GoiUv4TDwEGzQ==", + "dependencies": { + "fast-fifo": "^1.1.0", + "queue-tick": "^1.0.1" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "license": "MIT", @@ -3533,6 +4317,20 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "license": "MIT", @@ -3543,6 +4341,18 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strtok3": { "version": "6.3.0", "license": "MIT", @@ -3569,6 +4379,16 @@ "node": ">=8" } }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/text-table": { "version": "0.2.0", "dev": true, @@ -3615,6 +4435,14 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/type-check": { "version": "0.4.0", "dev": true, @@ -3694,8 +4522,13 @@ } }, "node_modules/uuid": { - "version": "9.0.0", - "license": "MIT", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } @@ -3759,7 +4592,6 @@ }, "node_modules/which": { "version": "2.0.2", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -3786,6 +4618,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "license": "ISC" @@ -3914,6 +4763,84 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/zip-stream/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/zip-stream/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } } } } diff --git a/package.json b/package.json index d491384c0..8f84c95b9 100644 --- a/package.json +++ b/package.json @@ -4,17 +4,20 @@ "@agnai/web-tokenizers": "^0.1.3", "@dqbd/tiktoken": "^1.0.13", "@zeldafan0225/ai_horde": "^4.0.1", + "archiver": "^7.0.1", "bing-translate-api": "^2.9.1", "body-parser": "^1.20.2", "command-exists": "^1.2.9", "compression": "^1", "cookie-parser": "^1.4.6", + "cookie-session": "^2.1.0", "cors": "^2.8.5", "csrf-csrf": "^2.2.3", "express": "^4.19.2", "form-data": "^4.0.0", "google-translate-api-browser": "^3.0.1", "gpt3-tokenizer": "^1.1.5", + "helmet": "^7.1.0", "ip-matching": "^2.1.2", "ipaddr.js": "^2.0.1", "jimp": "^0.22.10", @@ -22,10 +25,12 @@ "mime-types": "^2.1.35", "multer": "^1.4.5-lts.1", "node-fetch": "^2.6.11", + "node-persist": "^4.0.1", "open": "^8.4.2", "png-chunk-text": "^1.0.0", "png-chunks-encode": "^1.0.0", "png-chunks-extract": "^1.0.0", + "rate-limiter-flexible": "^5.0.0", "response-time": "^2.3.2", "sanitize-filename": "^1.6.3", "sillytavern-transformers": "^2.14.6", @@ -45,6 +50,9 @@ "vectra": { "openai": "^4.17.0" }, + "load-bmfont": { + "phin": "^3.7.1" + }, "axios": { "follow-redirects": "^1.15.4" }, @@ -59,7 +67,7 @@ "type": "git", "url": "https://github.com/SillyTavern/SillyTavern.git" }, - "version": "1.11.7", + "version": "1.12.0-preview", "scripts": { "start": "node server.js", "start-multi": "node server.js --disableCsrf", @@ -76,6 +84,7 @@ }, "main": "server.js", "devDependencies": { + "@types/jquery": "^3.5.29", "eslint": "^8.55.0", "jquery": "^3.6.4" } diff --git a/post-install.js b/post-install.js index 406154aae..645085a8a 100644 --- a/post-install.js +++ b/post-install.js @@ -106,7 +106,6 @@ function addMissingConfigValues() { */ function createDefaultFiles() { const files = { - settings: './public/settings.json', config: './config.yaml', user: './public/css/user.css', }; @@ -167,29 +166,6 @@ function copyWasmFiles() { } } -/** - * Moves the custom background into settings.json. - */ -function migrateBackground() { - if (!fs.existsSync('./public/css/bg_load.css')) return; - - const bgCSS = fs.readFileSync('./public/css/bg_load.css', 'utf-8'); - const bgMatch = /url\('([^']*)'\)/.exec(bgCSS); - if (!bgMatch) return; - const bgFilename = bgMatch[1].replace('../backgrounds/', ''); - - const settings = fs.readFileSync('./public/settings.json', 'utf-8'); - const settingsJSON = JSON.parse(settings); - if (Object.hasOwn(settingsJSON, 'background')) { - console.log(color.yellow('Both bg_load.css and the "background" setting exist. Please delete bg_load.css manually.')); - return; - } - - settingsJSON.background = { name: bgFilename, url: `url('backgrounds/${bgFilename}')` }; - fs.writeFileSync('./public/settings.json', JSON.stringify(settingsJSON, null, 4)); - fs.rmSync('./public/css/bg_load.css'); -} - try { // 0. Convert config.conf to config.yaml convertConfig(); @@ -199,8 +175,6 @@ try { copyWasmFiles(); // 3. Add missing config values addMissingConfigValues(); - // 4. Migrate bg_load.css to settings.json - migrateBackground(); } catch (error) { console.error(error); } diff --git a/public/NovelAI Settings/.gitkeep b/public/NovelAI Settings/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/public/OpenAI Settings/.gitkeep b/public/OpenAI Settings/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/public/TextGen Settings/.gitkeep b/public/TextGen Settings/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/public/User Avatars/README.md b/public/User Avatars/README.md deleted file mode 100644 index 17c7b8bff..000000000 --- a/public/User Avatars/README.md +++ /dev/null @@ -1 +0,0 @@ -# Put images here to select them as a user persona avatar. diff --git a/public/assets/ambient/.placeholder b/public/assets/ambient/.placeholder deleted file mode 100644 index a4faa1166..000000000 --- a/public/assets/ambient/.placeholder +++ /dev/null @@ -1 +0,0 @@ -Put ambient audio files here. \ No newline at end of file diff --git a/public/assets/bgm/.placeholder b/public/assets/bgm/.placeholder deleted file mode 100644 index 95839f44e..000000000 --- a/public/assets/bgm/.placeholder +++ /dev/null @@ -1 +0,0 @@ -Put bgm audio files here diff --git a/public/assets/blip/.placeholder b/public/assets/blip/.placeholder deleted file mode 100644 index 1ebb7122e..000000000 --- a/public/assets/blip/.placeholder +++ /dev/null @@ -1 +0,0 @@ -Put blip audio files here diff --git a/public/assets/live2d/.placeholder b/public/assets/live2d/.placeholder deleted file mode 100644 index c76c79ab1..000000000 --- a/public/assets/live2d/.placeholder +++ /dev/null @@ -1 +0,0 @@ -Put live2d model folders here diff --git a/public/assets/temp/.placeholder b/public/assets/temp/.placeholder deleted file mode 100644 index e69de29bb..000000000 diff --git a/public/assets/vrm/animation/.placeholder b/public/assets/vrm/animation/.placeholder deleted file mode 100644 index c7a29571f..000000000 --- a/public/assets/vrm/animation/.placeholder +++ /dev/null @@ -1 +0,0 @@ -Put VRM animation files here diff --git a/public/assets/vrm/model/.placeholder b/public/assets/vrm/model/.placeholder deleted file mode 100644 index 14ce3cf88..000000000 --- a/public/assets/vrm/model/.placeholder +++ /dev/null @@ -1 +0,0 @@ -Put VRM model files here diff --git a/public/characters/.gitkeep b/public/characters/.gitkeep deleted file mode 100644 index 0fa956051..000000000 --- a/public/characters/.gitkeep +++ /dev/null @@ -1,8 +0,0 @@ -# Put PNG character cards here. - -To create a sprites folder, name it the same as your character (NOT the PNG file). - -For example: - -- Character: /characters/Asuka Langley.png -- Sprite: /characters/Asuka Langley/joy.png diff --git a/public/chats/.gitkeep b/public/chats/.gitkeep deleted file mode 100644 index ac488e9b9..000000000 --- a/public/chats/.gitkeep +++ /dev/null @@ -1,5 +0,0 @@ -# Put Chat JSONL files here in subfolders corresponding to character names - -For example: - -- /chats/Robot/chat.jsonl diff --git a/public/context/.gitkeep b/public/context/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/public/css/accounts.css b/public/css/accounts.css new file mode 100644 index 000000000..e5414bf59 --- /dev/null +++ b/public/css/accounts.css @@ -0,0 +1,5 @@ +.userAccount { + border: 1px solid var(--SmartThemeBorderColor); + padding: 5px 10px; + border-radius: 5px; +} diff --git a/public/css/login.css b/public/css/login.css new file mode 100644 index 000000000..d93ae1b85 --- /dev/null +++ b/public/css/login.css @@ -0,0 +1,35 @@ +body.login #shadow_popup { + opacity: 1; + display: flex; +} + +body.login .logo { + max-width: 30px; +} + +body.login #logoBlock { + align-items: center; + margin: 0 auto; + gap: 10px; +} + +body.login .userSelect { + display: flex; + flex-direction: column; + color: var(--SmartThemeBodyColor); + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 5px; + padding: 3px 5px; + width: min-content; + cursor: pointer; + margin: 5px 0; + transition: background-color 0.15s ease-in-out; + display: flex; + align-items: center; + justify-content: center; + text-align: center; +} + +body.login .userSelect:hover { + background-color: var(--black30a); +} diff --git a/public/group chats/.gitkeep b/public/group chats/.gitkeep deleted file mode 100644 index 535c6dab6..000000000 --- a/public/group chats/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# Put Group Chat JSONL files here diff --git a/public/groups/.gitkeep b/public/groups/.gitkeep deleted file mode 100644 index 0f56f2da9..000000000 --- a/public/groups/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# Put Group JSON files here diff --git a/public/img/logo.png b/public/img/logo.png new file mode 100644 index 000000000..14768e82b Binary files /dev/null and b/public/img/logo.png differ diff --git a/public/index.html b/public/index.html index 0ef12400a..378258675 100644 --- a/public/index.html +++ b/public/index.html @@ -88,6 +88,7 @@ + SillyTavern @@ -3474,7 +3475,21 @@
- +
+ + + +
+
@@ -5349,17 +5364,16 @@ Enable simple UI mode +

+ Your Persona +

- - Before you get started, you must select a user name. + + Before you get started, you must select a persona name. This can be changed at any time via the icon.
-

UI Language:

- -

User Name:

+

Persona Name:

diff --git a/public/instruct/.gitkeep b/public/instruct/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/public/login.html b/public/login.html new file mode 100644 index 000000000..5c6cd2366 --- /dev/null +++ b/public/login.html @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + SillyTavern + + + +
+
+
+
+
+

+ + Welcome to SillyTavern +

+

+ Select a User +

+

+ Enter Login Details +

+
+
+ + + +
+
+
+
+
+
+
+ + + diff --git a/public/script.js b/public/script.js index 31156c99c..f2a6466d7 100644 --- a/public/script.js +++ b/public/script.js @@ -211,6 +211,7 @@ import { loadMancerModels, loadOllamaModels, loadTogetherAIModels, loadInfermati import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId } from './scripts/chats.js'; import { initPresetManager } from './scripts/preset-manager.js'; import { evaluateMacros } from './scripts/macros.js'; +import { currentUser, setUserControls } from './scripts/user.js'; import { callGenericPopup } from './scripts/popup.js'; import { renderTemplate, renderTemplateAsync } from './scripts/templates.js'; @@ -665,13 +666,15 @@ async function getSystemMessages() { registerPromptManagerMigration(); $(document).ajaxError(function myErrorHandler(_, xhr) { + // Cohee: CSRF doesn't error out in multiple tabs anymore, so this is unnecessary + /* if (xhr.status == 403) { toastr.warning( 'doubleCsrf errors in console are NORMAL in this case. If you want to run ST in multiple tabs, start the server with --disableCsrf option.', 'Looks like you\'ve opened SillyTavern in another browser tab', { timeOut: 0, extendedTimeOut: 0, preventDuplicates: true }, ); - } + } */ }); async function getClientVersion() { @@ -1500,7 +1503,7 @@ function getCharacterSource(chId = this_chid) { } async function getCharacters() { - var response = await fetch('/api/characters/all', { + const response = await fetch('/api/characters/all', { method: 'POST', headers: getRequestHeaders(), body: JSON.stringify({ @@ -1508,11 +1511,9 @@ async function getCharacters() { }), }); if (response.ok === true) { - var getData = ''; //RossAscends: reset to force array to update to account for deleted character. - getData = await response.json(); - const load_ch_count = Object.getOwnPropertyNames(getData); - for (var i = 0; i < load_ch_count.length; i++) { - characters[i] = []; + characters.splice(0, characters.length); + const getData = await response.json(); + for (let i = 0; i < getData.length; i++) { characters[i] = getData[i]; characters[i]['name'] = DOMPurify.sanitize(characters[i]['name']); @@ -6066,7 +6067,7 @@ async function doOnboarding(avatarId) { template.find('input[name="enable_simple_mode"]').on('input', function () { simpleUiMode = $(this).is(':checked'); }); - var userName = await callPopup(template, 'input', name1); + let userName = await callPopup(template, 'input', currentUser?.name || name1); if (userName) { userName = userName.replace('\n', ' '); @@ -6120,6 +6121,8 @@ async function getSettings() { $('#your_name').val(name1); } + await setUserControls(data.enable_accounts); + // Allow subscribers to mutate settings eventSource.emit(event_types.SETTINGS_LOADED_BEFORE, settings); @@ -10137,6 +10140,7 @@ jQuery(async function () { '#character_cross', '#avatar-and-name-block', '#shadow_popup', + '.shadow_popup', '#world_popup', '.ui-widget', '.text_pole', diff --git a/public/scripts/BulkEditOverlay.js b/public/scripts/BulkEditOverlay.js index fccf12da3..26b9fc5b3 100644 --- a/public/scripts/BulkEditOverlay.js +++ b/public/scripts/BulkEditOverlay.js @@ -266,7 +266,7 @@ class BulkTagPopupHandler { printTagList($('#bulkTagList'), { tags: () => this.getMutualTags(), tagOptions: { removable: true } }); // Tag input with resolvable list for the mutual tags to get redrawn, so that newly added tags get sorted correctly - createTagInput('#bulkTagInput', '#bulkTagList', { tags: () => this.getMutualTags(), tagOptions: { removable: true }}); + createTagInput('#bulkTagInput', '#bulkTagList', { tags: () => this.getMutualTags(), tagOptions: { removable: true } }); document.querySelector('#bulk_tag_popup_reset').addEventListener('click', this.resetTags.bind(this)); document.querySelector('#bulk_tag_popup_remove_mutual').addEventListener('click', this.removeMutual.bind(this)); @@ -291,7 +291,7 @@ class BulkTagPopupHandler { // Find mutual tags for multiple characters const allTags = this.characterIds.map(cid => getTagsList(getTagKeyForEntity(cid))); const mutualTags = allTags.reduce((mutual, characterTags) => - mutual.filter(tag => characterTags.some(cTag => cTag.id === tag.id)) + mutual.filter(tag => characterTags.some(cTag => cTag.id === tag.id)), ); this.currentMutualTags = mutualTags.sort(compareTagsForSort); @@ -587,7 +587,7 @@ class BulkEditOverlay { this.container.removeEventListener('mouseup', cancelHold); this.container.removeEventListener('touchend', cancelHold); }, - BulkEditOverlay.longPressDelay); + BulkEditOverlay.longPressDelay); }; handleLongPressEnd = (event) => { @@ -694,7 +694,7 @@ class BulkEditOverlay { } else { character.classList.remove(BulkEditOverlay.selectedClass); if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false; - this.#selectedCharacters = this.#selectedCharacters.filter(item => String(characterId) !== item) + this.#selectedCharacters = this.#selectedCharacters.filter(item => String(characterId) !== item); } this.updateSelectedCount(); @@ -816,7 +816,7 @@ class BulkEditOverlay { Also delete the chat files
`; - } + }; /** * Request user input before concurrently handle deletion diff --git a/public/scripts/RossAscends-mods.js b/public/scripts/RossAscends-mods.js index b007a0d7e..a6bebf3e4 100644 --- a/public/scripts/RossAscends-mods.js +++ b/public/scripts/RossAscends-mods.js @@ -263,7 +263,7 @@ async function RA_autoloadchat() { await selectCharacterById(String(active_character_id)); // Do a little tomfoolery to spoof the tag selector - const selectedCharElement = $(`#rm_print_characters_block .character_select[chid="${active_character_id}"]`) + const selectedCharElement = $(`#rm_print_characters_block .character_select[chid="${active_character_id}"]`); applyTagsOnCharacterSelect.call(selectedCharElement); } } diff --git a/public/scripts/chats.js b/public/scripts/chats.js index 41e54fd48..0b37e9345 100644 --- a/public/scripts/chats.js +++ b/public/scripts/chats.js @@ -44,22 +44,29 @@ function isConvertible(type) { } /** - * Mark message as hidden (system message). - * @param {number} messageId Message ID - * @param {JQuery} messageBlock Message UI element - * @returns + * Mark a range of messages as hidden ("is_system") or not. + * @param {number} start Starting message ID + * @param {number} end Ending message ID (inclusive) + * @param {boolean} unhide If true, unhide the messages instead. + * @returns {Promise} */ -export async function hideChatMessage(messageId, messageBlock) { - const chatId = getCurrentChatId(); +export async function hideChatMessageRange(start, end, unhide) { + if (!getCurrentChatId()) return; - if (!chatId || isNaN(messageId)) return; + if (isNaN(start)) return; + if (!end) end = start; + const hide = !unhide; - const message = chat[messageId]; + for (let messageId = start; messageId <= end; messageId++) { + const message = chat[messageId]; + if (!message) continue; - if (!message) return; + const messageBlock = $(`.mes[mesid="${messageId}"]`); + if (!messageBlock.length) continue; - message.is_system = true; - messageBlock.attr('is_system', String(true)); + message.is_system = hide; + messageBlock.attr('is_system', String(hide)); + } // Reload swipes. Useful when a last message is hidden. hideSwipeButtons(); @@ -69,28 +76,25 @@ export async function hideChatMessage(messageId, messageBlock) { } /** - * Mark message as visible (non-system message). + * Mark message as hidden (system message). + * @deprecated Use hideChatMessageRange. * @param {number} messageId Message ID - * @param {JQuery} messageBlock Message UI element - * @returns + * @param {JQuery} _messageBlock Unused + * @returns {Promise} */ -export async function unhideChatMessage(messageId, messageBlock) { - const chatId = getCurrentChatId(); +export async function hideChatMessage(messageId, _messageBlock) { + return hideChatMessageRange(messageId, messageId, false); +} - if (!chatId || isNaN(messageId)) return; - - const message = chat[messageId]; - - if (!message) return; - - message.is_system = false; - messageBlock.attr('is_system', String(false)); - - // Reload swipes. Useful when a last message is hidden. - hideSwipeButtons(); - showSwipeButtons(); - - saveChatDebounced(); +/** + * Mark message as visible (non-system message). + * @deprecated Use hideChatMessageRange. + * @param {number} messageId Message ID + * @param {JQuery} _messageBlock Unused + * @returns {Promise} + */ +export async function unhideChatMessage(messageId, _messageBlock) { + return hideChatMessageRange(messageId, messageId, true); } /** @@ -476,13 +480,13 @@ jQuery(function () { $(document).on('click', '.mes_hide', async function () { const messageBlock = $(this).closest('.mes'); const messageId = Number(messageBlock.attr('mesid')); - await hideChatMessage(messageId, messageBlock); + await hideChatMessageRange(messageId, messageId, false); }); $(document).on('click', '.mes_unhide', async function () { const messageBlock = $(this).closest('.mes'); const messageId = Number(messageBlock.attr('mesid')); - await unhideChatMessage(messageId, messageBlock); + await hideChatMessageRange(messageId, messageId, true); }); $(document).on('click', '.mes_file_delete', async function () { diff --git a/public/scripts/loader.js b/public/scripts/loader.js index 534bd1609..91e7df196 100644 --- a/public/scripts/loader.js +++ b/public/scripts/loader.js @@ -5,16 +5,20 @@ export function showLoader() { const loader = $('
').attr('id', 'load-spinner').addClass('fa-solid fa-gear fa-spin fa-3x'); container.append(loader); $('body').append(container); - } -export function hideLoader() { +export async function hideLoader() { //Sets up a 2-step animation. Spinner blurs/fades out, and then the loader shadow does the same. $('#load-spinner').on('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function () { - //console.log('FADING BLUR SCREEN') + //uncomment this as part of user selection enabling + //$('#loader-spinner') + + //comment this instead $(`#${ELEMENT_ID}`) + //only fade out the spinner and replace with login screen .animate({ opacity: 0 }, 300, function () { - //console.log('REMOVING LOADER') + //when enabling user select, dont remove the loader container just yet + //comment this out $(`#${ELEMENT_ID}`).remove(); }); }); @@ -25,4 +29,7 @@ export function hideLoader() { 'filter': 'blur(15px)', 'opacity': '0', }); + + //uncomment to make user selection live + //await populateUserList() } diff --git a/public/scripts/login.js b/public/scripts/login.js new file mode 100644 index 000000000..a425d6aef --- /dev/null +++ b/public/scripts/login.js @@ -0,0 +1,275 @@ +/** + * CRSF token for requests. + */ +let csrfToken = ''; +let discreetLogin = false; + +/** + * Gets a CSRF token from the server. + * @returns {Promise} CSRF token + */ +async function getCsrfToken() { + const response = await fetch('/csrf-token'); + const data = await response.json(); + return data.token; +} + +/** + * Gets a list of users from the server. + * @returns {Promise} List of users + */ +async function getUserList() { + const response = await fetch('/api/users/list', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + return displayError(errorData.error || 'An error occurred'); + } + + if (response.status === 204) { + discreetLogin = true; + return []; + } + + const userListObj = await response.json(); + console.log(userListObj); + return userListObj; +} + +/** + * Requests a recovery code for the user. + * @param {string} handle User handle + * @returns {Promise} + */ +async function sendRecoveryPart1(handle) { + const response = await fetch('/api/users/recover-step1', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + }, + body: JSON.stringify({ handle }), + }); + + if (!response.ok) { + const errorData = await response.json(); + return displayError(errorData.error || 'An error occurred'); + } + + showRecoveryBlock(); +} + +/** + * Sets a new password for the user using the recovery code. + * @param {string} handle User handle + * @param {string} code Recovery code + * @param {string} newPassword New password + * @returns {Promise} + */ +async function sendRecoveryPart2(handle, code, newPassword) { + const recoveryData = { + handle, + code, + newPassword, + }; + + const response = await fetch('/api/users/recover-step2', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + }, + body: JSON.stringify(recoveryData), + }); + + if (!response.ok) { + const errorData = await response.json(); + return displayError(errorData.error || 'An error occurred'); + } + + console.log(`Successfully recovered password for ${handle}!`); + await performLogin(handle, newPassword); +} + +/** + * Attempts to log in the user. + * @param {string} handle User's handle + * @param {string} password User's password + * @returns {Promise} + */ +async function performLogin(handle, password) { + const userInfo = { + handle: handle, + password: password, + }; + + try { + const response = await fetch('/api/users/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + }, + body: JSON.stringify(userInfo), + }); + + if (!response.ok) { + const errorData = await response.json(); + return displayError(errorData.error || 'An error occurred'); + } + + const data = await response.json(); + + if (data.handle) { + console.log(`Successfully logged in as ${handle}!`); + redirectToHome(); + } + } catch (error) { + console.error('Error logging in:', error); + displayError(String(error)); + } +} + +/** + * Handles the user selection event. + * @param {object} user User object + * @returns {Promise} + */ +async function onUserSelected(user) { + // No password, just log in + if (!user.password) { + return await performLogin(user.handle, ''); + } + + $('#passwordRecoveryBlock').hide(); + $('#passwordEntryBlock').show(); + $('#loginButton').off('click').on('click', async () => { + const password = String($('#userPassword').val()); + await performLogin(user.handle, password); + }); + + $('#recoverPassword').off('click').on('click', async () => { + await sendRecoveryPart1(user.handle); + }); + + $('#sendRecovery').off('click').on('click', async () => { + const code = String($('#recoveryCode').val()); + const newPassword = String($('#newPassword').val()); + await sendRecoveryPart2(user.handle, code, newPassword); + }); + + displayError(''); +} + +/** + * Displays an error message to the user. + * @param {string} message Error message + */ +function displayError(message) { + $('#errorMessage').text(message); +} + +/** + * Redirects the user to the home page. + */ +function redirectToHome() { + window.location.href = '/'; +} + +/** + * Hides the password entry block and shows the password recovery block. + */ +function showRecoveryBlock() { + $('#passwordEntryBlock').hide(); + $('#passwordRecoveryBlock').show(); + displayError(''); +} + +/** + * Hides the password recovery block and shows the password entry block. + */ +function onCancelRecoveryClick() { + $('#passwordRecoveryBlock').hide(); + $('#passwordEntryBlock').show(); + displayError(''); +} + +/** + * Configures the login page for normal login. + * @param {import('../../src/users').UserViewModel[]} userList List of users + */ +function configureNormalLogin(userList) { + console.log('Discreet login is disabled'); + $('#handleEntryBlock').hide(); + $('#normalLoginPrompt').show(); + $('#discreetLoginPrompt').hide(); + console.log(userList); + for (const user of userList) { + const userBlock = $('
').addClass('userSelect'); + const avatarBlock = $('
').addClass('avatar'); + avatarBlock.append($('').attr('src', user.avatar)); + userBlock.append(avatarBlock); + userBlock.append($('').text(user.name)); + userBlock.append($('').text(user.handle)); + userBlock.on('click', () => onUserSelected(user)); + $('#userList').append(userBlock); + } +} + +/** + * Configures the login page for discreet login. + */ +function configureDiscreetLogin() { + console.log('Discreet login is enabled'); + $('#handleEntryBlock').show(); + $('#normalLoginPrompt').hide(); + $('#discreetLoginPrompt').show(); + $('#userList').hide(); + $('#passwordRecoveryBlock').hide(); + $('#passwordEntryBlock').show(); + $('#loginButton').off('click').on('click', async () => { + const handle = String($('#userHandle').val()); + const password = String($('#userPassword').val()); + await performLogin(handle, password); + }); + + $('#recoverPassword').off('click').on('click', async () => { + const handle = String($('#userHandle').val()); + await sendRecoveryPart1(handle); + }); + + $('#sendRecovery').off('click').on('click', async () => { + const handle = String($('#userHandle').val()); + const code = String($('#recoveryCode').val()); + const newPassword = String($('#newPassword').val()); + await sendRecoveryPart2(handle, code, newPassword); + }); +} + +(async function () { + csrfToken = await getCsrfToken(); + const userList = await getUserList(); + + if (discreetLogin) { + configureDiscreetLogin(); + } else { + configureNormalLogin(userList); + } + document.getElementById('shadow_popup').style.opacity = ''; + $('#cancelRecovery').on('click', onCancelRecoveryClick); + $(document).on('keydown', (evt) => { + if (evt.key === 'Enter' && document.activeElement.tagName === 'INPUT') { + if ($('#passwordRecoveryBlock').is(':visible')) { + $('#sendRecovery').trigger('click'); + } else { + $('#loginButton').trigger('click'); + } + } + }); +})(); diff --git a/public/scripts/logprobs.js b/public/scripts/logprobs.js index b2e682286..e87adf487 100644 --- a/public/scripts/logprobs.js +++ b/public/scripts/logprobs.js @@ -221,7 +221,7 @@ function onAlternativeClicked(tokenLogprobs, alternative) { } if (getGeneratingApi() === 'openai') { - return callPopup(`

Feature unavailable

Due to API limitations, rerolling a token is not supported with OpenAI. Try switching to a different API.

`, 'text'); + return callPopup('

Feature unavailable

Due to API limitations, rerolling a token is not supported with OpenAI. Try switching to a different API.

', 'text'); } const { messageLogprobs, continueFrom } = getActiveMessageLogprobData(); @@ -261,7 +261,7 @@ function onPrefixClicked() { function checkGenerateReady() { if (is_send_press) { - toastr.warning(`Please wait for the current generation to complete.`); + toastr.warning('Please wait for the current generation to complete.'); return false; } return true; @@ -292,13 +292,13 @@ function onToggleLogprobsPanel() { } else { logprobsViewer.addClass('resizing'); logprobsViewer.transition({ - opacity: 0.0, - duration: animation_duration, - }, - async function () { - await delay(50); - logprobsViewer.removeClass('resizing'); - }); + opacity: 0.0, + duration: animation_duration, + }, + async function () { + await delay(50); + logprobsViewer.removeClass('resizing'); + }); setTimeout(function () { logprobsViewer.hide(); }, animation_duration); @@ -407,7 +407,7 @@ export function saveLogprobsForActiveMessage(logprobs, continueFrom) { messageLogprobs: logprobs, continueFrom, hash: getMessageHash(chat[msgId]), - } + }; state.messageLogprobs.set(data.hash, data); @@ -458,7 +458,7 @@ function convertTokenIdLogprobsToText(input) { // Flatten unique token IDs across all logprobs const tokenIds = Array.from(new Set(input.flatMap(logprobs => - logprobs.topLogprobs.map(([token]) => token).concat(logprobs.token) + logprobs.topLogprobs.map(([token]) => token).concat(logprobs.token), ))); // Submit token IDs to tokenizer to get token text, then build ID->text map @@ -469,7 +469,7 @@ function convertTokenIdLogprobsToText(input) { input.forEach(logprobs => { logprobs.token = tokenIdText.get(logprobs.token); logprobs.topLogprobs = logprobs.topLogprobs.map(([token, logprob]) => - [tokenIdText.get(token), logprob] + [tokenIdText.get(token), logprob], ); }); } diff --git a/public/scripts/openai.js b/public/scripts/openai.js index 8a71b89f0..7ce8c8c37 100644 --- a/public/scripts/openai.js +++ b/public/scripts/openai.js @@ -2264,7 +2264,7 @@ export class ChatCompletion { const shouldSquash = (message) => { return !excludeList.includes(message.identifier) && message.role === 'system' && !message.name; - } + }; if (shouldSquash(message)) { if (lastMessage && shouldSquash(lastMessage)) { diff --git a/public/scripts/popup.js b/public/scripts/popup.js index b793f3a66..6a1e63b4d 100644 --- a/public/scripts/popup.js +++ b/public/scripts/popup.js @@ -219,7 +219,7 @@ export function callGenericPopup(text, type, inputValue = '', { okButton, cancel text, type, inputValue, - { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling }, + { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling, cancelButton }, ); return popup.show(); } diff --git a/public/scripts/slash-commands.js b/public/scripts/slash-commands.js index f0503c35d..d2b6717aa 100644 --- a/public/scripts/slash-commands.js +++ b/public/scripts/slash-commands.js @@ -41,7 +41,7 @@ import { SlashCommandParser as NewSlashCommandParser } from './slash-commands/Sl import { SlashCommandParserError } from './slash-commands/SlashCommandParserError.js'; import { SlashCommandExecutor } from './slash-commands/SlashCommandExecutor.js'; import { getMessageTimeStamp } from './RossAscends-mods.js'; -import { hideChatMessage, unhideChatMessage } from './chats.js'; +import { hideChatMessageRange } from './chats.js'; import { getContext, saveMetadataDebounced } from './extensions.js'; import { getRegexedString, regex_placement } from './extensions/regex/engine.js'; import { findGroupMemberId, groups, is_group_generating, openGroupById, resetSelectedGroup, saveGroupChat, selected_group } from './group-chats.js'; @@ -937,16 +937,7 @@ async function hideMessageCallback(_, arg) { return; } - for (let messageId = range.start; messageId <= range.end; messageId++) { - const messageBlock = $(`.mes[mesid="${messageId}"]`); - - if (!messageBlock.length) { - console.warn(`WARN: No message found with ID ${messageId}`); - return; - } - - await hideChatMessage(messageId, messageBlock); - } + await hideChatMessageRange(range.start, range.end, false); } async function unhideMessageCallback(_, arg) { @@ -962,17 +953,7 @@ async function unhideMessageCallback(_, arg) { return ''; } - for (let messageId = range.start; messageId <= range.end; messageId++) { - const messageBlock = $(`.mes[mesid="${messageId}"]`); - - if (!messageBlock.length) { - console.warn(`WARN: No message found with ID ${messageId}`); - return ''; - } - - await unhideChatMessage(messageId, messageBlock); - } - + await hideChatMessageRange(range.start, range.end, true); return ''; } diff --git a/public/scripts/templates/admin.html b/public/scripts/templates/admin.html new file mode 100644 index 000000000..6519e76f1 --- /dev/null +++ b/public/scripts/templates/admin.html @@ -0,0 +1,104 @@ +
+ +
+
+
+
+ avatar +
+
+
+
+ + +

+   +
+
+ + Role: + + + + Status: +   + + + Created: +   + +
+
+
+
+ + + + + +
+
+ + + +
+
+
+
+ + +
diff --git a/public/scripts/templates/changeName.html b/public/scripts/templates/changeName.html new file mode 100644 index 000000000..76f76a68d --- /dev/null +++ b/public/scripts/templates/changeName.html @@ -0,0 +1,5 @@ +
+

+ Enter a new display name: +

+
diff --git a/public/scripts/templates/changePassword.html b/public/scripts/templates/changePassword.html new file mode 100644 index 000000000..b5ed6ecab --- /dev/null +++ b/public/scripts/templates/changePassword.html @@ -0,0 +1,14 @@ +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/public/scripts/templates/deleteUser.html b/public/scripts/templates/deleteUser.html new file mode 100644 index 000000000..6932f3b99 --- /dev/null +++ b/public/scripts/templates/deleteUser.html @@ -0,0 +1,26 @@ +
+

+ Are you sure you want to delete this user? +

+
+ Deleting: + +
+ +
+
+ Warning: + This action is irreversible. +
+
+ + +
+
diff --git a/public/scripts/templates/resetSettings.html b/public/scripts/templates/resetSettings.html new file mode 100644 index 000000000..dd8eb3a57 --- /dev/null +++ b/public/scripts/templates/resetSettings.html @@ -0,0 +1,13 @@ +
+

+ Are you sure you want to reset your settings to factory defaults? +

+
+ Don't forget to save a snapshot of your settings before proceeding. +
+
+
+ Enter your password below to confirm: +
+ +
diff --git a/public/scripts/templates/snapshotsView.html b/public/scripts/templates/snapshotsView.html new file mode 100644 index 000000000..5281be319 --- /dev/null +++ b/public/scripts/templates/snapshotsView.html @@ -0,0 +1,31 @@ +
+

+ Settings Snapshots + +

+
+
+
+
+
+
+
+ +
+ + () +
+
+
+ +
+
+
+ +
+
+
+
diff --git a/public/scripts/templates/userProfile.html b/public/scripts/templates/userProfile.html new file mode 100644 index 000000000..0aa40eb0d --- /dev/null +++ b/public/scripts/templates/userProfile.html @@ -0,0 +1,86 @@ +
+
+

+ Hi, +
+ +
+

+ +
+
+

+ Account Info +

+
+
+
+ avatar +
+
+
+
+
+ Handle: + +
+
+ Role: + +
+
+
+
+ Created: + +
+
+ Password: + + +
+
+
+
+
+
+

+ Account Actions +

+
+
+ +
+
+ + +
+
+
+
+

+ Danger Zone +

+
+ + +
+
+
diff --git a/public/scripts/templates/userReset.html b/public/scripts/templates/userReset.html new file mode 100644 index 000000000..35079920d --- /dev/null +++ b/public/scripts/templates/userReset.html @@ -0,0 +1,18 @@ +
+

+ This will delete all your settings and data. There will be no undo button. + Make sure you have a backup before proceeding. +

+
+
+ Account reset code has been posted to the server console. +
+
+ + +
+
+ + +
+
diff --git a/public/scripts/user.js b/public/scripts/user.js new file mode 100644 index 000000000..c432e090b --- /dev/null +++ b/public/scripts/user.js @@ -0,0 +1,808 @@ +import { getRequestHeaders } from '../script.js'; +import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js'; +import { renderTemplateAsync } from './templates.js'; +import { humanFileSize } from './utils.js'; + +/** + * @type {import('../../src/users.js').UserViewModel} Logged in user + */ +export let currentUser = null; +export let accountsEnabled = false; + +/** + * Enable or disable user account controls in the UI. + * @param {boolean} isEnabled User account controls enabled + * @returns {Promise} + */ +export async function setUserControls(isEnabled) { + accountsEnabled = isEnabled; + + if (!isEnabled) { + $('#logout_button').hide(); + $('#admin_button').hide(); + return; + } + + $('#logout_button').show(); + await getCurrentUser(); +} + +/** + * Check if the current user is an admin. + * @returns {boolean} True if the current user is an admin + */ +function isAdmin() { + if (!currentUser) { + return false; + } + + return Boolean(currentUser.admin); +} + +/** + * Get the current user. + * @returns {Promise} + */ +async function getCurrentUser() { + try { + const response = await fetch('/api/users/me', { + headers: getRequestHeaders(), + }); + + if (!response.ok) { + throw new Error('Failed to get current user'); + } + + currentUser = await response.json(); + $('#admin_button').toggle(accountsEnabled && isAdmin()); + } catch (error) { + console.error('Error getting current user:', error); + } +} + +/** + * Get a list of all users. + * @returns {Promise} Users + */ +async function getUsers() { + try { + const response = await fetch('/api/users/get', { + method: 'POST', + headers: getRequestHeaders(), + }); + + if (!response.ok) { + throw new Error('Failed to get users'); + } + + return response.json(); + } catch (error) { + console.error('Error getting users:', error); + } +} + +/** + * Enable a user account. + * @param {string} handle User handle + * @param {function} callback Success callback + * @returns {Promise} + */ +async function enableUser(handle, callback) { + try { + const response = await fetch('/api/users/enable', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ handle }), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to enable user'); + throw new Error('Failed to enable user'); + } + + callback(); + } catch (error) { + console.error('Error enabling user:', error); + } +} + +async function disableUser(handle, callback) { + try { + const response = await fetch('/api/users/disable', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ handle }), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data?.error || 'Unknown error', 'Failed to disable user'); + throw new Error('Failed to disable user'); + } + + callback(); + } catch (error) { + console.error('Error disabling user:', error); + } +} + +/** + * Promote a user to admin. + * @param {string} handle User handle + * @param {function} callback Success callback + * @returns {Promise} + */ +async function promoteUser(handle, callback) { + try { + const response = await fetch('/api/users/promote', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ handle }), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to promote user'); + throw new Error('Failed to promote user'); + } + + callback(); + } catch (error) { + console.error('Error promoting user:', error); + } +} + +/** + * Demote a user from admin. + * @param {string} handle User handle + * @param {function} callback Success callback + */ +async function demoteUser(handle, callback) { + try { + const response = await fetch('/api/users/demote', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ handle }), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to demote user'); + throw new Error('Failed to demote user'); + } + + callback(); + } catch (error) { + console.error('Error demoting user:', error); + } +} + +/** + * Create a new user. + * @param {HTMLFormElement} form Form element + */ +async function createUser(form, callback) { + const errors = []; + const formData = new FormData(form); + + if (!formData.get('handle')) { + errors.push('Handle is required'); + } + + if (formData.get('password') !== formData.get('confirm')) { + errors.push('Passwords do not match'); + } + + if (errors.length) { + toastr.error(errors.join(', '), 'Failed to create user'); + return; + } + + const body = {}; + formData.forEach(function (value, key) { + if (key === 'confirm') { + return; + } + if (key.startsWith('_')) { + key = key.substring(1); + } + body[key] = value; + }); + + try { + const response = await fetch('/api/users/create', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify(body), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to create user'); + throw new Error('Failed to create user'); + } + + form.reset(); + callback(); + } catch (error) { + console.error('Error creating user:', error); + } +} + +/** + * Backup a user's data. + * @param {string} handle Handle of the user to backup + * @param {function} callback Success callback + * @returns {Promise} + */ +async function backupUserData(handle, callback) { + try { + toastr.info('Please wait for the download to start.', 'Backup Requested'); + const response = await fetch('/api/users/backup', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ handle }), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to backup user data'); + throw new Error('Failed to backup user data'); + } + + const blob = await response.blob(); + const header = response.headers.get('Content-Disposition'); + const parts = header.split(';'); + const filename = parts[1].split('=')[1].replaceAll('"', ''); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + callback(); + } catch (error) { + console.error('Error backing up user data:', error); + } +} + +/** + * Shows a popup to change a user's password. + * @param {string} handle User handle + * @param {function} callback Success callback + */ +async function changePassword(handle, callback) { + try { + const template = $(await renderTemplateAsync('changePassword')); + template.find('.currentPasswordBlock').toggle(!isAdmin()); + let newPassword = ''; + let confirmPassword = ''; + let oldPassword = ''; + template.find('input[name="current"]').on('input', function () { + oldPassword = String($(this).val()); + }); + template.find('input[name="password"]').on('input', function () { + newPassword = String($(this).val()); + }); + template.find('input[name="confirm"]').on('input', function () { + confirmPassword = String($(this).val()); + }); + const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { okButton: 'Change', cancelButton: 'Cancel', wide: false, large: false }); + if (result === POPUP_RESULT.CANCELLED || result === POPUP_RESULT.NEGATIVE) { + throw new Error('Change password cancelled'); + } + + if (newPassword !== confirmPassword) { + toastr.error('Passwords do not match', 'Failed to change password'); + throw new Error('Passwords do not match'); + } + + const response = await fetch('/api/users/change-password', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ handle, newPassword, oldPassword }), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to change password'); + throw new Error('Failed to change password'); + } + + toastr.success('Password changed successfully', 'Password Changed'); + callback(); + } + catch (error) { + console.error('Error changing password:', error); + } +} + +/** + * Delete a user. + * @param {string} handle User handle + * @param {function} callback Success callback + */ +async function deleteUser(handle, callback) { + try { + if (handle === currentUser.handle) { + toastr.error('Cannot delete yourself', 'Failed to delete user'); + throw new Error('Cannot delete yourself'); + } + + let purge = false; + let confirmHandle = ''; + + const template = $(await renderTemplateAsync('deleteUser')); + template.find('#deleteUserName').text(handle); + template.find('input[name="deleteUserData"]').on('input', function () { + purge = $(this).is(':checked'); + }); + template.find('input[name="deleteUserHandle"]').on('input', function () { + confirmHandle = String($(this).val()); + }); + + const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel', wide: false, large: false }); + + if (result !== POPUP_RESULT.AFFIRMATIVE) { + throw new Error('Delete user cancelled'); + } + + if (handle !== confirmHandle) { + toastr.error('Handles do not match', 'Failed to delete user'); + throw new Error('Handles do not match'); + } + + const response = await fetch('/api/users/delete', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ handle, purge }), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to delete user'); + throw new Error('Failed to delete user'); + } + + toastr.success('User deleted successfully', 'User Deleted'); + callback(); + } catch (error) { + console.error('Error deleting user:', error); + } +} + +/** + * Reset a user's settings. + * @param {string} handle User handle + * @param {function} callback Success callback + */ +async function resetSettings(handle, callback) { + try { + let password = ''; + const template = $(await renderTemplateAsync('resetSettings')); + template.find('input[name="password"]').on('input', function () { + password = String($(this).val()); + }); + const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { okButton: 'Reset', cancelButton: 'Cancel', wide: false, large: false }); + + if (result !== POPUP_RESULT.AFFIRMATIVE) { + throw new Error('Reset settings cancelled'); + } + + const response = await fetch('/api/users/reset-settings', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ handle, password }), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to reset settings'); + throw new Error('Failed to reset settings'); + } + + toastr.success('Settings reset successfully', 'Settings Reset'); + callback(); + } catch (error) { + console.error('Error resetting settings:', error); + } +} + +/** + * Change a user's display name. + * @param {string} handle User handle + * @param {string} name Current name + * @param {function} callback Success callback + */ +async function changeName(handle, name, callback) { + try { + const template = $(await renderTemplateAsync('changeName')); + const result = await callGenericPopup(template, POPUP_TYPE.INPUT, name, { okButton: 'Change', cancelButton: 'Cancel', wide: false, large: false }); + + if (!result) { + throw new Error('Change name cancelled'); + } + + name = String(result); + + const response = await fetch('/api/users/change-name', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ handle, name }), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to change name'); + throw new Error('Failed to change name'); + } + + toastr.success('Name changed successfully', 'Name Changed'); + callback(); + + } catch (error) { + console.error('Error changing name:', error); + } +} + +/** + * Restore a settings snapshot. + * @param {string} name Snapshot name + * @param {function} callback Success callback + */ +async function restoreSnapshot(name, callback) { + try { + const confirm = await callGenericPopup( + `Are you sure you want to restore the settings from "${name}"?`, + POPUP_TYPE.CONFIRM, + '', + { okButton: 'Restore', cancelButton: 'Cancel', wide: false, large: false }, + ); + + if (confirm !== POPUP_RESULT.AFFIRMATIVE) { + throw new Error('Restore snapshot cancelled'); + } + + const response = await fetch('/api/settings/restore-snapshot', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ name }), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to restore snapshot'); + throw new Error('Failed to restore snapshot'); + } + + callback(); + } catch (error) { + console.error('Error restoring snapshot:', error); + } + +} + +/** + * Load the content of a settings snapshot. + * @param {string} name Snapshot name + * @returns {Promise} Snapshot content + */ +async function loadSnapshotContent(name) { + try { + const response = await fetch('/api/settings/load-snapshot', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ name }), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to load snapshot content'); + throw new Error('Failed to load snapshot content'); + } + + return response.text(); + } catch (error) { + console.error('Error loading snapshot content:', error); + } +} + +/** + * Gets a list of settings snapshots. + * @returns {Promise} List of snapshots + * @typedef {Object} Snapshot + * @property {string} name Snapshot name + * @property {number} date Date in milliseconds + * @property {number} size File size in bytes + */ +async function getSnapshots() { + try { + const response = await fetch('/api/settings/get-snapshots', { + method: 'POST', + headers: getRequestHeaders(), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to get settings snapshots'); + throw new Error('Failed to get settings snapshots'); + } + + const snapshots = await response.json(); + return snapshots; + } catch (error) { + console.error('Error getting settings snapshots:', error); + return []; + } +} + +/** + * Make a snapshot of the current settings. + * @param {function} callback Success callback + * @returns {Promise} + */ +async function makeSnapshot(callback) { + try { + const response = await fetch('/api/settings/make-snapshot', { + method: 'POST', + headers: getRequestHeaders(), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to make snapshot'); + throw new Error('Failed to make snapshot'); + } + + toastr.success('Snapshot created successfully', 'Snapshot Created'); + callback(); + } catch (error) { + console.error('Error making snapshot:', error); + } +} + +/** + * Open the settings snapshots view. + */ +async function viewSettingsSnapshots() { + const template = $(await renderTemplateAsync('snapshotsView')); + async function renderSnapshots() { + const snapshots = await getSnapshots(); + template.find('.snapshotList').empty(); + + for (const snapshot of snapshots.sort((a, b) => b.date - a.date)) { + const snapshotBlock = template.find('.snapshotTemplate .snapshot').clone(); + snapshotBlock.find('.snapshotName').text(snapshot.name); + snapshotBlock.find('.snapshotDate').text(new Date(snapshot.date).toLocaleString()); + snapshotBlock.find('.snapshotSize').text(humanFileSize(snapshot.size)); + snapshotBlock.find('.snapshotRestoreButton').on('click', async (e) => { + e.stopPropagation(); + restoreSnapshot(snapshot.name, () => location.reload()); + }); + snapshotBlock.find('.inline-drawer-toggle').on('click', async () => { + const contentBlock = snapshotBlock.find('.snapshotContent'); + if (!contentBlock.val()) { + const content = await loadSnapshotContent(snapshot.name); + contentBlock.val(content); + } + + }); + template.find('.snapshotList').append(snapshotBlock); + } + } + + callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: false, large: false }); + template.find('.makeSnapshotButton').on('click', () => makeSnapshot(renderSnapshots)); + renderSnapshots(); +} + +/** + * Reset everything to default. + * @param {function} callback Success callback + */ +async function resetEverything(callback) { + try { + const step1Response = await fetch('/api/users/reset-step1', { + method: 'POST', + headers: getRequestHeaders(), + }); + + if (!step1Response.ok) { + const data = await step1Response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to reset'); + throw new Error('Failed to reset everything'); + } + + let password = ''; + let code = ''; + + const template = $(await renderTemplateAsync('userReset')); + template.find('input[name="password"]').on('input', function () { + password = String($(this).val()); + }); + template.find('input[name="code"]').on('input', function () { + code = String($(this).val()); + }); + const confirm = await callGenericPopup( + template, + POPUP_TYPE.CONFIRM, + '', + { okButton: 'Reset', cancelButton: 'Cancel', wide: false, large: false }, + ); + + if (confirm !== POPUP_RESULT.AFFIRMATIVE) { + throw new Error('Reset everything cancelled'); + } + + const step2Response = await fetch('/api/users/reset-step2', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ password, code }), + }); + + if (!step2Response.ok) { + const data = await step2Response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to reset'); + throw new Error('Failed to reset everything'); + } + + toastr.success('Everything reset successfully', 'Reset Everything'); + callback(); + } catch (error) { + console.error('Error resetting everything:', error); + } + +} + +async function openUserProfile() { + await getCurrentUser(); + const template = $(await renderTemplateAsync('userProfile')); + template.find('.userName').text(currentUser.name); + template.find('.userHandle').text(currentUser.handle); + template.find('.avatar img').attr('src', currentUser.avatar); + template.find('.userRole').text(currentUser.admin ? 'Admin' : 'User'); + template.find('.userCreated').text(new Date(currentUser.created).toLocaleString()); + template.find('.hasPassword').toggle(currentUser.password); + template.find('.noPassword').toggle(!currentUser.password); + template.find('.userSettingsSnapshotsButton').on('click', () => viewSettingsSnapshots()); + template.find('.userChangeNameButton').on('click', async () => changeName(currentUser.handle, currentUser.name, async () => { + await getCurrentUser(); + template.find('.userName').text(currentUser.name); + })); + template.find('.userChangePasswordButton').on('click', () => changePassword(currentUser.handle, async () => { + await getCurrentUser(); + template.find('.hasPassword').toggle(currentUser.password); + template.find('.noPassword').toggle(!currentUser.password); + })); + template.find('.userBackupButton').on('click', function () { + $(this).addClass('disabled'); + backupUserData(currentUser.handle, () => { + $(this).removeClass('disabled'); + }); + }); + template.find('.userResetSettingsButton').on('click', () => resetSettings(currentUser.handle, () => location.reload())); + template.find('.userResetAllButton').on('click', () => resetEverything(() => location.reload())); + + if (!accountsEnabled) { + template.find('[data-require-accounts]').hide(); + template.find('.accountsDisabledHint').show(); + } + + const popupOptions = { + okButton: 'Close', + wide: false, + large: false, + allowVerticalScrolling: true, + allowHorizontalScrolling: false, + }; + callGenericPopup(template, POPUP_TYPE.TEXT, '', popupOptions); +} + +async function openAdminPanel() { + async function renderUsers() { + const users = await getUsers(); + template.find('.usersList').empty(); + for (const user of users) { + const userBlock = template.find('.userAccountTemplate .userAccount').clone(); + userBlock.find('.userName').text(user.name); + userBlock.find('.userHandle').text(user.handle); + userBlock.find('.userStatus').text(user.enabled ? 'Enabled' : 'Disabled'); + userBlock.find('.userRole').text(user.admin ? 'Admin' : 'User'); + userBlock.find('.avatar img').attr('src', user.avatar); + userBlock.find('.hasPassword').toggle(user.password); + userBlock.find('.noPassword').toggle(!user.password); + userBlock.find('.userCreated').text(new Date(user.created).toLocaleString()); + userBlock.find('.userEnableButton').toggle(!user.enabled).on('click', () => enableUser(user.handle, renderUsers)); + userBlock.find('.userDisableButton').toggle(user.enabled).on('click', () => disableUser(user.handle, renderUsers)); + userBlock.find('.userPromoteButton').toggle(!user.admin).on('click', () => promoteUser(user.handle, renderUsers)); + userBlock.find('.userDemoteButton').toggle(user.admin).on('click', () => demoteUser(user.handle, renderUsers)); + userBlock.find('.userChangePasswordButton').on('click', () => changePassword(user.handle, renderUsers)); + userBlock.find('.userDelete').on('click', () => deleteUser(user.handle, renderUsers)); + userBlock.find('.userChangeNameButton').on('click', async () => changeName(user.handle, user.name, renderUsers)); + userBlock.find('.userBackupButton').on('click', function () { + $(this).addClass('disabled').off('click'); + backupUserData(user.handle, renderUsers); + }); + template.find('.usersList').append(userBlock); + } + } + + const template = $(await renderTemplateAsync('admin')); + + template.find('.adminNav > button').on('click', function () { + const target = String($(this).data('target-tab')); + template.find('.navTab').each(function () { + $(this).toggle(this.classList.contains(target)); + }); + }); + + template.find('.createUserDisplayName').on('input', async function () { + const slug = await slugify(String($(this).val())); + template.find('.createUserHandle').val(slug); + }); + + template.find('.userCreateForm').on('submit', function (event) { + if (!(event.target instanceof HTMLFormElement)) { + return; + } + + event.preventDefault(); + createUser(event.target, () => { + template.find('.manageUsersButton').trigger('click'); + renderUsers(); + }); + }); + + callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: false, large: false, allowVerticalScrolling: true, allowHorizontalScrolling: false }); + renderUsers(); +} + +/** + * Log out the current user. + * @returns {Promise} + */ +async function logout() { + await fetch('/api/users/logout', { + method: 'POST', + headers: getRequestHeaders(), + }); + + window.location.reload(); +} + +/** + * Runs a text through the slugify API endpoint. + * @param {string} text Text to slugify + * @returns {Promise} Slugified text + */ +async function slugify(text) { + try { + const response = await fetch('/api/users/slugify', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ text }), + }); + + if (!response.ok) { + throw new Error('Failed to slugify text'); + } + + return response.text(); + } catch (error) { + console.error('Error slugifying text:', error); + return text; + } +} + +jQuery(() => { + $('#logout_button').on('click', () => { + logout(); + }); + $('#admin_button').on('click', () => { + openAdminPanel(); + }); + $('#account_button').on('click', () => { + openUserProfile(); + }); +}); diff --git a/public/style.css b/public/style.css index 6c3bb2b08..cacb948f8 100644 --- a/public/style.css +++ b/public/style.css @@ -5,6 +5,7 @@ @import url(css/character-group-overlay.css); @import url(css/file-form.css); @import url(css/logprobs.css); +@import url(css/accounts.css); :root { --doc-height: 100%; @@ -456,7 +457,7 @@ body.reduced-motion #bg_custom { } #bg1 { - background-image: url('backgrounds/__transparent.png'); + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='); z-index: -3; } @@ -3498,7 +3499,7 @@ a { } #ui_language_select { - width: 10em; + width: 8em; } #extensions_settings .inline-drawer-toggle.inline-drawer-header:hover, diff --git a/public/themes/.gitkeep b/public/themes/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/public/themes/Default (Dark) 1.7.1.json b/public/themes/Default (Dark) 1.7.1.json deleted file mode 100644 index 2d31cb473..000000000 --- a/public/themes/Default (Dark) 1.7.1.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "Default (Dark) 1.7.1", - "blur_strength": 10, - "main_text_color": "rgba(220, 220, 210, 1)", - "italics_text_color": "rgba(145, 145, 145, 1)", - "quote_text_color": "rgba(225, 138, 36, 1)", - "blur_tint_color": "rgba(23, 23, 23, 1)", - "user_mes_blur_tint_color": "rgba(0, 0, 0, 0.9)", - "bot_mes_blur_tint_color": "rgba(0, 0, 0, 0.9)", - "shadow_color": "rgba(0, 0, 0, 1)", - "shadow_width": 2, - "font_scale": 1, - "fast_ui_mode": false, - "waifuMode": false, - "avatar_style": 0, - "chat_display": 0, - "noShadows": true, - "sheld_width": 0, - "timer_enabled": false, - "hotswap_enabled": true -} diff --git a/public/themes/Ross v2.json b/public/themes/Ross v2.json deleted file mode 100644 index 1634c4922..000000000 --- a/public/themes/Ross v2.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "Ross v2", - "blur_strength": 10, - "main_text_color": "rgba(230, 230, 220, 1)", - "italics_text_color": "rgba(145, 145, 145, 1)", - "quote_text_color": "rgba(73, 179, 255, 0.91)", - "blur_tint_color": "rgba(0, 0, 0, 0.5)", - "user_mes_blur_tint_color": "rgba(51, 51, 51, 0.2)", - "bot_mes_blur_tint_color": "rgba(97, 97, 97, 0.43)", - "shadow_color": "rgba(0, 0, 0, 0.5)", - "shadow_width": 2, - "font_scale": 0.95, - "fast_ui_mode": false, - "waifuMode": false, - "avatar_style": 1, - "chat_display": 1, - "noShadows": false, - "sheld_width": 1, - "timer_enabled": true, - "hotswap_enabled": true -} \ No newline at end of file diff --git a/public/user/.gitkeep b/public/user/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/public/worlds/README.md b/public/worlds/README.md deleted file mode 100644 index 4e5570780..000000000 --- a/public/worlds/README.md +++ /dev/null @@ -1 +0,0 @@ -# Put World Info / Lorebook JSON files here diff --git a/recover.js b/recover.js new file mode 100644 index 000000000..938b44153 --- /dev/null +++ b/recover.js @@ -0,0 +1,62 @@ +const yaml = require('yaml'); +const fs = require('fs'); +const storage = require('node-persist'); +const users = require('./src/users'); + +const userAccount = process.argv[2]; +const userPassword = process.argv[3]; + +if (!userAccount) { + console.error('A tool for recovering lost SillyTavern accounts. Uses a "dataRoot" setting from config.yaml file.'); + console.error('Usage: node recover.js [account] (password)'); + console.error('Example: node recover.js admin password'); + process.exit(1); +} + +async function initStorage() { + const config = yaml.parse(fs.readFileSync('config.yaml', 'utf8')); + const dataRoot = config.dataRoot; + + if (!dataRoot) { + console.error('No "dataRoot" setting found in config.yaml file.'); + process.exit(1); + } + + await users.initUserStorage(dataRoot); +} + +async function main() { + await initStorage(); + + /** + * @type {import('./src/users').User} + */ + const user = await storage.get(users.toKey(userAccount)); + + if (!user) { + console.error(`User "${userAccount}" not found.`); + process.exit(1); + } + + if (!user.enabled) { + console.log('User is disabled. Enabling...'); + user.enabled = true; + } + + if (userPassword) { + console.log('Setting new password...'); + const salt = users.getPasswordSalt(); + const passwordHash = users.getPasswordHash(userPassword, salt); + user.password = passwordHash; + user.salt = salt; + } else { + console.log('Setting an empty password...'); + user.password = ''; + user.salt = ''; + } + + await storage.setItem(users.toKey(userAccount), user); + console.log('User recovered. A program will exit now.'); +} + +main(); diff --git a/server.js b/server.js index 81c1a3119..cb14b3c7e 100644 --- a/server.js +++ b/server.js @@ -1,7 +1,6 @@ #!/usr/bin/env node // native node modules -const crypto = require('crypto'); const fs = require('fs'); const http = require('http'); const https = require('https'); @@ -19,8 +18,10 @@ const doubleCsrf = require('csrf-csrf').doubleCsrf; const express = require('express'); const compression = require('compression'); const cookieParser = require('cookie-parser'); +const cookieSession = require('cookie-session'); const multer = require('multer'); const responseTime = require('response-time'); +const helmet = require('helmet').default; // net related library imports const net = require('net'); @@ -33,6 +34,7 @@ util.inspect.defaultOptions.maxStringLength = null; util.inspect.defaultOptions.depth = 4; // local library imports +const userModule = require('./src/users'); const basicAuthMiddleware = require('./src/middleware/basicAuth'); const whitelistMiddleware = require('./src/middleware/whitelist'); const contentManager = require('./src/endpoints/content-manager'); @@ -60,6 +62,7 @@ const DEFAULT_PORT = 8000; const DEFAULT_AUTORUN = false; const DEFAULT_LISTEN = false; const DEFAULT_CORS_PROXY = false; +const DEFAULT_WHITELIST = true; const cliArguments = yargs(hideBin(process.argv)) .usage('Usage: [options]') @@ -95,6 +98,14 @@ const cliArguments = yargs(hideBin(process.argv)) type: 'string', default: 'certs/privkey.pem', describe: 'Path to your private key file.', + }).option('whitelist', { + type: 'boolean', + default: null, + describe: 'Enables whitelist mode', + }).option('dataRoot', { + type: 'string', + default: null, + describe: 'Root directory for data storage', }).parseSync(); // change all relative paths @@ -103,6 +114,9 @@ const serverDirectory = __dirname; process.chdir(serverDirectory); const app = express(); +app.use(helmet({ + contentSecurityPolicy: false, +})); app.use(compression()); app.use(responseTime()); @@ -110,9 +124,12 @@ const server_port = cliArguments.port ?? process.env.SILLY_TAVERN_PORT ?? getCon const autorun = (cliArguments.autorun ?? getConfigValue('autorun', DEFAULT_AUTORUN)) && !cliArguments.ssl; const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN); const enableCorsProxy = cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', DEFAULT_CORS_PROXY); +const enableWhitelist = cliArguments.whitelist ?? getConfigValue('whitelistMode', DEFAULT_WHITELIST); +const dataRoot = cliArguments.dataRoot ?? getConfigValue('dataRoot', './data'); const basicAuthMode = getConfigValue('basicAuthMode', false); +const enableAccounts = getConfigValue('enableUserAccounts', false); -const { DIRECTORIES, UPLOADS_PATH } = require('./src/constants'); +const { UPLOADS_PATH } = require('./src/constants'); // CORS Settings // const CORS = cors({ @@ -124,41 +141,7 @@ app.use(CORS); if (listen && basicAuthMode) app.use(basicAuthMiddleware); -app.use(whitelistMiddleware(listen)); - -// CSRF Protection // -if (!cliArguments.disableCsrf) { - const CSRF_SECRET = crypto.randomBytes(8).toString('hex'); - const COOKIES_SECRET = crypto.randomBytes(8).toString('hex'); - - const { generateToken, doubleCsrfProtection } = doubleCsrf({ - getSecret: () => CSRF_SECRET, - cookieName: 'X-CSRF-Token', - cookieOptions: { - httpOnly: true, - sameSite: 'strict', - secure: false, - }, - size: 64, - getTokenFromRequest: (req) => req.headers['x-csrf-token'], - }); - - app.get('/csrf-token', (req, res) => { - res.json({ - 'token': generateToken(res, req), - }); - }); - - app.use(cookieParser(COOKIES_SECRET)); - app.use(doubleCsrfProtection); -} else { - console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n'); - app.get('/csrf-token', (req, res) => { - res.json({ - 'token': 'disabled', - }); - }); -} +app.use(whitelistMiddleware(enableWhitelist, listen)); if (enableCorsProxy) { const bodyParser = require('body-parser'); @@ -210,34 +193,94 @@ if (enableCorsProxy) { }); } +app.use(cookieSession({ + name: userModule.getCookieSessionName(), + sameSite: 'strict', + httpOnly: true, + maxAge: 24 * 60 * 60 * 1000, // 24 hours + secret: userModule.getCookieSecret(), +})); + +app.use(userModule.setUserDataMiddleware); + +// CSRF Protection // +if (!cliArguments.disableCsrf) { + const COOKIES_SECRET = userModule.getCookieSecret(); + + const { generateToken, doubleCsrfProtection } = doubleCsrf({ + getSecret: userModule.getCsrfSecret, + cookieName: 'X-CSRF-Token', + cookieOptions: { + httpOnly: true, + sameSite: 'strict', + secure: false, + }, + size: 64, + getTokenFromRequest: (req) => req.headers['x-csrf-token'], + }); + + app.get('/csrf-token', (req, res) => { + res.json({ + 'token': generateToken(res, req), + }); + }); + + app.use(cookieParser(COOKIES_SECRET)); + app.use(doubleCsrfProtection); +} else { + console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n'); + app.get('/csrf-token', (req, res) => { + res.json({ + 'token': 'disabled', + }); + }); +} + +// Static files +// Host index page +app.get('/', (request, response) => { + if (userModule.shouldRedirectToLogin(request)) { + return response.redirect('/login'); + } + + return response.sendFile('index.html', { root: path.join(process.cwd(), 'public') }); +}); + +// Host login page +app.get('/login', async (request, response) => { + if (!enableAccounts) { + console.log('User accounts are disabled. Redirecting to index page.'); + return response.redirect('/'); + } + + const autoLogin = await userModule.tryAutoLogin(request); + + if (autoLogin) { + return response.redirect('/'); + } + + return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') }); +}); + +// Host frontend assets app.use(express.static(process.cwd() + '/public', {})); -app.use('/backgrounds', (req, res) => { - const filePath = decodeURIComponent(path.join(process.cwd(), DIRECTORIES.backgrounds, req.url.replace(/%20/g, ' '))); - fs.readFile(filePath, (err, data) => { - if (err) { - res.status(404).send('File not found'); - return; - } - //res.contentType('image/jpeg'); - res.send(data); - }); -}); +// Public API +app.use('/api/users', require('./src/endpoints/users-public').router); -app.use('/characters', (req, res) => { - const filePath = decodeURIComponent(path.join(process.cwd(), DIRECTORIES.characters, req.url.replace(/%20/g, ' '))); - fs.readFile(filePath, (err, data) => { - if (err) { - res.status(404).send('File not found'); - return; - } - res.send(data); - }); -}); +// Everything below this line requires authentication +app.use(userModule.requireLoginMiddleware); + +// File uploads app.use(multer({ dest: UPLOADS_PATH, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); -app.get('/', function (request, response) { - response.sendFile(process.cwd() + '/public/index.html'); -}); + +// User data mount +app.use('/', userModule.router); +// Private endpoints +app.use('/api/users', require('./src/endpoints/users-private').router); +// Admin endpoints +app.use('/api/users', require('./src/endpoints/users-admin').router); + app.get('/version', async function (_, response) { const data = await getVersion(); response.send(data); @@ -488,10 +531,17 @@ const setupTasks = async function () { // TODO: do endpoint init functions depend on certain directories existing or not existing? They should be callable // in any order for encapsulation reasons, but right now it's unknown if that would break anything. + await userModule.initUserStorage(dataRoot); + + if (listen && !basicAuthMode && enableAccounts) { + await userModule.checkAccountsProtection(); + } + await settingsEndpoint.init(); - ensurePublicDirectoriesExist(); + const directories = await userModule.ensurePublicDirectoriesExist(); + await userModule.migrateUserData(); + await contentManager.checkForNewContent(directories); await ensureThumbnailCache(); - contentManager.checkForNewContent(); cleanUploads(); await loadTokenizers(); @@ -551,7 +601,7 @@ async function loadPlugins() { } } -if (listen && !getConfigValue('whitelistMode', true) && !basicAuthMode) { +if (listen && !enableWhitelist && !basicAuthMode) { if (getConfigValue('securityOverride', false)) { console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.')); } @@ -579,11 +629,3 @@ if (cliArguments.ssl) { setupTasks, ); } - -function ensurePublicDirectoriesExist() { - for (const dir of Object.values(DIRECTORIES)) { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - } -} diff --git a/src/additional-headers.js b/src/additional-headers.js index 4ac30d25c..aa151011e 100644 --- a/src/additional-headers.js +++ b/src/additional-headers.js @@ -2,8 +2,13 @@ const { TEXTGEN_TYPES, OPENROUTER_HEADERS } = require('./constants'); const { SECRET_KEYS, readSecret } = require('./endpoints/secrets'); const { getConfigValue } = require('./util'); -function getMancerHeaders() { - const apiKey = readSecret(SECRET_KEYS.MANCER); +/** + * Gets the headers for the Mancer API. + * @param {import('./users').UserDirectoryList} directories User directories + * @returns {object} Headers for the request + */ +function getMancerHeaders(directories) { + const apiKey = readSecret(directories, SECRET_KEYS.MANCER); return apiKey ? ({ 'X-API-KEY': apiKey, @@ -11,39 +16,64 @@ function getMancerHeaders() { }) : {}; } -function getTogetherAIHeaders() { - const apiKey = readSecret(SECRET_KEYS.TOGETHERAI); +/** + * Gets the headers for the TogetherAI API. + * @param {import('./users').UserDirectoryList} directories User directories + * @returns {object} Headers for the request + */ +function getTogetherAIHeaders(directories) { + const apiKey = readSecret(directories, SECRET_KEYS.TOGETHERAI); return apiKey ? ({ 'Authorization': `Bearer ${apiKey}`, }) : {}; } -function getInfermaticAIHeaders() { - const apiKey = readSecret(SECRET_KEYS.INFERMATICAI); +/** + * Gets the headers for the InfermaticAI API. + * @param {import('./users').UserDirectoryList} directories User directories + * @returns {object} Headers for the request + */ +function getInfermaticAIHeaders(directories) { + const apiKey = readSecret(directories, SECRET_KEYS.INFERMATICAI); return apiKey ? ({ 'Authorization': `Bearer ${apiKey}`, }) : {}; } -function getDreamGenHeaders() { - const apiKey = readSecret(SECRET_KEYS.DREAMGEN); +/** + * Gets the headers for the DreamGen API. + * @param {import('./users').UserDirectoryList} directories User directories + * @returns {object} Headers for the request + */ +function getDreamGenHeaders(directories) { + const apiKey = readSecret(directories, SECRET_KEYS.DREAMGEN); return apiKey ? ({ 'Authorization': `Bearer ${apiKey}`, }) : {}; } -function getOpenRouterHeaders() { - const apiKey = readSecret(SECRET_KEYS.OPENROUTER); +/** + * Gets the headers for the OpenRouter API. + * @param {import('./users').UserDirectoryList} directories User directories + * @returns {object} Headers for the request + */ +function getOpenRouterHeaders(directories) { + const apiKey = readSecret(directories, SECRET_KEYS.OPENROUTER); const baseHeaders = { ...OPENROUTER_HEADERS }; return apiKey ? Object.assign(baseHeaders, { 'Authorization': `Bearer ${apiKey}` }) : baseHeaders; } -function getAphroditeHeaders() { - const apiKey = readSecret(SECRET_KEYS.APHRODITE); +/** + * Gets the headers for the Aphrodite API. + * @param {import('./users').UserDirectoryList} directories User directories + * @returns {object} Headers for the request + */ +function getAphroditeHeaders(directories) { + const apiKey = readSecret(directories, SECRET_KEYS.APHRODITE); return apiKey ? ({ 'X-API-KEY': apiKey, @@ -51,8 +81,13 @@ function getAphroditeHeaders() { }) : {}; } -function getTabbyHeaders() { - const apiKey = readSecret(SECRET_KEYS.TABBY); +/** + * Gets the headers for the Tabby API. + * @param {import('./users').UserDirectoryList} directories User directories + * @returns {object} Headers for the request + */ +function getTabbyHeaders(directories) { + const apiKey = readSecret(directories, SECRET_KEYS.TABBY); return apiKey ? ({ 'x-api-key': apiKey, @@ -60,24 +95,39 @@ function getTabbyHeaders() { }) : {}; } -function getLlamaCppHeaders() { - const apiKey = readSecret(SECRET_KEYS.LLAMACPP); +/** + * Gets the headers for the LlamaCPP API. + * @param {import('./users').UserDirectoryList} directories User directories + * @returns {object} Headers for the request + */ +function getLlamaCppHeaders(directories) { + const apiKey = readSecret(directories, SECRET_KEYS.LLAMACPP); return apiKey ? ({ 'Authorization': `Bearer ${apiKey}`, }) : {}; } -function getOobaHeaders() { - const apiKey = readSecret(SECRET_KEYS.OOBA); +/** + * Gets the headers for the Ooba API. + * @param {import('./users').UserDirectoryList} directories + * @returns {object} Headers for the request + */ +function getOobaHeaders(directories) { + const apiKey = readSecret(directories, SECRET_KEYS.OOBA); return apiKey ? ({ 'Authorization': `Bearer ${apiKey}`, }) : {}; } -function getKoboldCppHeaders() { - const apiKey = readSecret(SECRET_KEYS.KOBOLDCPP); +/** + * Gets the headers for the KoboldCpp API. + * @param {import('./users').UserDirectoryList} directories + * @returns {object} Headers for the request + */ +function getKoboldCppHeaders(directories) { + const apiKey = readSecret(directories, SECRET_KEYS.KOBOLDCPP); return apiKey ? ({ 'Authorization': `Bearer ${apiKey}`, @@ -96,7 +146,7 @@ function getOverrideHeaders(urlHost) { /** * Sets additional headers for the request. - * @param {object} request Original request body + * @param {import('express').Request} request Original request body * @param {object} args New request arguments * @param {string|null} server API server for new request */ @@ -115,7 +165,7 @@ function setAdditionalHeaders(request, args, server) { }; const getHeaders = headerGetters[request.body.api_type]; - const headers = getHeaders ? getHeaders() : {}; + const headers = getHeaders ? getHeaders(request.user.directories) : {}; if (typeof server === 'string' && server.length > 0) { try { diff --git a/src/constants.js b/src/constants.js index 918374eab..256687b7f 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,34 +1,62 @@ -const DIRECTORIES = { - worlds: 'public/worlds/', - user: 'public/user', - avatars: 'public/User Avatars', +const PUBLIC_DIRECTORIES = { images: 'public/img/', - userImages: 'public/user/images/', - groups: 'public/groups/', - groupChats: 'public/group chats', - chats: 'public/chats/', - characters: 'public/characters/', - backgrounds: 'public/backgrounds', - novelAI_Settings: 'public/NovelAI Settings', - koboldAI_Settings: 'public/KoboldAI Settings', - openAI_Settings: 'public/OpenAI Settings', - textGen_Settings: 'public/TextGen Settings', - thumbnails: 'thumbnails/', - thumbnailsBg: 'thumbnails/bg/', - thumbnailsAvatar: 'thumbnails/avatar/', - themes: 'public/themes', - movingUI: 'public/movingUI', - extensions: 'public/scripts/extensions', - instruct: 'public/instruct', - context: 'public/context', backups: 'backups/', - quickreplies: 'public/QuickReplies', - assets: 'public/assets', - comfyWorkflows: 'public/user/workflows', - files: 'public/user/files', sounds: 'public/sounds', + extensions: 'public/scripts/extensions', }; +const DEFAULT_AVATAR = '/img/ai4.png'; +const SETTINGS_FILE = 'settings.json'; + +/** + * @type {import('./users').UserDirectoryList} + * @readonly + * @enum {string} + */ +const USER_DIRECTORY_TEMPLATE = Object.freeze({ + root: '', + thumbnails: 'thumbnails', + thumbnailsBg: 'thumbnails/bg', + thumbnailsAvatar: 'thumbnails/avatar', + worlds: 'worlds', + user: 'user', + avatars: 'User Avatars', + userImages: 'user/images', + groups: 'groups', + groupChats: 'group chats', + chats: 'chats', + characters: 'characters', + backgrounds: 'backgrounds', + novelAI_Settings: 'NovelAI Settings', + koboldAI_Settings: 'KoboldAI Settings', + openAI_Settings: 'OpenAI Settings', + textGen_Settings: 'TextGen Settings', + themes: 'themes', + movingUI: 'movingUI', + extensions: 'extensions', + instruct: 'instruct', + context: 'context', + quickreplies: 'QuickReplies', + assets: 'assets', + comfyWorkflows: 'user/workflows', + files: 'user/files', + vectors: 'vectors', +}); + +/** + * @type {import('./users').User} + * @readonly + */ +const DEFAULT_USER = Object.freeze({ + handle: 'default-user', + name: 'User', + created: Date.now(), + password: '', + admin: true, + enabled: true, + salt: '', +}); + const UNSAFE_EXTENSIONS = [ '.php', '.exe', @@ -270,7 +298,11 @@ const OPENROUTER_KEYS = [ ]; module.exports = { - DIRECTORIES, + DEFAULT_USER, + DEFAULT_AVATAR, + SETTINGS_FILE, + PUBLIC_DIRECTORIES, + USER_DIRECTORY_TEMPLATE, UNSAFE_EXTENSIONS, UPLOADS_PATH, GEMINI_SAFETY, diff --git a/src/endpoints/anthropic.js b/src/endpoints/anthropic.js index 7116988a1..899251fde 100644 --- a/src/endpoints/anthropic.js +++ b/src/endpoints/anthropic.js @@ -39,7 +39,7 @@ router.post('/caption-image', jsonParser, async (request, response) => { headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', - 'x-api-key': request.body.reverse_proxy ? request.body.proxy_password : readSecret(SECRET_KEYS.CLAUDE), + 'x-api-key': request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.CLAUDE), }, timeout: 0, }); diff --git a/src/endpoints/assets.js b/src/endpoints/assets.js index cf5f0d239..a9dc317d6 100644 --- a/src/endpoints/assets.js +++ b/src/endpoints/assets.js @@ -1,14 +1,15 @@ 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 { DIRECTORIES, UNSAFE_EXTENSIONS } = require('../constants'); +const { UNSAFE_EXTENSIONS } = require('../constants'); const { jsonParser } = require('../express-common'); const { clientRelativePath } = require('../util'); -const VALID_CATEGORIES = ['bgm', 'ambient', 'blip', 'live2d', 'vrm', 'character']; +const VALID_CATEGORIES = ['bgm', 'ambient', 'blip', 'live2d', 'vrm', 'character', 'temp']; /** * Validates the input filename for the asset. @@ -48,7 +49,12 @@ function validateAssetFileName(inputFilename) { return { error: false }; } -// Recursive function to get files +/** + * 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 = []) { // Get an array of all files and directories in the passed directory using fs.readdirSync const fileList = fs.readdirSync(dir, { withFileTypes: true }); @@ -77,13 +83,23 @@ const router = express.Router(); * * @returns {void} */ -router.post('/get', jsonParser, async (_, response) => { - const folderPath = path.join(DIRECTORIES.assets); +router.post('/get', jsonParser, async (request, response) => { + const folderPath = path.join(request.user.directories.assets); let output = {}; - //console.info("Checking files into",folderPath); try { if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) { + + 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); + } + } + const folders = fs.readdirSync(folderPath, { withFileTypes: true }) .filter(file => file.isDirectory()); @@ -100,7 +116,7 @@ router.post('/get', jsonParser, async (_, response) => { for (let file of files) { if (file.includes('model') && file.endsWith('.json')) { //console.debug("Asset live2d model found:",file) - output[folder].push(clientRelativePath(file)); + output[folder].push(clientRelativePath(request.user.directories.root, file)); } } continue; @@ -116,7 +132,7 @@ router.post('/get', jsonParser, async (_, response) => { for (let file of files) { if (!file.endsWith('.placeholder')) { //console.debug("Asset VRM model found:",file) - output['vrm']['model'].push(clientRelativePath(file)); + output['vrm']['model'].push(clientRelativePath(request.user.directories.root, file)); } } @@ -127,7 +143,7 @@ router.post('/get', jsonParser, async (_, response) => { for (let file of files) { if (!file.endsWith('.placeholder')) { //console.debug("Asset VRM animation found:",file) - output['vrm']['animation'].push(clientRelativePath(file)); + output['vrm']['animation'].push(clientRelativePath(request.user.directories.root, file)); } } continue; @@ -170,7 +186,7 @@ router.post('/download', jsonParser, async (request, response) => { category = i; if (category === null) { - console.debug('Bad request: unsuported asset category.'); + console.debug('Bad request: unsupported asset category.'); return response.sendStatus(400); } @@ -179,8 +195,8 @@ router.post('/download', jsonParser, async (request, response) => { if (validation.error) return response.status(400).send(validation.message); - const temp_path = path.join(DIRECTORIES.assets, 'temp', request.body.filename); - const file_path = path.join(DIRECTORIES.assets, category, request.body.filename); + 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 { @@ -197,12 +213,15 @@ router.post('/download', jsonParser, async (request, response) => { }); } const fileStream = fs.createWriteStream(destination, { flags: 'wx' }); + // @ts-ignore await finished(res.body.pipe(fileStream)); if (category === 'character') { - response.sendFile(temp_path, { root: process.cwd() }, () => { - fs.rmSync(temp_path); - }); + 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; } @@ -235,7 +254,7 @@ router.post('/delete', jsonParser, async (request, response) => { category = i; if (category === null) { - console.debug('Bad request: unsuported asset category.'); + console.debug('Bad request: unsupported asset category.'); return response.sendStatus(400); } @@ -244,7 +263,7 @@ router.post('/delete', jsonParser, async (request, response) => { if (validation.error) return response.status(400).send(validation.message); - const file_path = path.join(DIRECTORIES.assets, category, request.body.filename); + const file_path = path.join(request.user.directories.assets, category, request.body.filename); console.debug('Request received to delete', category, file_path); try { @@ -290,11 +309,11 @@ router.post('/character', jsonParser, async (request, response) => { category = i; if (category === null) { - console.debug('Bad request: unsuported asset category.'); + console.debug('Bad request: unsupported asset category.'); return response.sendStatus(400); } - const folderPath = path.join(DIRECTORIES.characters, name, category); + const folderPath = path.join(request.user.directories.characters, name, category); let output = []; try { diff --git a/src/endpoints/avatars.js b/src/endpoints/avatars.js index d13d1bf29..58571bae9 100644 --- a/src/endpoints/avatars.js +++ b/src/endpoints/avatars.js @@ -4,7 +4,7 @@ const fs = require('fs'); const sanitize = require('sanitize-filename'); const writeFileAtomicSync = require('write-file-atomic').sync; const { jsonParser, urlencodedParser } = require('../express-common'); -const { DIRECTORIES, AVATAR_WIDTH, AVATAR_HEIGHT, UPLOADS_PATH } = require('../constants'); +const { AVATAR_WIDTH, AVATAR_HEIGHT, UPLOADS_PATH } = require('../constants'); const { getImages, tryParse } = require('../util'); // image processing related library imports @@ -13,7 +13,7 @@ const jimp = require('jimp'); const router = express.Router(); router.post('/get', jsonParser, function (request, response) { - var images = getImages(DIRECTORIES.avatars); + var images = getImages(request.user.directories.avatars); response.send(JSON.stringify(images)); }); @@ -25,7 +25,7 @@ router.post('/delete', jsonParser, function (request, response) { return response.sendStatus(403); } - const fileName = path.join(DIRECTORIES.avatars, sanitize(request.body.avatar)); + const fileName = path.join(request.user.directories.avatars, sanitize(request.body.avatar)); if (fs.existsSync(fileName)) { fs.rmSync(fileName); @@ -50,7 +50,7 @@ router.post('/upload', urlencodedParser, async (request, response) => { const image = await rawImg.cover(AVATAR_WIDTH, AVATAR_HEIGHT).getBufferAsync(jimp.MIME_PNG); const filename = request.body.overwrite_name || `${Date.now()}.png`; - const pathToNewFile = path.join(DIRECTORIES.avatars, filename); + const pathToNewFile = path.join(request.user.directories.avatars, filename); writeFileAtomicSync(pathToNewFile, image); fs.rmSync(pathToUpload); return response.send({ path: filename }); diff --git a/src/endpoints/backends/chat-completions.js b/src/endpoints/backends/chat-completions.js index 876352b06..f38809ff8 100644 --- a/src/endpoints/backends/chat-completions.js +++ b/src/endpoints/backends/chat-completions.js @@ -98,7 +98,7 @@ async function parseCohereStream(jsonStream, request, response) { */ async function sendClaudeRequest(request, response) { const apiUrl = new URL(request.body.reverse_proxy || API_CLAUDE).toString(); - const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(SECRET_KEYS.CLAUDE); + const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.CLAUDE); const divider = '-'.repeat(process.stdout.columns); if (!apiKey) { @@ -179,7 +179,7 @@ async function sendClaudeRequest(request, response) { */ async function sendScaleRequest(request, response) { const apiUrl = new URL(request.body.api_url_scale).toString(); - const apiKey = readSecret(SECRET_KEYS.SCALE); + const apiKey = readSecret(request.user.directories, SECRET_KEYS.SCALE); if (!apiKey) { console.log('Scale API key is missing.'); @@ -230,7 +230,7 @@ async function sendScaleRequest(request, response) { * @param {express.Response} response Express response */ async function sendMakerSuiteRequest(request, response) { - const apiKey = readSecret(SECRET_KEYS.MAKERSUITE); + const apiKey = readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE); if (!apiKey) { console.log('MakerSuite API key is missing.'); @@ -392,7 +392,7 @@ async function sendAI21Request(request, response) { headers: { accept: 'application/json', 'content-type': 'application/json', - Authorization: `Bearer ${readSecret(SECRET_KEYS.AI21)}`, + Authorization: `Bearer ${readSecret(request.user.directories, SECRET_KEYS.AI21)}`, }, body: JSON.stringify({ numResults: 1, @@ -456,7 +456,7 @@ async function sendAI21Request(request, response) { */ async function sendMistralAIRequest(request, response) { const apiUrl = new URL(request.body.reverse_proxy || API_MISTRAL).toString(); - const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(SECRET_KEYS.MISTRALAI); + const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MISTRALAI); if (!apiKey) { console.log('MistralAI API key is missing.'); @@ -553,7 +553,7 @@ async function sendMistralAIRequest(request, response) { * @param {express.Response} response Express response */ async function sendCohereRequest(request, response) { - const apiKey = readSecret(SECRET_KEYS.COHERE); + const apiKey = readSecret(request.user.directories, SECRET_KEYS.COHERE); const controller = new AbortController(); request.socket.removeAllListeners('close'); request.socket.on('close', function () { @@ -642,25 +642,25 @@ router.post('/status', jsonParser, async function (request, response_getstatus_o if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENAI) { api_url = new URL(request.body.reverse_proxy || API_OPENAI).toString(); - api_key_openai = request.body.reverse_proxy ? request.body.proxy_password : readSecret(SECRET_KEYS.OPENAI); + api_key_openai = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.OPENAI); headers = {}; } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENROUTER) { api_url = 'https://openrouter.ai/api/v1'; - api_key_openai = readSecret(SECRET_KEYS.OPENROUTER); + api_key_openai = readSecret(request.user.directories, SECRET_KEYS.OPENROUTER); // OpenRouter needs to pass the Referer and X-Title: https://openrouter.ai/docs#requests headers = { ...OPENROUTER_HEADERS }; } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.MISTRALAI) { api_url = new URL(request.body.reverse_proxy || API_MISTRAL).toString(); - api_key_openai = request.body.reverse_proxy ? request.body.proxy_password : readSecret(SECRET_KEYS.MISTRALAI); + api_key_openai = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MISTRALAI); headers = {}; } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.CUSTOM) { api_url = request.body.custom_url; - api_key_openai = readSecret(SECRET_KEYS.CUSTOM); + api_key_openai = readSecret(request.user.directories, SECRET_KEYS.CUSTOM); headers = {}; mergeObjectWithYaml(headers, request.body.custom_include_headers); } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.COHERE) { api_url = API_COHERE; - api_key_openai = readSecret(SECRET_KEYS.COHERE); + api_key_openai = readSecret(request.user.directories, SECRET_KEYS.COHERE); headers = {}; } else { console.log('This chat completion source is not supported yet.'); @@ -825,10 +825,11 @@ router.post('/generate', jsonParser, function (request, response) { if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENAI) { apiUrl = new URL(request.body.reverse_proxy || API_OPENAI).toString(); - apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(SECRET_KEYS.OPENAI); + apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.OPENAI); headers = {}; bodyParams = { logprobs: request.body.logprobs, + top_logprobs: undefined, }; // Adjust logprobs params for Chat Completions API, which expects { top_logprobs: number; logprobs: boolean; } @@ -842,7 +843,7 @@ router.post('/generate', jsonParser, function (request, response) { } } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENROUTER) { apiUrl = 'https://openrouter.ai/api/v1'; - apiKey = readSecret(SECRET_KEYS.OPENROUTER); + apiKey = readSecret(request.user.directories, SECRET_KEYS.OPENROUTER); // OpenRouter needs to pass the Referer and X-Title: https://openrouter.ai/docs#requests headers = { ...OPENROUTER_HEADERS }; bodyParams = { 'transforms': ['middle-out'] }; @@ -864,10 +865,11 @@ router.post('/generate', jsonParser, function (request, response) { } } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.CUSTOM) { apiUrl = request.body.custom_url; - apiKey = readSecret(SECRET_KEYS.CUSTOM); + apiKey = readSecret(request.user.directories, SECRET_KEYS.CUSTOM); headers = {}; bodyParams = { logprobs: request.body.logprobs, + top_logprobs: undefined, }; // Adjust logprobs params for Chat Completions API, which expects { top_logprobs: number; logprobs: boolean; } diff --git a/src/endpoints/backends/scale-alt.js b/src/endpoints/backends/scale-alt.js index edcb7f83f..28b46de8a 100644 --- a/src/endpoints/backends/scale-alt.js +++ b/src/endpoints/backends/scale-alt.js @@ -14,7 +14,7 @@ router.post('/generate', jsonParser, function (request, response) { method: 'POST', headers: { 'Content-Type': 'application/json', - 'cookie': `_jwt=${readSecret(SECRET_KEYS.SCALE_COOKIE)}`, + 'cookie': `_jwt=${readSecret(request.user.directories, SECRET_KEYS.SCALE_COOKIE)}`, }, body: JSON.stringify({ json: { diff --git a/src/endpoints/backends/text-completions.js b/src/endpoints/backends/text-completions.js index 8e0ebba8d..22806cbf0 100644 --- a/src/endpoints/backends/text-completions.js +++ b/src/endpoints/backends/text-completions.js @@ -516,7 +516,7 @@ llamacpp.post('/slots', jsonParser, async function (request, response) { const baseUrl = trimV1(request.body.server_url); let fetchResponse; - if (request.body.action === "info") { + if (request.body.action === 'info') { fetchResponse = await fetch(`${baseUrl}/slots`, { method: 'GET', timeout: 0, @@ -525,16 +525,16 @@ llamacpp.post('/slots', jsonParser, async function (request, response) { if (!/^\d+$/.test(request.body.id_slot)) { return response.sendStatus(400); } - if (request.body.action !== "erase" && !request.body.filename) { + if (request.body.action !== 'erase' && !request.body.filename) { return response.sendStatus(400); } - + fetchResponse = await fetch(`${baseUrl}/slots/${request.body.id_slot}?action=${request.body.action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, timeout: 0, body: JSON.stringify({ - filename: request.body.action !== "erase" ? `${request.body.filename}` : undefined, + filename: request.body.action !== 'erase' ? `${request.body.filename}` : undefined, }), }); } diff --git a/src/endpoints/backgrounds.js b/src/endpoints/backgrounds.js index d0b9d5ab7..33419ef4f 100644 --- a/src/endpoints/backgrounds.js +++ b/src/endpoints/backgrounds.js @@ -4,16 +4,15 @@ const express = require('express'); const sanitize = require('sanitize-filename'); const { jsonParser, urlencodedParser } = require('../express-common'); -const { DIRECTORIES, UPLOADS_PATH } = require('../constants'); +const { UPLOADS_PATH } = require('../constants'); const { invalidateThumbnail } = require('./thumbnails'); const { getImages } = require('../util'); const router = express.Router(); router.post('/all', jsonParser, function (request, response) { - var images = getImages('public/backgrounds'); + var images = getImages(request.user.directories.backgrounds); response.send(JSON.stringify(images)); - }); router.post('/delete', jsonParser, function (request, response) { @@ -24,7 +23,7 @@ router.post('/delete', jsonParser, function (request, response) { return response.sendStatus(403); } - const fileName = path.join('public/backgrounds/', sanitize(request.body.bg)); + const fileName = path.join(request.user.directories.backgrounds, sanitize(request.body.bg)); if (!fs.existsSync(fileName)) { console.log('BG file not found'); @@ -32,15 +31,15 @@ router.post('/delete', jsonParser, function (request, response) { } fs.rmSync(fileName); - invalidateThumbnail('bg', request.body.bg); + invalidateThumbnail(request.user.directories, 'bg', request.body.bg); return response.send('ok'); }); router.post('/rename', jsonParser, function (request, response) { if (!request.body) return response.sendStatus(400); - const oldFileName = path.join(DIRECTORIES.backgrounds, sanitize(request.body.old_bg)); - const newFileName = path.join(DIRECTORIES.backgrounds, sanitize(request.body.new_bg)); + const oldFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.old_bg)); + const newFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.new_bg)); if (!fs.existsSync(oldFileName)) { console.log('BG file not found'); @@ -53,7 +52,7 @@ router.post('/rename', jsonParser, function (request, response) { } fs.renameSync(oldFileName, newFileName); - invalidateThumbnail('bg', request.body.old_bg); + invalidateThumbnail(request.user.directories, 'bg', request.body.old_bg); return response.send('ok'); }); @@ -64,8 +63,8 @@ router.post('/upload', urlencodedParser, function (request, response) { const filename = request.file.originalname; try { - fs.renameSync(img_path, path.join('public/backgrounds/', filename)); - invalidateThumbnail('bg', filename); + fs.renameSync(img_path, path.join(request.user.directories.backgrounds, filename)); + invalidateThumbnail(request.user.directories, 'bg', filename); response.send(filename); } catch (err) { console.error(err); diff --git a/src/endpoints/characters.js b/src/endpoints/characters.js index 45d2896ac..ee586168b 100644 --- a/src/endpoints/characters.js +++ b/src/endpoints/characters.js @@ -1,15 +1,17 @@ const path = require('path'); const fs = require('fs'); +const fsPromises = require('fs').promises; const readline = require('readline'); const express = require('express'); const sanitize = require('sanitize-filename'); const writeFileAtomicSync = require('write-file-atomic').sync; const yaml = require('yaml'); const _ = require('lodash'); +const mime = require('mime-types'); const jimp = require('jimp'); -const { DIRECTORIES, UPLOADS_PATH, AVATAR_WIDTH, AVATAR_HEIGHT } = require('../constants'); +const { UPLOADS_PATH, AVATAR_WIDTH, AVATAR_HEIGHT } = require('../constants'); const { jsonParser, urlencodedParser } = require('../express-common'); const { deepMerge, humanizedISO8601DateTime, tryParse } = require('../util'); const { TavernCardValidator } = require('../validator/TavernCardValidator'); @@ -19,82 +21,99 @@ const { invalidateThumbnail } = require('./thumbnails'); const { importRisuSprites } = require('./sprites'); const defaultAvatarPath = './public/img/ai4.png'; -let characters = {}; - // KV-store for parsed character data const characterDataCache = new Map(); /** * Reads the character card from the specified image file. - * @param {string} img_url - Path to the image file - * @param {string} input_format - 'png' + * @param {string} inputFile - Path to the image file + * @param {string} inputFormat - 'png' * @returns {Promise} - Character card data */ -async function charaRead(img_url, input_format = 'png') { - const stat = fs.statSync(img_url); - const cacheKey = `${img_url}-${stat.mtimeMs}`; +async function readCharacterData(inputFile, inputFormat = 'png') { + const stat = fs.statSync(inputFile); + const cacheKey = `${inputFile}-${stat.mtimeMs}`; if (characterDataCache.has(cacheKey)) { return characterDataCache.get(cacheKey); } - const result = characterCardParser.parse(img_url, input_format); + const result = characterCardParser.parse(inputFile, inputFormat); characterDataCache.set(cacheKey, result); return result; } /** - * @param {express.Response | undefined} response - * @param {{file_name: string} | string} mes + * Writes the character card to the specified image file. + * @param {string} inputFile - Path to the image file + * @param {string} data - Character card data + * @param {string} outputFile - Target image file name + * @param {import('express').Request} request - Express request obejct + * @param {Crop|undefined} crop - Crop parameters + * @returns {Promise} - True if the operation was successful */ -async function charaWrite(img_url, data, target_img, response = undefined, mes = 'ok', crop = undefined) { +async function writeCharacterData(inputFile, data, outputFile, request, crop = undefined) { try { // Reset the cache for (const key of characterDataCache.keys()) { - if (key.startsWith(img_url)) { + if (key.startsWith(inputFile)) { characterDataCache.delete(key); break; } } // Read the image, resize, and save it as a PNG into the buffer - const inputImage = await tryReadImage(img_url, crop); + const inputImage = await tryReadImage(inputFile, crop); // Get the chunks const outputImage = characterCardParser.write(inputImage, data); + const outputImagePath = path.join(request.user.directories.characters, `${outputFile}.png`); - writeFileAtomicSync(DIRECTORIES.characters + target_img + '.png', outputImage); - if (response !== undefined) response.send(mes); + writeFileAtomicSync(outputImagePath, outputImage); return true; } catch (err) { console.log(err); - if (response !== undefined) response.status(500).send(err); return false; } } -async function tryReadImage(img_url, crop) { +/** + * @typedef {Object} Crop + * @property {number} x X-coordinate + * @property {number} y Y-coordinate + * @property {number} width Width + * @property {number} height Height + * @property {boolean} want_resize Resize the image to the standard avatar size + */ + +/** + * Reads an image file and applies crop if defined. + * @param {string} imgPath Path to the image file + * @param {Crop|undefined} crop Crop parameters + * @returns {Promise} Image buffer + */ +async function tryReadImage(imgPath, crop) { try { - let rawImg = await jimp.read(img_url); - let final_width = rawImg.bitmap.width, final_height = rawImg.bitmap.height; + let rawImg = await jimp.read(imgPath); + let finalWidth = rawImg.bitmap.width, finalHeight = rawImg.bitmap.height; // Apply crop if defined if (typeof crop == 'object' && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) { rawImg = rawImg.crop(crop.x, crop.y, crop.width, crop.height); // Apply standard resize if requested if (crop.want_resize) { - final_width = AVATAR_WIDTH; - final_height = AVATAR_HEIGHT; + finalWidth = AVATAR_WIDTH; + finalHeight = AVATAR_HEIGHT; } else { - final_width = crop.width; - final_height = crop.height; + finalWidth = crop.width; + finalHeight = crop.height; } } - const image = await rawImg.cover(final_width, final_height).getBufferAsync(jimp.MIME_PNG); + const image = await rawImg.cover(finalWidth, finalHeight).getBufferAsync(jimp.MIME_PNG); return image; } // If it's an unsupported type of image (APNG) - just read the file as buffer catch { - return fs.readFileSync(img_url); + return fs.readFileSync(imgPath); } } @@ -131,54 +150,57 @@ const calculateDataSize = (data) => { * processCharacter - Process a given character, read its data and calculate its statistics. * * @param {string} item The name of the character. - * @param {number} i The index of the character in the characters list. - * @return {Promise} A Promise that resolves when the character processing is done. + * @param {import('../users').UserDirectoryList} directories User directories + * @return {Promise} A Promise that resolves when the character processing is done. */ -const processCharacter = async (item, i) => { +const processCharacter = async (item, directories) => { try { - const img_data = await charaRead(DIRECTORIES.characters + item); - if (img_data === undefined) throw new Error('Failed to read character file'); + const imgFile = path.join(directories.characters, item); + const imgData = await readCharacterData(imgFile); + if (imgData === undefined) throw new Error('Failed to read character file'); - let jsonObject = getCharaCardV2(JSON.parse(img_data), false); + let jsonObject = getCharaCardV2(JSON.parse(imgData), directories, false); jsonObject.avatar = item; - characters[i] = jsonObject; - characters[i]['json_data'] = img_data; - const charStat = fs.statSync(path.join(DIRECTORIES.characters, item)); - characters[i]['date_added'] = charStat.ctimeMs; - characters[i]['create_date'] = jsonObject['create_date'] || humanizedISO8601DateTime(charStat.ctimeMs); - const char_dir = path.join(DIRECTORIES.chats, item.replace('.png', '')); + const character = jsonObject; + character['json_data'] = imgData; + const charStat = fs.statSync(path.join(directories.characters, item)); + character['date_added'] = charStat.ctimeMs; + character['create_date'] = jsonObject['create_date'] || humanizedISO8601DateTime(charStat.ctimeMs); + const chatsDirectory = path.join(directories.chats, item.replace('.png', '')); - const { chatSize, dateLastChat } = calculateChatSize(char_dir); - characters[i]['chat_size'] = chatSize; - characters[i]['date_last_chat'] = dateLastChat; - characters[i]['data_size'] = calculateDataSize(jsonObject?.data); + const { chatSize, dateLastChat } = calculateChatSize(chatsDirectory); + character['chat_size'] = chatSize; + character['date_last_chat'] = dateLastChat; + character['data_size'] = calculateDataSize(jsonObject?.data); + return character; } catch (err) { - characters[i] = { + console.log(`Could not process character: ${item}`); + + if (err instanceof SyntaxError) { + console.log(`${item} does not contain a valid JSON object.`); + } else { + console.log('An unexpected error occurred: ', err); + } + + return { date_added: 0, date_last_chat: 0, chat_size: 0, }; - - console.log(`Could not process character: ${item}`); - - if (err instanceof SyntaxError) { - console.log('String [' + i + '] is not valid JSON!'); - } else { - console.log('An unexpected error occurred: ', err); - } } }; /** * Convert a character object to Spec V2 format. * @param {object} jsonObject Character object + * @param {import('../users').UserDirectoryList} directories User directories * @param {boolean} hoistDate Will set the chat and create_date fields to the current date if they are missing * @returns {object} Character object in Spec V2 format */ -function getCharaCardV2(jsonObject, hoistDate = true) { +function getCharaCardV2(jsonObject, directories, hoistDate = true) { if (jsonObject.spec === undefined) { - jsonObject = convertToV2(jsonObject); + jsonObject = convertToV2(jsonObject, directories); if (hoistDate && !jsonObject.create_date) { jsonObject.create_date = humanizedISO8601DateTime(); @@ -192,9 +214,10 @@ function getCharaCardV2(jsonObject, hoistDate = true) { /** * Convert a character object to Spec V2 format. * @param {object} char Character object + * @param {import('../users').UserDirectoryList} directories User directories * @returns {object} Character object in Spec V2 format */ -function convertToV2(char) { +function convertToV2(char, directories) { // Simulate incoming data from frontend form const result = charaFormatData({ json_data: JSON.stringify(char), @@ -212,7 +235,7 @@ function convertToV2(char) { depth_prompt_prompt: char.depth_prompt_prompt, depth_prompt_depth: char.depth_prompt_depth, depth_prompt_role: char.depth_prompt_role, - }); + }, directories); result.chat = char.chat ?? humanizedISO8601DateTime(); result.create_date = char.create_date; @@ -278,8 +301,13 @@ function readFromV2(char) { return char; } -//***************** Main functions -function charaFormatData(data) { +/** + * Format character data to Spec V2 format. + * @param {object} data Character data + * @param {import('../users').UserDirectoryList} directories User directories + * @returns + */ +function charaFormatData(data, directories) { // This is supposed to save all the foreign keys that ST doesn't care about const char = tryParse(data.json_data) || {}; @@ -344,7 +372,7 @@ function charaFormatData(data) { if (data.world) { try { - const file = readWorldInfoFile(data.world, false); + const file = readWorldInfoFile(directories, data.world, false); // File was imported - save it to the character book if (file && file.originalData) { @@ -423,15 +451,16 @@ function convertWorldInfoToCharacterBook(name, entries) { /** * Import a character from a YAML file. * @param {string} uploadPath Path to the uploaded file - * @param {import('express').Response} response Express response object + * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects + * @returns {Promise} Internal name of the character */ -function importFromYaml(uploadPath, response) { +async function importFromYaml(uploadPath, context) { const fileText = fs.readFileSync(uploadPath, 'utf8'); fs.rmSync(uploadPath); const yamlData = yaml.parse(fileText); - console.log('importing from yaml'); + console.log('Importing from YAML'); yamlData.name = sanitize(yamlData.name); - const fileName = getPngName(yamlData.name); + const fileName = getPngName(yamlData.name, context.request.user.directories); let char = convertToV2({ 'name': yamlData.name, 'description': yamlData.context ?? '', @@ -446,32 +475,177 @@ function importFromYaml(uploadPath, response) { 'talkativeness': 0.5, 'creator': '', 'tags': '', - }); - charaWrite(defaultAvatarPath, JSON.stringify(char), fileName, response, { file_name: fileName }); + }, context.request.user.directories); + const result = await writeCharacterData(defaultAvatarPath, JSON.stringify(char), fileName, context.request); + return result ? fileName : ''; +} + +/** + * Import a character from a JSON file. + * @param {string} uploadPath Path to the uploaded file + * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects + * @returns {Promise} Internal name of the character + */ +async function importFromJson(uploadPath, { request }) { + const data = fs.readFileSync(uploadPath, 'utf8'); + fs.unlinkSync(uploadPath); + + let jsonData = JSON.parse(data); + + if (jsonData.spec !== undefined) { + console.log('Importing from v2 json'); + importRisuSprites(request.user.directories, jsonData); + unsetFavFlag(jsonData); + jsonData = readFromV2(jsonData); + jsonData['create_date'] = humanizedISO8601DateTime(); + const pngName = getPngName(jsonData.data?.name || jsonData.name, request.user.directories); + const char = JSON.stringify(jsonData); + const result = await writeCharacterData(defaultAvatarPath, char, pngName, request); + return result ? pngName : ''; + } else if (jsonData.name !== undefined) { + console.log('Importing from v1 json'); + jsonData.name = sanitize(jsonData.name); + if (jsonData.creator_notes) { + jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', ''); + } + const pngName = getPngName(jsonData.name, request.user.directories); + let char = { + 'name': jsonData.name, + 'description': jsonData.description ?? '', + 'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '', + 'personality': jsonData.personality ?? '', + 'first_mes': jsonData.first_mes ?? '', + 'avatar': 'none', + 'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(), + 'mes_example': jsonData.mes_example ?? '', + 'scenario': jsonData.scenario ?? '', + 'create_date': humanizedISO8601DateTime(), + 'talkativeness': jsonData.talkativeness ?? 0.5, + 'creator': jsonData.creator ?? '', + 'tags': jsonData.tags ?? '', + }; + char = convertToV2(char, request.user.directories); + let charJSON = JSON.stringify(char); + const result = await writeCharacterData(defaultAvatarPath, charJSON, pngName, request); + return result ? pngName : ''; + } else if (jsonData.char_name !== undefined) {//json Pygmalion notepad + console.log('Importing from gradio json'); + jsonData.char_name = sanitize(jsonData.char_name); + if (jsonData.creator_notes) { + jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', ''); + } + const pngName = getPngName(jsonData.char_name, request.user.directories); + let char = { + 'name': jsonData.char_name, + 'description': jsonData.char_persona ?? '', + 'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '', + 'personality': '', + 'first_mes': jsonData.char_greeting ?? '', + 'avatar': 'none', + 'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(), + 'mes_example': jsonData.example_dialogue ?? '', + 'scenario': jsonData.world_scenario ?? '', + 'create_date': humanizedISO8601DateTime(), + 'talkativeness': jsonData.talkativeness ?? 0.5, + 'creator': jsonData.creator ?? '', + 'tags': jsonData.tags ?? '', + }; + char = convertToV2(char, request.user.directories); + const charJSON = JSON.stringify(char); + const result = await writeCharacterData(defaultAvatarPath, charJSON, pngName, request); + return result ? pngName : ''; + } + + return ''; +} + +/** + * Import a character from a PNG file. + * @param {string} uploadPath Path to the uploaded file + * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects + * @param {string|undefined} preservedFileName Preserved file name + * @returns {Promise} Internal name of the character + */ +async function importFromPng(uploadPath, { request }, preservedFileName) { + const imgData = await readCharacterData(uploadPath); + if (imgData === undefined) throw new Error('Failed to read character data'); + + let jsonData = JSON.parse(imgData); + + jsonData.name = sanitize(jsonData.data?.name || jsonData.name); + const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories); + + if (jsonData.spec !== undefined) { + console.log('Found a v2 character file.'); + importRisuSprites(request.user.directories, jsonData); + unsetFavFlag(jsonData); + jsonData = readFromV2(jsonData); + jsonData['create_date'] = humanizedISO8601DateTime(); + const char = JSON.stringify(jsonData); + const result = await writeCharacterData(uploadPath, char, pngName, request); + fs.unlinkSync(uploadPath); + return result ? pngName : ''; + } else if (jsonData.name !== undefined) { + console.log('Found a v1 character file.'); + + if (jsonData.creator_notes) { + jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', ''); + } + + let char = { + 'name': jsonData.name, + 'description': jsonData.description ?? '', + 'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '', + 'personality': jsonData.personality ?? '', + 'first_mes': jsonData.first_mes ?? '', + 'avatar': 'none', + 'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(), + 'mes_example': jsonData.mes_example ?? '', + 'scenario': jsonData.scenario ?? '', + 'create_date': humanizedISO8601DateTime(), + 'talkativeness': jsonData.talkativeness ?? 0.5, + 'creator': jsonData.creator ?? '', + 'tags': jsonData.tags ?? '', + }; + char = convertToV2(char, request.user.directories); + const charJSON = JSON.stringify(char); + const result = await writeCharacterData(uploadPath, charJSON, pngName, request); + fs.unlinkSync(uploadPath); + return result ? pngName : ''; + } + + return ''; } const router = express.Router(); router.post('/create', urlencodedParser, async function (request, response) { - if (!request.body) return response.sendStatus(400); + try { + if (!request.body) return response.sendStatus(400); - request.body.ch_name = sanitize(request.body.ch_name); + request.body.ch_name = sanitize(request.body.ch_name); - const char = JSON.stringify(charaFormatData(request.body)); - const internalName = getPngName(request.body.ch_name); - const avatarName = `${internalName}.png`; - const defaultAvatar = './public/img/ai4.png'; - const chatsPath = DIRECTORIES.chats + internalName; //path.join(chatsPath, internalName); + const char = JSON.stringify(charaFormatData(request.body, request.user.directories)); + const internalName = getPngName(request.body.ch_name, request.user.directories); + const avatarName = `${internalName}.png`; + const defaultAvatar = './public/img/ai4.png'; + const chatsPath = path.join(request.user.directories.chats, internalName); - if (!fs.existsSync(chatsPath)) fs.mkdirSync(chatsPath); + if (!fs.existsSync(chatsPath)) fs.mkdirSync(chatsPath); - if (!request.file) { - charaWrite(defaultAvatar, char, internalName, response, avatarName); - } else { - const crop = tryParse(request.query.crop); - const uploadPath = path.join(UPLOADS_PATH, request.file.filename); - await charaWrite(uploadPath, char, internalName, response, avatarName, crop); - fs.unlinkSync(uploadPath); + if (!request.file) { + await writeCharacterData(defaultAvatar, char, internalName, request); + return response.send(avatarName); + } else { + const crop = tryParse(request.query.crop); + const uploadPath = path.join(UPLOADS_PATH, request.file.filename); + await writeCharacterData(uploadPath, char, internalName, request, crop); + fs.unlinkSync(uploadPath); + return response.send(avatarName); + } + } catch (err) { + console.error(err); + response.sendStatus(500); } }); @@ -483,26 +657,26 @@ router.post('/rename', jsonParser, async function (request, response) { const oldAvatarName = request.body.avatar_url; const newName = sanitize(request.body.new_name); const oldInternalName = path.parse(request.body.avatar_url).name; - const newInternalName = getPngName(newName); + const newInternalName = getPngName(newName, request.user.directories); const newAvatarName = `${newInternalName}.png`; - const oldAvatarPath = path.join(DIRECTORIES.characters, oldAvatarName); + const oldAvatarPath = path.join(request.user.directories.characters, oldAvatarName); - const oldChatsPath = path.join(DIRECTORIES.chats, oldInternalName); - const newChatsPath = path.join(DIRECTORIES.chats, newInternalName); + const oldChatsPath = path.join(request.user.directories.chats, oldInternalName); + const newChatsPath = path.join(request.user.directories.chats, newInternalName); try { // Read old file, replace name int it - const rawOldData = await charaRead(oldAvatarPath); + const rawOldData = await readCharacterData(oldAvatarPath); if (rawOldData === undefined) throw new Error('Failed to read character file'); - const oldData = getCharaCardV2(JSON.parse(rawOldData)); + const oldData = getCharaCardV2(JSON.parse(rawOldData), request.user.directories); _.set(oldData, 'data.name', newName); _.set(oldData, 'name', newName); const newData = JSON.stringify(oldData); // Write data to new location - await charaWrite(oldAvatarPath, newData, newInternalName); + await writeCharacterData(oldAvatarPath, newData, newInternalName, request); // Rename chats folder if (fs.existsSync(oldChatsPath) && !fs.existsSync(newChatsPath)) { @@ -513,7 +687,7 @@ router.post('/rename', jsonParser, async function (request, response) { fs.rmSync(oldAvatarPath); // Return new avatar name to ST - return response.send({ 'avatar': newAvatarName }); + return response.send({ avatar: newAvatarName }); } catch (err) { console.error(err); @@ -534,23 +708,25 @@ router.post('/edit', urlencodedParser, async function (request, response) { return; } - let char = charaFormatData(request.body); + let char = charaFormatData(request.body, request.user.directories); char.chat = request.body.chat; char.create_date = request.body.create_date; char = JSON.stringify(char); - let target_img = (request.body.avatar_url).replace('.png', ''); + let targetFile = (request.body.avatar_url).replace('.png', ''); try { if (!request.file) { - const avatarPath = path.join(DIRECTORIES.characters, request.body.avatar_url); - await charaWrite(avatarPath, char, target_img, response, 'Character saved'); + const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url); + await writeCharacterData(avatarPath, char, targetFile, request); } else { const crop = tryParse(request.query.crop); const newAvatarPath = path.join(UPLOADS_PATH, request.file.filename); - invalidateThumbnail('avatar', request.body.avatar_url); - await charaWrite(newAvatarPath, char, target_img, response, 'Character saved', crop); + invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url); + await writeCharacterData(newAvatarPath, char, targetFile, request, crop); fs.unlinkSync(newAvatarPath); } + + return response.sendStatus(200); } catch { console.error('An error occured, character edit invalidated.'); @@ -572,22 +748,20 @@ router.post('/edit-attribute', jsonParser, async function (request, response) { console.log(request.body); if (!request.body) { console.error('Error: no response body detected'); - response.status(400).send('Error: no response body detected'); - return; + return response.status(400).send('Error: no response body detected'); } if (request.body.ch_name === '' || request.body.ch_name === undefined || request.body.ch_name === '.') { console.error('Error: invalid name.'); - response.status(400).send('Error: invalid name.'); - return; + return response.status(400).send('Error: invalid name.'); } try { - const avatarPath = path.join(DIRECTORIES.characters, request.body.avatar_url); - let charJSON = await charaRead(avatarPath); + const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url); + const charJSON = await readCharacterData(avatarPath); if (typeof charJSON !== 'string') throw new Error('Failed to read character file'); - let char = JSON.parse(charJSON); + const char = JSON.parse(charJSON); //check if the field exists if (char[request.body.field] === undefined && char.data[request.body.field] === undefined) { console.error('Error: invalid field.'); @@ -597,7 +771,9 @@ router.post('/edit-attribute', jsonParser, async function (request, response) { char[request.body.field] = request.body.value; char.data[request.body.field] = request.body.value; let newCharJSON = JSON.stringify(char); - await charaWrite(avatarPath, newCharJSON, (request.body.avatar_url).replace('.png', ''), response, 'Character saved'); + const targetFile = (request.body.avatar_url).replace('.png', ''); + await writeCharacterData(avatarPath, newCharJSON, targetFile, request); + return response.sendStatus(200); } catch (err) { console.error('An error occured, character edit invalidated.', err); } @@ -617,30 +793,25 @@ router.post('/edit-attribute', jsonParser, async function (request, response) { router.post('/merge-attributes', jsonParser, async function (request, response) { try { const update = request.body; - const avatarPath = path.join(DIRECTORIES.characters, update.avatar); + const avatarPath = path.join(request.user.directories.characters, update.avatar); - const pngStringData = await charaRead(avatarPath); + const pngStringData = await readCharacterData(avatarPath); if (!pngStringData) { console.error('Error: invalid character file.'); - response.status(400).send('Error: invalid character file.'); - return; + return response.status(400).send('Error: invalid character file.'); } let character = JSON.parse(pngStringData); character = deepMerge(character, update); const validator = new TavernCardValidator(character); + const targetImg = (update.avatar).replace('.png', ''); //Accept either V1 or V2. if (validator.validate()) { - await charaWrite( - avatarPath, - JSON.stringify(character), - (update.avatar).replace('.png', ''), - response, - 'Character saved', - ); + await writeCharacterData(avatarPath, JSON.stringify(character), targetImg, request); + response.sendStatus(200); } else { console.log(validator.lastValidationError); response.status(400).send({ message: `Validation failed for ${character.name}`, error: validator.lastValidationError }); @@ -660,13 +831,13 @@ router.post('/delete', jsonParser, async function (request, response) { return response.sendStatus(403); } - const avatarPath = DIRECTORIES.characters + request.body.avatar_url; + const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url); if (!fs.existsSync(avatarPath)) { return response.sendStatus(400); } fs.rmSync(avatarPath); - invalidateThumbnail('avatar', request.body.avatar_url); + invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url); let dir_name = (request.body.avatar_url.replace('.png', '')); if (!dir_name.length) { @@ -676,7 +847,7 @@ router.post('/delete', jsonParser, async function (request, response) { if (request.body.delete_chats == true) { try { - await fs.promises.rm(path.join(DIRECTORIES.chats, sanitize(dir_name)), { recursive: true, force: true }); + await fs.promises.rm(path.join(request.user.directories.chats, sanitize(dir_name)), { recursive: true, force: true }); } catch (err) { console.error(err); return response.sendStatus(500); @@ -696,46 +867,40 @@ router.post('/delete', jsonParser, async function (request, response) { * The stats are calculated by the `calculateStats` function. * The characters are processed by the `processCharacter` function. * - * @param {object} request The HTTP request object. - * @param {object} response The HTTP response object. - * @return {undefined} Does not return a value. + * @param {import("express").Request} request The HTTP request object. + * @param {import("express").Response} response The HTTP response object. + * @return {void} */ -router.post('/all', jsonParser, function (request, response) { - fs.readdir(DIRECTORIES.characters, async (err, files) => { - if (err) { - console.error(err); - return; - } - +router.post('/all', jsonParser, async function (request, response) { + try { + const files = fs.readdirSync(request.user.directories.characters); const pngFiles = files.filter(file => file.endsWith('.png')); - characters = {}; - - let processingPromises = pngFiles.map((file, index) => processCharacter(file, index)); - await Promise.all(processingPromises); performance.mark('B'); - - // Filter out invalid/broken characters - characters = Object.values(characters).filter(x => x?.name).reduce((acc, val, index) => { - acc[index] = val; - return acc; - }, {}); - - response.send(JSON.stringify(characters)); - }); + const processingPromises = pngFiles.map(file => processCharacter(file, request.user.directories)); + const data = await Promise.all(processingPromises); + return response.send(data); + } catch (err) { + console.error(err); + response.sendStatus(500); + } }); router.post('/get', jsonParser, async function (request, response) { - if (!request.body) return response.sendStatus(400); - const item = request.body.avatar_url; - const filePath = path.join(DIRECTORIES.characters, item); + try { + if (!request.body) return response.sendStatus(400); + const item = request.body.avatar_url; + const filePath = path.join(request.user.directories.characters, item); - if (!fs.existsSync(filePath)) { - return response.sendStatus(404); + if (!fs.existsSync(filePath)) { + return response.sendStatus(404); + } + + const data = await processCharacter(item, request.user.directories); + + return response.send(data); + } catch (err) { + console.error(err); + response.sendStatus(500); } - - characters = {}; - await processCharacter(item, 0); - - return response.send(characters[0]); }); router.post('/chats', jsonParser, async function (request, response) { @@ -744,7 +909,7 @@ router.post('/chats', jsonParser, async function (request, response) { const characterDirectory = (request.body.avatar_url).replace('.png', ''); try { - const chatsDirectory = path.join(DIRECTORIES.chats, characterDirectory); + const chatsDirectory = path.join(request.user.directories.chats, characterDirectory); const files = fs.readdirSync(chatsDirectory); const jsonFiles = files.filter(file => path.extname(file) === '.jsonl'); @@ -755,7 +920,7 @@ router.post('/chats', jsonParser, async function (request, response) { const jsonFilesPromise = jsonFiles.map((file) => { return new Promise(async (res) => { - const pathToFile = path.join(DIRECTORIES.chats, characterDirectory, file); + const pathToFile = path.join(request.user.directories.chats, characterDirectory, file); const fileStream = fs.createReadStream(pathToFile); const stats = fs.statSync(pathToFile); const fileSizeInKB = `${(stats.size / 1024).toFixed(2)}kb`; @@ -805,11 +970,17 @@ router.post('/chats', jsonParser, async function (request, response) { } }); -function getPngName(file) { +/** + * Gets the name for the uploaded PNG file. + * @param {string} file File name + * @param {import('../users').UserDirectoryList} directories User directories + * @returns {string} - The name for the uploaded PNG file + */ +function getPngName(file, directories) { let i = 1; - let base_name = file; - while (fs.existsSync(DIRECTORIES.characters + file + '.png')) { - file = base_name + i; + const baseName = file; + while (fs.existsSync(path.join(directories.characters, `${file}.png`))) { + file = baseName + i; i++; } return file; @@ -829,147 +1000,35 @@ function getPreservedName(request) { router.post('/import', urlencodedParser, async function (request, response) { if (!request.body || !request.file) return response.sendStatus(400); - let png_name = ''; - let filedata = request.file; - let uploadPath = path.join(UPLOADS_PATH, filedata.filename); - let format = request.body.file_type; + const uploadPath = path.join(UPLOADS_PATH, request.file.filename); + const format = request.body.file_type; const preservedFileName = getPreservedName(request); - if (format == 'yaml' || format == 'yml') { - try { - importFromYaml(uploadPath, response); - } catch (err) { - console.log(err); - response.send({ error: true }); + const formatImportFunctions = { + 'yaml': importFromYaml, + 'yml': importFromYaml, + 'json': importFromJson, + 'png': importFromPng, + }; + + try { + const importFunction = formatImportFunctions[format]; + + if (!importFunction) { + throw new Error(`Unsupported format: ${format}`); } - } else if (format == 'json') { - fs.readFile(uploadPath, 'utf8', async (err, data) => { - fs.unlinkSync(uploadPath); - if (err) { - console.log(err); - response.send({ error: true }); - } + const fileName = await importFunction(uploadPath, { request, response }, preservedFileName); - let jsonData = JSON.parse(data); - - if (jsonData.spec !== undefined) { - console.log('importing from v2 json'); - importRisuSprites(jsonData); - unsetFavFlag(jsonData); - jsonData = readFromV2(jsonData); - jsonData['create_date'] = humanizedISO8601DateTime(); - png_name = getPngName(jsonData.data?.name || jsonData.name); - let char = JSON.stringify(jsonData); - charaWrite(defaultAvatarPath, char, png_name, response, { file_name: png_name }); - } else if (jsonData.name !== undefined) { - console.log('importing from v1 json'); - jsonData.name = sanitize(jsonData.name); - if (jsonData.creator_notes) { - jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', ''); - } - png_name = getPngName(jsonData.name); - let char = { - 'name': jsonData.name, - 'description': jsonData.description ?? '', - 'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '', - 'personality': jsonData.personality ?? '', - 'first_mes': jsonData.first_mes ?? '', - 'avatar': 'none', - 'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(), - 'mes_example': jsonData.mes_example ?? '', - 'scenario': jsonData.scenario ?? '', - 'create_date': humanizedISO8601DateTime(), - 'talkativeness': jsonData.talkativeness ?? 0.5, - 'creator': jsonData.creator ?? '', - 'tags': jsonData.tags ?? '', - }; - char = convertToV2(char); - let charJSON = JSON.stringify(char); - charaWrite(defaultAvatarPath, charJSON, png_name, response, { file_name: png_name }); - } else if (jsonData.char_name !== undefined) {//json Pygmalion notepad - console.log('importing from gradio json'); - jsonData.char_name = sanitize(jsonData.char_name); - if (jsonData.creator_notes) { - jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', ''); - } - png_name = getPngName(jsonData.char_name); - let char = { - 'name': jsonData.char_name, - 'description': jsonData.char_persona ?? '', - 'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '', - 'personality': '', - 'first_mes': jsonData.char_greeting ?? '', - 'avatar': 'none', - 'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(), - 'mes_example': jsonData.example_dialogue ?? '', - 'scenario': jsonData.world_scenario ?? '', - 'create_date': humanizedISO8601DateTime(), - 'talkativeness': jsonData.talkativeness ?? 0.5, - 'creator': jsonData.creator ?? '', - 'tags': jsonData.tags ?? '', - }; - char = convertToV2(char); - let charJSON = JSON.stringify(char); - charaWrite(defaultAvatarPath, charJSON, png_name, response, { file_name: png_name }); - } else { - console.log('Incorrect character format .json'); - response.send({ error: true }); - } - }); - } else { - try { - var img_data = await charaRead(uploadPath, format); - if (img_data === undefined) throw new Error('Failed to read character data'); - - let jsonData = JSON.parse(img_data); - - jsonData.name = sanitize(jsonData.data?.name || jsonData.name); - png_name = preservedFileName || getPngName(jsonData.name); - - if (jsonData.spec !== undefined) { - console.log('Found a v2 character file.'); - importRisuSprites(jsonData); - unsetFavFlag(jsonData); - jsonData = readFromV2(jsonData); - jsonData['create_date'] = humanizedISO8601DateTime(); - const char = JSON.stringify(jsonData); - await charaWrite(uploadPath, char, png_name, response, { file_name: png_name }); - fs.unlinkSync(uploadPath); - } else if (jsonData.name !== undefined) { - console.log('Found a v1 character file.'); - - if (jsonData.creator_notes) { - jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', ''); - } - - let char = { - 'name': jsonData.name, - 'description': jsonData.description ?? '', - 'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '', - 'personality': jsonData.personality ?? '', - 'first_mes': jsonData.first_mes ?? '', - 'avatar': 'none', - 'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(), - 'mes_example': jsonData.mes_example ?? '', - 'scenario': jsonData.scenario ?? '', - 'create_date': humanizedISO8601DateTime(), - 'talkativeness': jsonData.talkativeness ?? 0.5, - 'creator': jsonData.creator ?? '', - 'tags': jsonData.tags ?? '', - }; - char = convertToV2(char); - const charJSON = JSON.stringify(char); - await charaWrite(uploadPath, charJSON, png_name, response, { file_name: png_name }); - fs.unlinkSync(uploadPath); - } else { - console.log('Unknown character card format'); - response.send({ error: true }); - } - } catch (err) { - console.log(err); - response.send({ error: true }); + if (!fileName) { + console.error('Failed to import character'); + return response.sendStatus(400); } + + response.send({ file_name: fileName }); + } catch (err) { + console.log(err); + response.send({ error: true }); } }); @@ -980,7 +1039,7 @@ router.post('/duplicate', jsonParser, async function (request, response) { console.log(request.body); return response.sendStatus(400); } - let filename = path.join(DIRECTORIES.characters, sanitize(request.body.avatar_url)); + let filename = path.join(request.user.directories.characters, sanitize(request.body.avatar_url)); if (!fs.existsSync(filename)) { console.log('file for dupe not found'); console.log(filename); @@ -1002,11 +1061,11 @@ router.post('/duplicate', jsonParser, async function (request, response) { baseName = nameParts.join('_'); // original filename is completely the baseName } - newFilename = path.join(DIRECTORIES.characters, `${baseName}_${suffix}${path.extname(filename)}`); + newFilename = path.join(request.user.directories.characters, `${baseName}_${suffix}${path.extname(filename)}`); while (fs.existsSync(newFilename)) { let suffixStr = '_' + suffix; - newFilename = path.join(DIRECTORIES.characters, `${baseName}${suffixStr}${path.extname(filename)}`); + newFilename = path.join(request.user.directories.characters, `${baseName}${suffixStr}${path.extname(filename)}`); suffix++; } @@ -1021,33 +1080,43 @@ router.post('/duplicate', jsonParser, async function (request, response) { }); router.post('/export', jsonParser, async function (request, response) { - if (!request.body.format || !request.body.avatar_url) { - return response.sendStatus(400); - } + try { + if (!request.body.format || !request.body.avatar_url) { + return response.sendStatus(400); + } - let filename = path.join(DIRECTORIES.characters, sanitize(request.body.avatar_url)); + let filename = path.join(request.user.directories.characters, sanitize(request.body.avatar_url)); - if (!fs.existsSync(filename)) { - return response.sendStatus(404); - } + if (!fs.existsSync(filename)) { + return response.sendStatus(404); + } - switch (request.body.format) { - case 'png': - return response.sendFile(filename, { root: process.cwd() }); - case 'json': { - try { - let json = await charaRead(filename); - if (json === undefined) return response.sendStatus(400); - let jsonObject = getCharaCardV2(JSON.parse(json)); - return response.type('json').send(JSON.stringify(jsonObject, null, 4)); + switch (request.body.format) { + case 'png': { + const fileContent = await fsPromises.readFile(filename); + const contentType = mime.lookup(filename) || 'image/png'; + response.setHeader('Content-Type', contentType); + response.setHeader('Content-Disposition', `attachment; filename=${path.basename(filename)}`); + return response.send(fileContent); } - catch { - return response.sendStatus(400); + case 'json': { + try { + let json = await readCharacterData(filename); + if (json === undefined) return response.sendStatus(400); + let jsonObject = getCharaCardV2(JSON.parse(json), request.user.directories); + return response.type('json').send(JSON.stringify(jsonObject, null, 4)); + } + catch { + return response.sendStatus(400); + } } } - } - return response.sendStatus(400); + return response.sendStatus(400); + } catch (err) { + console.error('Character export failed', err); + response.sendStatus(500); + } }); module.exports = { router }; diff --git a/src/endpoints/chats.js b/src/endpoints/chats.js index 99fce52e5..49cf98e01 100644 --- a/src/endpoints/chats.js +++ b/src/endpoints/chats.js @@ -6,7 +6,7 @@ const sanitize = require('sanitize-filename'); const writeFileAtomicSync = require('write-file-atomic').sync; const { jsonParser, urlencodedParser } = require('../express-common'); -const { DIRECTORIES, UPLOADS_PATH } = require('../constants'); +const { PUBLIC_DIRECTORIES, UPLOADS_PATH } = require('../constants'); const { getConfigValue, humanizedISO8601DateTime, tryParse, generateTimestamp, removeOldBackups } = require('../util'); /** @@ -22,14 +22,14 @@ function backupChat(name, chat) { return; } - if (!fs.existsSync(DIRECTORIES.backups)) { - fs.mkdirSync(DIRECTORIES.backups); + if (!fs.existsSync(PUBLIC_DIRECTORIES.backups)) { + fs.mkdirSync(PUBLIC_DIRECTORIES.backups); } // replace non-alphanumeric characters with underscores name = sanitize(name).replace(/[^a-z0-9]/gi, '_').toLowerCase(); - const backupFile = path.join(DIRECTORIES.backups, `chat_${name}_${generateTimestamp()}.jsonl`); + const backupFile = path.join(PUBLIC_DIRECTORIES.backups, `chat_${name}_${generateTimestamp()}.jsonl`); writeFileAtomicSync(backupFile, chat, 'utf-8'); removeOldBackups(`chat_${name}_`); @@ -38,18 +38,25 @@ function backupChat(name, chat) { } } -function importOobaChat(user_name, ch_name, jsonData, avatar_url) { +/** + * Imports a chat from Ooba's format. + * @param {string} userName User name + * @param {string} characterName Character name + * @param {object} jsonData JSON data + * @returns {string} Chat data + */ +function importOobaChat(userName, characterName, jsonData) { /** @type {object[]} */ const chat = [{ - user_name: user_name, - character_name: ch_name, + user_name: userName, + character_name: characterName, create_date: humanizedISO8601DateTime(), }]; for (const arr of jsonData.data_visible) { if (arr[0]) { const userMessage = { - name: user_name, + name: userName, is_user: true, send_date: humanizedISO8601DateTime(), mes: arr[0], @@ -58,7 +65,7 @@ function importOobaChat(user_name, ch_name, jsonData, avatar_url) { } if (arr[1]) { const charMessage = { - name: ch_name, + name: characterName, is_user: false, send_date: humanizedISO8601DateTime(), mes: arr[1], @@ -68,21 +75,28 @@ function importOobaChat(user_name, ch_name, jsonData, avatar_url) { } const chatContent = chat.map(obj => JSON.stringify(obj)).join('\n'); - writeFileAtomicSync(`${DIRECTORIES.chats + avatar_url}/${ch_name} - ${humanizedISO8601DateTime()} imported.jsonl`, chatContent, 'utf8'); + return chatContent; } -function importAgnaiChat(user_name, ch_name, jsonData, avatar_url) { +/** + * Imports a chat from Agnai's format. + * @param {string} userName User name + * @param {string} characterName Character name + * @param {object} jsonData Chat data + * @returns {string} Chat data + */ +function importAgnaiChat(userName, characterName, jsonData) { /** @type {object[]} */ const chat = [{ - user_name: user_name, - character_name: ch_name, + user_name: userName, + character_name: characterName, create_date: humanizedISO8601DateTime(), }]; for (const message of jsonData.messages) { const isUser = !!message.userId; chat.push({ - name: isUser ? user_name : ch_name, + name: isUser ? userName : characterName, is_user: isUser, send_date: humanizedISO8601DateTime(), mes: message.msg, @@ -90,60 +104,54 @@ function importAgnaiChat(user_name, ch_name, jsonData, avatar_url) { } const chatContent = chat.map(obj => JSON.stringify(obj)).join('\n'); - writeFileAtomicSync(`${DIRECTORIES.chats + avatar_url}/${ch_name} - ${humanizedISO8601DateTime()} imported.jsonl`, chatContent, 'utf8'); + return chatContent; } -function importCAIChat(user_name, ch_name, jsonData, avatar_url) { - const chat = { - from(history) { - return [ - { - user_name: user_name, - character_name: ch_name, - create_date: humanizedISO8601DateTime(), - }, - ...history.msgs.map( - (message) => ({ - name: message.src.is_human ? user_name : ch_name, - is_user: message.src.is_human, - send_date: humanizedISO8601DateTime(), - mes: message.text, - }), - ), - ]; - }, - }; +/** + * Imports a chat from CAI Tools format. + * @param {string} userName User name + * @param {string} characterName Character name + * @param {object} jsonData JSON data + * @returns {string[]} Converted data + */ +function importCAIChat(userName, characterName, jsonData) { + /** + * Converts the chat data to suitable format. + * @param {object} history Imported chat data + * @returns {object[]} Converted chat data + */ + function convert(history) { + const starter = { + user_name: userName, + character_name: characterName, + create_date: humanizedISO8601DateTime(), + }; - const newChats = []; - (jsonData.histories.histories ?? []).forEach((history) => { - newChats.push(chat.from(history)); - }); + const historyData = history.msgs.map((msg) => ({ + name: msg.src.is_human ? userName : characterName, + is_user: msg.src.is_human, + send_date: humanizedISO8601DateTime(), + mes: msg.text, + })); - const errors = []; - - for (const chat of newChats) { - const filePath = `${DIRECTORIES.chats + avatar_url}/${ch_name} - ${humanizedISO8601DateTime()} imported.jsonl`; - const fileContent = chat.map(tryParse).filter(x => x).join('\n'); - - try { - writeFileAtomicSync(filePath, fileContent, 'utf8'); - } catch (err) { - errors.push(err); - } + return [starter, ...historyData]; } - return errors; + const newChats = (jsonData.histories.histories ?? []).map(history => newChats.push(convert(history).map(obj => JSON.stringify(obj)).join('\n'))); + return newChats; } const router = express.Router(); router.post('/save', jsonParser, function (request, response) { try { - var dir_name = String(request.body.avatar_url).replace('.png', ''); - let chat_data = request.body.chat; - let jsonlData = chat_data.map(JSON.stringify).join('\n'); - writeFileAtomicSync(`${DIRECTORIES.chats + sanitize(dir_name)}/${sanitize(String(request.body.file_name))}.jsonl`, jsonlData, 'utf8'); - backupChat(dir_name, jsonlData); + const directoryName = String(request.body.avatar_url).replace('.png', ''); + const chatData = request.body.chat; + const jsonlData = chatData.map(JSON.stringify).join('\n'); + const fileName = `${sanitize(String(request.body.file_name))}.jsonl`; + const filePath = path.join(request.user.directories.chats, directoryName, fileName); + writeFileAtomicSync(filePath, jsonlData, 'utf8'); + backupChat(directoryName, jsonlData); return response.send({ result: 'ok' }); } catch (error) { response.send(error); @@ -154,11 +162,12 @@ router.post('/save', jsonParser, function (request, response) { router.post('/get', jsonParser, function (request, response) { try { const dirName = String(request.body.avatar_url).replace('.png', ''); - const chatDirExists = fs.existsSync(DIRECTORIES.chats + dirName); + const directoryPath = path.join(request.user.directories.chats, dirName); + const chatDirExists = fs.existsSync(directoryPath); //if no chat dir for the character is found, make one with the character name if (!chatDirExists) { - fs.mkdirSync(DIRECTORIES.chats + dirName); + fs.mkdirSync(directoryPath); return response.send({}); } @@ -166,7 +175,7 @@ router.post('/get', jsonParser, function (request, response) { return response.send({}); } - const fileName = `${DIRECTORIES.chats + dirName}/${sanitize(String(request.body.file_name))}.jsonl`; + const fileName = path.join(directoryPath, `${sanitize(String(request.body.file_name))}.jsonl`); const chatFileExists = fs.existsSync(fileName); if (!chatFileExists) { @@ -192,8 +201,8 @@ router.post('/rename', jsonParser, async function (request, response) { } const pathToFolder = request.body.is_group - ? DIRECTORIES.groupChats - : path.join(DIRECTORIES.chats, String(request.body.avatar_url).replace('.png', '')); + ? request.user.directories.groupChats + : path.join(request.user.directories.chats, String(request.body.avatar_url).replace('.png', '')); const pathToOriginalFile = path.join(pathToFolder, request.body.original_file); const pathToRenamedFile = path.join(pathToFolder, request.body.renamed_file); console.log('Old chat name', pathToOriginalFile); @@ -210,7 +219,6 @@ router.post('/rename', jsonParser, async function (request, response) { }); router.post('/delete', jsonParser, function (request, response) { - console.log('/api/chats/delete entered'); if (!request.body) { console.log('no request body seen'); return response.sendStatus(400); @@ -222,18 +230,15 @@ router.post('/delete', jsonParser, function (request, response) { } const dirName = String(request.body.avatar_url).replace('.png', ''); - const fileName = `${DIRECTORIES.chats + dirName}/${sanitize(String(request.body.chatfile))}`; + const fileName = path.join(request.user.directories.chats, dirName, sanitize(String(request.body.chatfile))); const chatFileExists = fs.existsSync(fileName); if (!chatFileExists) { console.log(`Chat file not found '${fileName}'`); return response.sendStatus(400); } else { - console.log('found the chat file: ' + fileName); - /* fs.unlinkSync(fileName); */ fs.rmSync(fileName); - console.log('deleted chat file: ' + fileName); - + console.log('Deleted chat file: ' + fileName); } return response.send('ok'); @@ -244,8 +249,8 @@ router.post('/export', jsonParser, async function (request, response) { return response.sendStatus(400); } const pathToFolder = request.body.is_group - ? DIRECTORIES.groupChats - : path.join(DIRECTORIES.chats, String(request.body.avatar_url).replace('.png', '')); + ? request.user.directories.groupChats + : path.join(request.user.directories.chats, String(request.body.avatar_url).replace('.png', '')); let filename = path.join(pathToFolder, request.body.file); let exportfilename = request.body.exportfilename; if (!fs.existsSync(filename)) { @@ -321,7 +326,7 @@ router.post('/group/import', urlencodedParser, function (request, response) { const chatname = humanizedISO8601DateTime(); const pathToUpload = path.join(UPLOADS_PATH, filedata.filename); - const pathToNewFile = path.join(DIRECTORIES.groupChats, `${chatname}.jsonl`); + const pathToNewFile = path.join(request.user.directories.groupChats, `${chatname}.jsonl`); fs.copyFileSync(pathToUpload, pathToNewFile); fs.unlinkSync(pathToUpload); return response.send({ res: chatname }); @@ -334,35 +339,42 @@ router.post('/group/import', urlencodedParser, function (request, response) { router.post('/import', urlencodedParser, function (request, response) { if (!request.body) return response.sendStatus(400); - var format = request.body.file_type; - let filedata = request.file; - let avatar_url = (request.body.avatar_url).replace('.png', ''); - let ch_name = request.body.character_name; - let user_name = request.body.user_name || 'You'; + const format = request.body.file_type; + const avatarUrl = (request.body.avatar_url).replace('.png', ''); + const characterName = request.body.character_name; + const userName = request.body.user_name || 'You'; - if (!filedata) { + if (!request.file) { return response.sendStatus(400); } try { - const data = fs.readFileSync(path.join(UPLOADS_PATH, filedata.filename), 'utf8'); + const data = fs.readFileSync(path.join(UPLOADS_PATH, request.file.filename), 'utf8'); if (format === 'json') { const jsonData = JSON.parse(data); if (jsonData.histories !== undefined) { // CAI Tools format - const errors = importCAIChat(user_name, ch_name, jsonData, avatar_url); - if (0 < errors.length) { - return response.send('Errors occurred while writing character files. Errors: ' + JSON.stringify(errors)); + const chats = importCAIChat(userName, characterName, jsonData); + for (const chat of chats) { + const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`; + const filePath = path.join(request.user.directories.chats, avatarUrl, fileName); + writeFileAtomicSync(filePath, chat, 'utf8'); } return response.send({ res: true }); } else if (Array.isArray(jsonData.data_visible)) { // oobabooga's format - importOobaChat(user_name, ch_name, jsonData, avatar_url); + const chat = importOobaChat(userName, characterName, jsonData); + const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`; + const filePath = path.join(request.user.directories.chats, avatarUrl, fileName); + writeFileAtomicSync(filePath, chat, 'utf8'); return response.send({ res: true }); } else if (Array.isArray(jsonData.messages)) { // Agnai format - importAgnaiChat(user_name, ch_name, jsonData, avatar_url); + const chat = importAgnaiChat(userName, characterName, jsonData); + const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`; + const filePath = path.join(request.user.directories.chats, avatarUrl, fileName); + writeFileAtomicSync(filePath, chat, 'utf8'); return response.send({ res: true }); } else { console.log('Incorrect chat format .json'); @@ -373,10 +385,12 @@ router.post('/import', urlencodedParser, function (request, response) { if (format === 'jsonl') { const line = data.split('\n')[0]; - let jsonData = JSON.parse(line); + const jsonData = JSON.parse(line); if (jsonData.user_name !== undefined || jsonData.name !== undefined) { - fs.copyFileSync(path.join(UPLOADS_PATH, filedata.filename), (`${DIRECTORIES.chats + avatar_url}/${ch_name} - ${humanizedISO8601DateTime()}.jsonl`)); + const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`; + const filePath = path.join(request.user.directories.chats, avatarUrl, fileName); + fs.copyFileSync(path.join(UPLOADS_PATH, request.file.filename), filePath); response.send({ res: true }); } else { console.log('Incorrect chat format .jsonl'); @@ -395,7 +409,7 @@ router.post('/group/get', jsonParser, (request, response) => { } const id = request.body.id; - const pathToFile = path.join(DIRECTORIES.groupChats, `${id}.jsonl`); + const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`); if (fs.existsSync(pathToFile)) { const data = fs.readFileSync(pathToFile, 'utf8'); @@ -415,7 +429,7 @@ router.post('/group/delete', jsonParser, (request, response) => { } const id = request.body.id; - const pathToFile = path.join(DIRECTORIES.groupChats, `${id}.jsonl`); + const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`); if (fs.existsSync(pathToFile)) { fs.rmSync(pathToFile); @@ -431,10 +445,10 @@ router.post('/group/save', jsonParser, (request, response) => { } const id = request.body.id; - const pathToFile = path.join(DIRECTORIES.groupChats, `${id}.jsonl`); + const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`); - if (!fs.existsSync(DIRECTORIES.groupChats)) { - fs.mkdirSync(DIRECTORIES.groupChats); + if (!fs.existsSync(request.user.directories.groupChats)) { + fs.mkdirSync(request.user.directories.groupChats); } let chat_data = request.body.chat; diff --git a/src/endpoints/content-manager.js b/src/endpoints/content-manager.js index bbb444faf..6d736b9a8 100644 --- a/src/endpoints/content-manager.js +++ b/src/endpoints/content-manager.js @@ -6,17 +6,44 @@ 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'); -const { DIRECTORIES } = require('../constants'); -const presetFolders = [DIRECTORIES.koboldAI_Settings, DIRECTORIES.openAI_Settings, DIRECTORIES.novelAI_Settings, DIRECTORIES.textGen_Settings]; const characterCardParser = require('../character-card-parser.js'); +/** + * @typedef {Object} ContentItem + * @property {string} filename + * @property {string} type + */ + +/** + * @typedef {string} ContentType + * @enum {string} + */ +const CONTENT_TYPES = { + SETTINGS: 'settings', + CHARACTER: 'character', + SPRITES: 'sprites', + BACKGROUND: 'background', + WORLD: 'world', + AVATAR: 'avatar', + THEME: 'theme', + WORKFLOW: 'workflow', + KOBOLD_PRESET: 'kobold_preset', + OPENAI_PRESET: 'openai_preset', + NOVEL_PRESET: 'novel_preset', + TEXTGEN_PRESET: 'textgen_preset', + INSTRUCT: 'instruct', + CONTEXT: 'context', + MOVING_UI: 'moving_ui', + QUICK_REPLIES: 'quick_replies', +}; + /** * Gets the default presets from the content directory. + * @param {import('../users').UserDirectoryList} directories User directories * @returns {object[]} Array of default presets */ -function getDefaultPresets() { +function getDefaultPresets(directories) { try { const contentIndexText = fs.readFileSync(contentIndexPath, 'utf8'); const contentIndex = JSON.parse(contentIndexText); @@ -26,7 +53,7 @@ function getDefaultPresets() { for (const contentItem of contentIndex) { if (contentItem.type.endsWith('_preset') || contentItem.type === 'instruct' || contentItem.type === 'context') { contentItem.name = path.parse(contentItem.filename).name; - contentItem.folder = getTargetByType(contentItem.type); + contentItem.folder = getTargetByType(contentItem.type, directories); presets.push(contentItem); } } @@ -59,116 +86,130 @@ function getDefaultPresetFile(filename) { } } -function migratePresets() { - for (const presetFolder of presetFolders) { - const presetPath = path.join(process.cwd(), presetFolder); - const presetFiles = fs.readdirSync(presetPath); - - for (const presetFile of presetFiles) { - const presetFilePath = path.join(presetPath, presetFile); - const newFileName = presetFile.replace('.settings', '.json'); - const newFilePath = path.join(presetPath, newFileName); - const backupFileName = presetFolder.replace('/', '_') + '_' + presetFile; - const backupFilePath = path.join(DIRECTORIES.backups, backupFileName); - - if (presetFilePath.endsWith('.settings')) { - if (!fs.existsSync(newFilePath)) { - fs.cpSync(presetFilePath, backupFilePath); - fs.cpSync(presetFilePath, newFilePath); - console.log(`Migrated ${presetFilePath} to ${newFilePath}`); - } - } - } +/** + * Seeds content for a user. + * @param {ContentItem[]} contentIndex Content index + * @param {import('../users').UserDirectoryList} directories User directories + * @param {string[]} forceCategories List of categories to force check (even if content check is skipped) + */ +async function seedContentForUser(contentIndex, directories, forceCategories) { + if (!fs.existsSync(directories.root)) { + fs.mkdirSync(directories.root, { recursive: true }); } + + const contentLogPath = path.join(directories.root, 'content.log'); + const contentLog = getContentLog(contentLogPath); + + for (const contentItem of contentIndex) { + // If the content item is already in the log, skip it + if (contentLog.includes(contentItem.filename) && !forceCategories?.includes(contentItem.type)) { + continue; + } + + contentLog.push(contentItem.filename); + const contentPath = path.join(contentDirectory, contentItem.filename); + + if (!fs.existsSync(contentPath)) { + console.log(`Content file ${contentItem.filename} is missing`); + continue; + } + + const contentTarget = getTargetByType(contentItem.type, directories); + + if (!contentTarget) { + console.log(`Content file ${contentItem.filename} has unknown type ${contentItem.type}`); + continue; + } + + const basePath = path.parse(contentItem.filename).base; + const targetPath = path.join(contentTarget, basePath); + + if (fs.existsSync(targetPath)) { + console.log(`Content file ${contentItem.filename} already exists in ${contentTarget}`); + continue; + } + + fs.cpSync(contentPath, targetPath, { recursive: true, force: false }); + console.log(`Content file ${contentItem.filename} copied to ${contentTarget}`); + } + + fs.writeFileSync(contentLogPath, contentLog.join('\n')); } -function checkForNewContent() { +/** + * Checks for new content and seeds it for all users. + * @param {import('../users').UserDirectoryList[]} directoriesList List of user directories + * @param {string[]} forceCategories List of categories to force check (even if content check is skipped) + * @returns {Promise} + */ +async function checkForNewContent(directoriesList, forceCategories = []) { try { - migratePresets(); - - if (getConfigValue('skipContentCheck', false)) { + if (getConfigValue('skipContentCheck', false) && forceCategories?.length === 0) { return; } - const contentLog = getContentLog(); const contentIndexText = fs.readFileSync(contentIndexPath, 'utf8'); const contentIndex = JSON.parse(contentIndexText); - for (const contentItem of contentIndex) { - // If the content item is already in the log, skip it - if (contentLog.includes(contentItem.filename)) { - continue; - } - - contentLog.push(contentItem.filename); - const contentPath = path.join(contentDirectory, contentItem.filename); - - if (!fs.existsSync(contentPath)) { - console.log(`Content file ${contentItem.filename} is missing`); - continue; - } - - const contentTarget = getTargetByType(contentItem.type); - - if (!contentTarget) { - console.log(`Content file ${contentItem.filename} has unknown type ${contentItem.type}`); - continue; - } - - const basePath = path.parse(contentItem.filename).base; - const targetPath = path.join(process.cwd(), contentTarget, basePath); - - if (fs.existsSync(targetPath)) { - console.log(`Content file ${contentItem.filename} already exists in ${contentTarget}`); - continue; - } - - fs.cpSync(contentPath, targetPath, { recursive: true, force: false }); - console.log(`Content file ${contentItem.filename} copied to ${contentTarget}`); + for (const directories of directoriesList) { + await seedContentForUser(contentIndex, directories, forceCategories); } - - fs.writeFileSync(contentLogPath, contentLog.join('\n')); } catch (err) { console.log('Content check failed', err); } } -function getTargetByType(type) { +/** + * Gets the target directory for the specified asset type. + * @param {ContentType} type Asset type + * @param {import('../users').UserDirectoryList} directories User directories + * @returns {string | null} Target directory + */ +function getTargetByType(type, directories) { switch (type) { - case 'character': - return DIRECTORIES.characters; - case 'sprites': - return DIRECTORIES.characters; - case 'background': - return DIRECTORIES.backgrounds; - case 'world': - return DIRECTORIES.worlds; - case 'sound': - return DIRECTORIES.sounds; - case 'avatar': - return DIRECTORIES.avatars; - case 'theme': - return DIRECTORIES.themes; - case 'workflow': - return DIRECTORIES.comfyWorkflows; - case 'kobold_preset': - return DIRECTORIES.koboldAI_Settings; - case 'openai_preset': - return DIRECTORIES.openAI_Settings; - case 'novel_preset': - return DIRECTORIES.novelAI_Settings; - case 'textgen_preset': - return DIRECTORIES.textGen_Settings; - case 'instruct': - return DIRECTORIES.instruct; - case 'context': - return DIRECTORIES.context; + case CONTENT_TYPES.SETTINGS: + return directories.root; + case CONTENT_TYPES.CHARACTER: + return directories.characters; + case CONTENT_TYPES.SPRITES: + return directories.characters; + case CONTENT_TYPES.BACKGROUND: + return directories.backgrounds; + case CONTENT_TYPES.WORLD: + return directories.worlds; + case CONTENT_TYPES.AVATAR: + return directories.avatars; + case CONTENT_TYPES.THEME: + return directories.themes; + case CONTENT_TYPES.WORKFLOW: + return directories.comfyWorkflows; + case CONTENT_TYPES.KOBOLD_PRESET: + return directories.koboldAI_Settings; + case CONTENT_TYPES.OPENAI_PRESET: + return directories.openAI_Settings; + case CONTENT_TYPES.NOVEL_PRESET: + return directories.novelAI_Settings; + case CONTENT_TYPES.TEXTGEN_PRESET: + return directories.textGen_Settings; + case CONTENT_TYPES.INSTRUCT: + return directories.instruct; + case CONTENT_TYPES.CONTEXT: + return directories.context; + case CONTENT_TYPES.MOVING_UI: + return directories.movingUI; + case CONTENT_TYPES.QUICK_REPLIES: + return directories.quickreplies; default: return null; } } -function getContentLog() { +/** + * Gets the content log from the content log file. + * @param {string} contentLogPath Path to the content log file + * @returns {string[]} Array of content log lines + */ +function getContentLog(contentLogPath) { if (!fs.existsSync(contentLogPath)) { return []; } @@ -461,6 +502,7 @@ router.post('/importUUID', jsonParser, async (request, response) => { }); module.exports = { + CONTENT_TYPES, checkForNewContent, getDefaultPresets, getDefaultPresetFile, diff --git a/src/endpoints/extensions.js b/src/endpoints/extensions.js index 9aaf93a3c..d14ddb8cf 100644 --- a/src/endpoints/extensions.js +++ b/src/endpoints/extensions.js @@ -3,7 +3,7 @@ const fs = require('fs'); const express = require('express'); const { default: simpleGit } = require('simple-git'); const sanitize = require('sanitize-filename'); -const { DIRECTORIES } = require('../constants'); +const { PUBLIC_DIRECTORIES } = require('../constants'); const { jsonParser } = require('../express-common'); /** @@ -67,12 +67,12 @@ router.post('/install', jsonParser, async (request, response) => { 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')); + if (!fs.existsSync(path.join(request.user.directories.extensions))) { + fs.mkdirSync(path.join(request.user.directories.extensions)); } const url = request.body.url; - const extensionPath = path.join(DIRECTORIES.extensions, 'third-party', path.basename(url, '.git')); + const extensionPath = path.join(request.user.directories.extensions, path.basename(url, '.git')); if (fs.existsSync(extensionPath)) { return response.status(409).send(`Directory already exists at ${extensionPath}`); @@ -111,7 +111,7 @@ router.post('/update', jsonParser, async (request, response) => { try { const extensionName = request.body.extensionName; - const extensionPath = path.join(DIRECTORIES.extensions, 'third-party', extensionName); + const extensionPath = path.join(request.user.directories.extensions, extensionName); if (!fs.existsSync(extensionPath)) { return response.status(404).send(`Directory does not exist at ${extensionPath}`); @@ -156,7 +156,7 @@ router.post('/version', jsonParser, async (request, response) => { try { const extensionName = request.body.extensionName; - const extensionPath = path.join(DIRECTORIES.extensions, 'third-party', extensionName); + const extensionPath = path.join(request.user.directories.extensions, extensionName); if (!fs.existsSync(extensionPath)) { return response.status(404).send(`Directory does not exist at ${extensionPath}`); @@ -195,7 +195,7 @@ router.post('/delete', jsonParser, async (request, response) => { const extensionName = sanitize(request.body.extensionName); try { - const extensionPath = path.join(DIRECTORIES.extensions, 'third-party', extensionName); + const extensionPath = path.join(request.user.directories.extensions, extensionName); if (!fs.existsSync(extensionPath)) { return response.status(404).send(`Directory does not exist at ${extensionPath}`); @@ -216,22 +216,22 @@ router.post('/delete', jsonParser, async (request, response) => { * Discover the extension folders * If the folder is called third-party, search for subfolders instead */ -router.get('/discover', jsonParser, function (_, response) { +router.get('/discover', jsonParser, function (request, 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()) + .readdirSync(PUBLIC_DIRECTORIES.extensions) + .filter(f => fs.statSync(path.join(PUBLIC_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'))) { + if (!fs.existsSync(path.join(request.user.directories.extensions))) { return response.send(extensions); } const thirdPartyExtensions = fs - .readdirSync(path.join(DIRECTORIES.extensions, 'third-party')) - .filter(f => fs.statSync(path.join(DIRECTORIES.extensions, 'third-party', f)).isDirectory()); + .readdirSync(path.join(request.user.directories.extensions)) + .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory()); // add the third-party extensions to the extensions array extensions.push(...thirdPartyExtensions.map(f => `third-party/${f}`)); diff --git a/src/endpoints/files.js b/src/endpoints/files.js index fb8dd4f93..d011ae2f8 100644 --- a/src/endpoints/files.js +++ b/src/endpoints/files.js @@ -4,7 +4,6 @@ const express = require('express'); const router = express.Router(); const { validateAssetFileName } = require('./assets'); const { jsonParser } = require('../express-common'); -const { DIRECTORIES } = require('../constants'); const { clientRelativePath } = require('../util'); router.post('/upload', jsonParser, async (request, response) => { @@ -22,9 +21,9 @@ router.post('/upload', jsonParser, async (request, response) => { if (validation.error) return response.status(400).send(validation.message); - const pathToUpload = path.join(DIRECTORIES.files, request.body.name); + const pathToUpload = path.join(request.user.directories.files, request.body.name); writeFileSyncAtomic(pathToUpload, request.body.data, 'base64'); - const url = clientRelativePath(pathToUpload); + const url = clientRelativePath(request.user.directories.root, pathToUpload); return response.send({ path: url }); } catch (error) { console.log(error); diff --git a/src/endpoints/google.js b/src/endpoints/google.js index 010b6f0ea..65c67d0cd 100644 --- a/src/endpoints/google.js +++ b/src/endpoints/google.js @@ -10,7 +10,8 @@ router.post('/caption-image', jsonParser, async (request, response) => { try { const mimeType = request.body.image.split(';')[0].split(':')[1]; const base64Data = request.body.image.split(',')[1]; - const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent?key=${readSecret(SECRET_KEYS.MAKERSUITE)}`; + const key = readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE); + const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent?key=${key}`; const body = { contents: [{ parts: [ diff --git a/src/endpoints/groups.js b/src/endpoints/groups.js index ea2cc7377..a69ac9b1c 100644 --- a/src/endpoints/groups.js +++ b/src/endpoints/groups.js @@ -5,24 +5,23 @@ const sanitize = require('sanitize-filename'); const writeFileAtomicSync = require('write-file-atomic').sync; const { jsonParser } = require('../express-common'); -const { DIRECTORIES } = require('../constants'); const { humanizedISO8601DateTime } = require('../util'); const router = express.Router(); -router.post('/all', jsonParser, (_, response) => { +router.post('/all', jsonParser, (request, response) => { const groups = []; - if (!fs.existsSync(DIRECTORIES.groups)) { - fs.mkdirSync(DIRECTORIES.groups); + if (!fs.existsSync(request.user.directories.groups)) { + fs.mkdirSync(request.user.directories.groups); } - const files = fs.readdirSync(DIRECTORIES.groups).filter(x => path.extname(x) === '.json'); - const chats = fs.readdirSync(DIRECTORIES.groupChats).filter(x => path.extname(x) === '.jsonl'); + const files = fs.readdirSync(request.user.directories.groups).filter(x => path.extname(x) === '.json'); + const chats = fs.readdirSync(request.user.directories.groupChats).filter(x => path.extname(x) === '.jsonl'); files.forEach(function (file) { try { - const filePath = path.join(DIRECTORIES.groups, file); + const filePath = path.join(request.user.directories.groups, file); const fileContents = fs.readFileSync(filePath, 'utf8'); const group = JSON.parse(fileContents); const groupStat = fs.statSync(filePath); @@ -35,7 +34,7 @@ router.post('/all', jsonParser, (_, response) => { if (Array.isArray(group.chats) && Array.isArray(chats)) { for (const chat of chats) { if (group.chats.includes(path.parse(chat).name)) { - const chatStat = fs.statSync(path.join(DIRECTORIES.groupChats, chat)); + const chatStat = fs.statSync(path.join(request.user.directories.groupChats, chat)); chat_size += chatStat.size; date_last_chat = Math.max(date_last_chat, chatStat.mtimeMs); } @@ -77,11 +76,11 @@ router.post('/create', jsonParser, (request, response) => { generation_mode_join_prefix: request.body.generation_mode_join_prefix ?? '', generation_mode_join_suffix: request.body.generation_mode_join_suffix ?? '', }; - const pathToFile = path.join(DIRECTORIES.groups, `${id}.json`); + const pathToFile = path.join(request.user.directories.groups, `${id}.json`); const fileData = JSON.stringify(groupMetadata); - if (!fs.existsSync(DIRECTORIES.groups)) { - fs.mkdirSync(DIRECTORIES.groups); + if (!fs.existsSync(request.user.directories.groups)) { + fs.mkdirSync(request.user.directories.groups); } writeFileAtomicSync(pathToFile, fileData); @@ -93,7 +92,7 @@ router.post('/edit', jsonParser, (request, response) => { return response.sendStatus(400); } const id = request.body.id; - const pathToFile = path.join(DIRECTORIES.groups, `${id}.json`); + const pathToFile = path.join(request.user.directories.groups, `${id}.json`); const fileData = JSON.stringify(request.body); writeFileAtomicSync(pathToFile, fileData); @@ -106,7 +105,7 @@ router.post('/delete', jsonParser, async (request, response) => { } const id = request.body.id; - const pathToGroup = path.join(DIRECTORIES.groups, sanitize(`${id}.json`)); + const pathToGroup = path.join(request.user.directories.groups, sanitize(`${id}.json`)); try { // Delete group chats @@ -115,7 +114,7 @@ router.post('/delete', jsonParser, async (request, response) => { if (group && Array.isArray(group.chats)) { for (const chat of group.chats) { console.log('Deleting group chat', chat); - const pathToFile = path.join(DIRECTORIES.groupChats, `${id}.jsonl`); + const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`); if (fs.existsSync(pathToFile)) { fs.rmSync(pathToFile); diff --git a/src/endpoints/horde.js b/src/endpoints/horde.js index 3a6f8efe6..32cb08f7b 100644 --- a/src/endpoints/horde.js +++ b/src/endpoints/horde.js @@ -159,7 +159,7 @@ router.post('/task-status', jsonParser, async (request, response) => { }); router.post('/generate-text', jsonParser, async (request, response) => { - const apiKey = readSecret(SECRET_KEYS.HORDE) || ANONYMOUS_KEY; + const apiKey = readSecret(request.user.directories, SECRET_KEYS.HORDE) || ANONYMOUS_KEY; const url = 'https://horde.koboldai.net/api/v2/generate/text/async'; const agent = await getClientAgent(); @@ -213,7 +213,7 @@ router.post('/sd-models', jsonParser, async (_, response) => { router.post('/caption-image', jsonParser, async (request, response) => { try { - const api_key_horde = readSecret(SECRET_KEYS.HORDE) || ANONYMOUS_KEY; + const api_key_horde = readSecret(request.user.directories, SECRET_KEYS.HORDE) || ANONYMOUS_KEY; const ai_horde = await getHordeClient(); const result = await ai_horde.postAsyncInterrogate({ source_image: request.body.image, @@ -263,8 +263,8 @@ router.post('/caption-image', jsonParser, async (request, response) => { } }); -router.post('/user-info', jsonParser, async (_, response) => { - const api_key_horde = readSecret(SECRET_KEYS.HORDE); +router.post('/user-info', jsonParser, async (request, response) => { + const api_key_horde = readSecret(request.user.directories, SECRET_KEYS.HORDE); if (!api_key_horde) { return response.send({ anonymous: true }); @@ -307,7 +307,7 @@ router.post('/generate-image', jsonParser, async (request, response) => { request.body.prompt = sanitized; } - const api_key_horde = readSecret(SECRET_KEYS.HORDE) || ANONYMOUS_KEY; + const api_key_horde = readSecret(request.user.directories, SECRET_KEYS.HORDE) || ANONYMOUS_KEY; console.log('Stable Horde request:', request.body); const ai_horde = await getHordeClient(); diff --git a/src/endpoints/images.js b/src/endpoints/images.js index e0f458c35..a01e34073 100644 --- a/src/endpoints/images.js +++ b/src/endpoints/images.js @@ -4,7 +4,6 @@ const express = require('express'); const sanitize = require('sanitize-filename'); const { jsonParser } = require('../express-common'); -const { DIRECTORIES } = require('../constants'); const { clientRelativePath, removeFileExtension, getImages } = require('../util'); /** @@ -60,23 +59,23 @@ router.post('/upload', jsonParser, async (request, response) => { } // if character is defined, save to a sub folder for that character - let pathToNewFile = path.join(DIRECTORIES.userImages, sanitize(filename)); + let pathToNewFile = path.join(request.user.directories.userImages, sanitize(filename)); if (request.body.ch_name) { - pathToNewFile = path.join(DIRECTORIES.userImages, sanitize(request.body.ch_name), sanitize(filename)); + pathToNewFile = path.join(request.user.directories.userImages, sanitize(request.body.ch_name), sanitize(filename)); } ensureDirectoryExistence(pathToNewFile); const imageBuffer = Buffer.from(base64Data, 'base64'); await fs.promises.writeFile(pathToNewFile, imageBuffer); - response.send({ path: clientRelativePath(pathToNewFile) }); + response.send({ path: clientRelativePath(request.user.directories.root, pathToNewFile) }); } catch (error) { console.log(error); response.status(500).send({ error: 'Failed to save the image' }); } }); -router.post('/list/:folder', (req, res) => { - const directoryPath = path.join(process.cwd(), DIRECTORIES.userImages, sanitize(req.params.folder)); +router.post('/list/:folder', (request, response) => { + const directoryPath = path.join(request.user.directories.userImages, sanitize(request.params.folder)); if (!fs.existsSync(directoryPath)) { fs.mkdirSync(directoryPath, { recursive: true }); @@ -84,10 +83,10 @@ router.post('/list/:folder', (req, res) => { try { const images = getImages(directoryPath); - return res.send(images); + return response.send(images); } catch (error) { console.error(error); - return res.status(500).send({ error: 'Unable to retrieve files' }); + return response.status(500).send({ error: 'Unable to retrieve files' }); } }); diff --git a/src/endpoints/moving-ui.js b/src/endpoints/moving-ui.js index c095c7a11..e3bba3e6c 100644 --- a/src/endpoints/moving-ui.js +++ b/src/endpoints/moving-ui.js @@ -4,7 +4,6 @@ const sanitize = require('sanitize-filename'); const writeFileAtomicSync = require('write-file-atomic').sync; const { jsonParser } = require('../express-common'); -const { DIRECTORIES } = require('../constants'); const router = express.Router(); @@ -13,7 +12,7 @@ router.post('/save', jsonParser, (request, response) => { return response.sendStatus(400); } - const filename = path.join(DIRECTORIES.movingUI, sanitize(request.body.name) + '.json'); + const filename = path.join(request.user.directories.movingUI, sanitize(request.body.name) + '.json'); writeFileAtomicSync(filename, JSON.stringify(request.body, null, 4), 'utf8'); return response.sendStatus(200); diff --git a/src/endpoints/novelai.js b/src/endpoints/novelai.js index e51f9c93a..2b018699f 100644 --- a/src/endpoints/novelai.js +++ b/src/endpoints/novelai.js @@ -66,7 +66,7 @@ const router = express.Router(); router.post('/status', jsonParser, async function (req, res) { if (!req.body) return res.sendStatus(400); - const api_key_novel = readSecret(SECRET_KEYS.NOVEL); + const api_key_novel = readSecret(req.user.directories, SECRET_KEYS.NOVEL); if (!api_key_novel) { console.log('NovelAI Access Token is missing.'); @@ -102,7 +102,7 @@ router.post('/status', jsonParser, async function (req, res) { router.post('/generate', jsonParser, async function (req, res) { if (!req.body) return res.sendStatus(400); - const api_key_novel = readSecret(SECRET_KEYS.NOVEL); + const api_key_novel = readSecret(req.user.directories, SECRET_KEYS.NOVEL); if (!api_key_novel) { console.log('NovelAI Access Token is missing.'); @@ -230,7 +230,7 @@ router.post('/generate-image', jsonParser, async (request, response) => { return response.sendStatus(400); } - const key = readSecret(SECRET_KEYS.NOVEL); + const key = readSecret(request.user.directories, SECRET_KEYS.NOVEL); if (!key) { console.log('NovelAI Access Token is missing.'); @@ -325,7 +325,7 @@ router.post('/generate-image', jsonParser, async (request, response) => { }); router.post('/generate-voice', jsonParser, async (request, response) => { - const token = readSecret(SECRET_KEYS.NOVEL); + const token = readSecret(request.user.directories, SECRET_KEYS.NOVEL); if (!token) { console.log('NovelAI Access Token is missing.'); diff --git a/src/endpoints/openai.js b/src/endpoints/openai.js index f8803cfec..9488d03e6 100644 --- a/src/endpoints/openai.js +++ b/src/endpoints/openai.js @@ -16,11 +16,11 @@ router.post('/caption-image', jsonParser, async (request, response) => { let bodyParams = {}; if (request.body.api === 'openai' && !request.body.reverse_proxy) { - key = readSecret(SECRET_KEYS.OPENAI); + key = readSecret(request.user.directories, SECRET_KEYS.OPENAI); } if (request.body.api === 'openrouter' && !request.body.reverse_proxy) { - key = readSecret(SECRET_KEYS.OPENROUTER); + key = readSecret(request.user.directories, SECRET_KEYS.OPENROUTER); } if (request.body.reverse_proxy && request.body.proxy_password) { @@ -28,18 +28,18 @@ router.post('/caption-image', jsonParser, async (request, response) => { } if (request.body.api === 'custom') { - key = readSecret(SECRET_KEYS.CUSTOM); + key = readSecret(request.user.directories, SECRET_KEYS.CUSTOM); mergeObjectWithYaml(bodyParams, request.body.custom_include_body); mergeObjectWithYaml(headers, request.body.custom_include_headers); } if (request.body.api === 'ooba') { - key = readSecret(SECRET_KEYS.OOBA); + key = readSecret(request.user.directories, SECRET_KEYS.OOBA); bodyParams.temperature = 0.1; } if (request.body.api === 'koboldcpp') { - key = readSecret(SECRET_KEYS.KOBOLDCPP); + key = readSecret(request.user.directories, SECRET_KEYS.KOBOLDCPP); } if (!key && !request.body.reverse_proxy && ['custom', 'ooba', 'koboldcpp'].includes(request.body.api) === false) { @@ -150,7 +150,7 @@ router.post('/caption-image', jsonParser, async (request, response) => { router.post('/transcribe-audio', urlencodedParser, async (request, response) => { try { - const key = readSecret(SECRET_KEYS.OPENAI); + const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI); if (!key) { console.log('No OpenAI key found'); @@ -198,7 +198,7 @@ router.post('/transcribe-audio', urlencodedParser, async (request, response) => router.post('/generate-voice', jsonParser, async (request, response) => { try { - const key = readSecret(SECRET_KEYS.OPENAI); + const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI); if (!key) { console.log('No OpenAI key found'); @@ -237,7 +237,7 @@ router.post('/generate-voice', jsonParser, async (request, response) => { router.post('/generate-image', jsonParser, async (request, response) => { try { - const key = readSecret(SECRET_KEYS.OPENAI); + const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI); if (!key) { console.log('No OpenAI key found'); diff --git a/src/endpoints/presets.js b/src/endpoints/presets.js index 0c2f15a21..ed90275cb 100644 --- a/src/endpoints/presets.js +++ b/src/endpoints/presets.js @@ -3,30 +3,30 @@ 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. * @param {string} apiId API source ID + * @param {import('../users').UserDirectoryList} directories User directories * @returns {object} Object containing the folder and extension for the preset settings */ -function getPresetSettingsByAPI(apiId) { +function getPresetSettingsByAPI(apiId, directories) { switch (apiId) { case 'kobold': case 'koboldhorde': - return { folder: DIRECTORIES.koboldAI_Settings, extension: '.json' }; + return { folder: directories.koboldAI_Settings, extension: '.json' }; case 'novel': - return { folder: DIRECTORIES.novelAI_Settings, extension: '.json' }; + return { folder: directories.novelAI_Settings, extension: '.json' }; case 'textgenerationwebui': - return { folder: DIRECTORIES.textGen_Settings, extension: '.json' }; + return { folder: directories.textGen_Settings, extension: '.json' }; case 'openai': - return { folder: DIRECTORIES.openAI_Settings, extension: '.json' }; + return { folder: directories.openAI_Settings, extension: '.json' }; case 'instruct': - return { folder: DIRECTORIES.instruct, extension: '.json' }; + return { folder: directories.instruct, extension: '.json' }; case 'context': - return { folder: DIRECTORIES.context, extension: '.json' }; + return { folder: directories.context, extension: '.json' }; default: return { folder: null, extension: null }; } @@ -40,7 +40,7 @@ router.post('/save', jsonParser, function (request, response) { return response.sendStatus(400); } - const settings = getPresetSettingsByAPI(request.body.apiId); + const settings = getPresetSettingsByAPI(request.body.apiId, request.user.directories); const filename = name + settings.extension; if (!settings.folder) { @@ -58,7 +58,7 @@ router.post('/delete', jsonParser, function (request, response) { return response.sendStatus(400); } - const settings = getPresetSettingsByAPI(request.body.apiId); + const settings = getPresetSettingsByAPI(request.body.apiId, request.user.directories); const filename = name + settings.extension; if (!settings.folder) { @@ -77,9 +77,9 @@ router.post('/delete', jsonParser, function (request, response) { router.post('/restore', jsonParser, function (request, response) { try { - const settings = getPresetSettingsByAPI(request.body.apiId); + const settings = getPresetSettingsByAPI(request.body.apiId, request.user.directories); const name = sanitize(request.body.name); - const defaultPresets = getDefaultPresets(); + const defaultPresets = getDefaultPresets(request.user.directories); const defaultPreset = defaultPresets.find(p => p.name === name && p.folder === settings.folder); @@ -104,7 +104,7 @@ router.post('/save-openai', jsonParser, function (request, response) { if (!name) return response.sendStatus(400); const filename = `${name}.json`; - const fullpath = path.join(DIRECTORIES.openAI_Settings, filename); + const fullpath = path.join(request.user.directories.openAI_Settings, filename); writeFileAtomicSync(fullpath, JSON.stringify(request.body, null, 4), 'utf-8'); return response.send({ name }); }); @@ -116,7 +116,7 @@ router.post('/delete-openai', jsonParser, function (request, response) { } const name = request.body.name; - const pathToFile = path.join(DIRECTORIES.openAI_Settings, `${name}.json`); + const pathToFile = path.join(request.user.directories.openAI_Settings, `${name}.json`); if (fs.existsSync(pathToFile)) { fs.rmSync(pathToFile); diff --git a/src/endpoints/quick-replies.js b/src/endpoints/quick-replies.js index c5921ad67..ed53985f2 100644 --- a/src/endpoints/quick-replies.js +++ b/src/endpoints/quick-replies.js @@ -5,7 +5,6 @@ const sanitize = require('sanitize-filename'); const writeFileAtomicSync = require('write-file-atomic').sync; const { jsonParser } = require('../express-common'); -const { DIRECTORIES } = require('../constants'); const router = express.Router(); @@ -14,7 +13,7 @@ router.post('/save', jsonParser, (request, response) => { return response.sendStatus(400); } - const filename = path.join(DIRECTORIES.quickreplies, sanitize(request.body.name) + '.json'); + const filename = path.join(request.user.directories.quickreplies, sanitize(request.body.name) + '.json'); writeFileAtomicSync(filename, JSON.stringify(request.body, null, 4), 'utf8'); return response.sendStatus(200); @@ -25,7 +24,7 @@ router.post('/delete', jsonParser, (request, response) => { return response.sendStatus(400); } - const filename = path.join(DIRECTORIES.quickreplies, sanitize(request.body.name) + '.json'); + const filename = path.join(request.user.directories.quickreplies, sanitize(request.body.name) + '.json'); if (fs.existsSync(filename)) { fs.unlinkSync(filename); } diff --git a/src/endpoints/secrets.js b/src/endpoints/secrets.js index afd41a1f7..27fff27a7 100644 --- a/src/endpoints/secrets.js +++ b/src/endpoints/secrets.js @@ -5,7 +5,7 @@ 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 SECRETS_FILE = 'secrets.json'; const SECRET_KEYS = { HORDE: 'api_key_horde', MANCER: 'api_key_mancer', @@ -48,57 +48,74 @@ const EXPORTABLE_KEYS = [ /** * Writes a secret to the secrets file + * @param {import('../users').UserDirectoryList} directories User directories * @param {string} key Secret key * @param {string} value Secret value */ -function writeSecret(key, value) { - if (!fs.existsSync(SECRETS_FILE)) { +function writeSecret(directories, key, value) { + const filePath = path.join(directories.root, SECRETS_FILE); + + if (!fs.existsSync(filePath)) { const emptyFile = JSON.stringify({}); - writeFileAtomicSync(SECRETS_FILE, emptyFile, 'utf-8'); + writeFileAtomicSync(filePath, emptyFile, 'utf-8'); } - const fileContents = fs.readFileSync(SECRETS_FILE, 'utf-8'); + const fileContents = fs.readFileSync(filePath, 'utf-8'); const secrets = JSON.parse(fileContents); secrets[key] = value; - writeFileAtomicSync(SECRETS_FILE, JSON.stringify(secrets, null, 4), 'utf-8'); + writeFileAtomicSync(filePath, JSON.stringify(secrets, null, 4), 'utf-8'); } -function deleteSecret(key) { - if (!fs.existsSync(SECRETS_FILE)) { +/** + * Deletes a secret from the secrets file + * @param {import('../users').UserDirectoryList} directories User directories + * @param {string} key Secret key + * @returns + */ +function deleteSecret(directories, key) { + const filePath = path.join(directories.root, SECRETS_FILE); + + if (!fs.existsSync(filePath)) { return; } - const fileContents = fs.readFileSync(SECRETS_FILE, 'utf-8'); + const fileContents = fs.readFileSync(filePath, 'utf-8'); const secrets = JSON.parse(fileContents); delete secrets[key]; - writeFileAtomicSync(SECRETS_FILE, JSON.stringify(secrets, null, 4), 'utf-8'); + writeFileAtomicSync(filePath, JSON.stringify(secrets, null, 4), 'utf-8'); } /** * Reads a secret from the secrets file + * @param {import('../users').UserDirectoryList} directories User directories * @param {string} key Secret key * @returns {string} Secret value */ -function readSecret(key) { - if (!fs.existsSync(SECRETS_FILE)) { +function readSecret(directories, key) { + const filePath = path.join(directories.root, SECRETS_FILE); + + if (!fs.existsSync(filePath)) { return ''; } - const fileContents = fs.readFileSync(SECRETS_FILE, 'utf-8'); + const fileContents = fs.readFileSync(filePath, 'utf-8'); const secrets = JSON.parse(fileContents); return secrets[key]; } /** * Reads the secret state from the secrets file + * @param {import('../users').UserDirectoryList} directories User directories * @returns {object} Secret state */ -function readSecretState() { - if (!fs.existsSync(SECRETS_FILE)) { +function readSecretState(directories) { + const filePath = path.join(directories.root, SECRETS_FILE); + + if (!fs.existsSync(filePath)) { return {}; } - const fileContents = fs.readFileSync(SECRETS_FILE, 'utf8'); + const fileContents = fs.readFileSync(filePath, 'utf8'); const secrets = JSON.parse(fileContents); const state = {}; @@ -109,75 +126,20 @@ function readSecretState() { return state; } -/** - * Migrates secrets from settings.json to secrets.json - * @param {string} settingsFile Path to settings.json - * @returns {void} - */ -function migrateSecrets(settingsFile) { - const palmKey = readSecret('api_key_palm'); - if (palmKey) { - console.log('Migrating Palm key...'); - writeSecret(SECRET_KEYS.MAKERSUITE, palmKey); - deleteSecret('api_key_palm'); - } - - if (!fs.existsSync(settingsFile)) { - console.log('Settings file does not exist'); - return; - } - - try { - let modified = false; - const fileContents = fs.readFileSync(settingsFile, 'utf8'); - const settings = JSON.parse(fileContents); - const oaiKey = settings?.api_key_openai; - const hordeKey = settings?.horde_settings?.api_key; - const novelKey = settings?.api_key_novel; - - if (typeof oaiKey === 'string') { - console.log('Migrating OpenAI key...'); - writeSecret(SECRET_KEYS.OPENAI, oaiKey); - delete settings.api_key_openai; - modified = true; - } - - if (typeof hordeKey === 'string') { - console.log('Migrating Horde key...'); - writeSecret(SECRET_KEYS.HORDE, hordeKey); - delete settings.horde_settings.api_key; - modified = true; - } - - if (typeof novelKey === 'string') { - console.log('Migrating Novel key...'); - writeSecret(SECRET_KEYS.NOVEL, novelKey); - delete settings.api_key_novel; - modified = true; - } - - if (modified) { - console.log('Writing updated settings.json...'); - const settingsContent = JSON.stringify(settings, null, 4); - writeFileAtomicSync(settingsFile, settingsContent, 'utf-8'); - } - } - catch (error) { - console.error('Could not migrate secrets file. Proceed with caution.'); - } -} - /** * Reads all secrets from the secrets file + * @param {import('../users').UserDirectoryList} directories User directories * @returns {Record | undefined} Secrets */ -function getAllSecrets() { - if (!fs.existsSync(SECRETS_FILE)) { +function getAllSecrets(directories) { + const filePath = path.join(directories.root, SECRETS_FILE); + + if (!fs.existsSync(filePath)) { console.log('Secrets file does not exist'); return undefined; } - const fileContents = fs.readFileSync(SECRETS_FILE, 'utf8'); + const fileContents = fs.readFileSync(filePath, 'utf8'); const secrets = JSON.parse(fileContents); return secrets; } @@ -188,13 +150,13 @@ router.post('/write', jsonParser, (request, response) => { const key = request.body.key; const value = request.body.value; - writeSecret(key, value); + writeSecret(request.user.directories, key, value); return response.send('ok'); }); -router.post('/read', jsonParser, (_, response) => { +router.post('/read', jsonParser, (request, response) => { try { - const state = readSecretState(); + const state = readSecretState(request.user.directories); return response.send(state); } catch (error) { console.error(error); @@ -202,7 +164,7 @@ router.post('/read', jsonParser, (_, response) => { } }); -router.post('/view', jsonParser, async (_, response) => { +router.post('/view', jsonParser, async (request, response) => { const allowKeysExposure = getConfigValue('allowKeysExposure', false); if (!allowKeysExposure) { @@ -211,7 +173,7 @@ router.post('/view', jsonParser, async (_, response) => { } try { - const secrets = getAllSecrets(); + const secrets = getAllSecrets(request.user.directories); if (!secrets) { return response.sendStatus(404); @@ -234,7 +196,7 @@ router.post('/find', jsonParser, (request, response) => { } try { - const secret = readSecret(key); + const secret = readSecret(request.user.directories, key); if (!secret) { response.sendStatus(404); @@ -250,8 +212,8 @@ router.post('/find', jsonParser, (request, response) => { module.exports = { writeSecret, readSecret, + deleteSecret, readSecretState, - migrateSecrets, getAllSecrets, SECRET_KEYS, router, diff --git a/src/endpoints/serpapi.js b/src/endpoints/serpapi.js index 62c50693a..0fc01b490 100644 --- a/src/endpoints/serpapi.js +++ b/src/endpoints/serpapi.js @@ -24,7 +24,7 @@ const visitHeaders = { router.post('/search', jsonParser, async (request, response) => { try { - const key = readSecret(SECRET_KEYS.SERPAPI); + const key = readSecret(request.user.directories, SECRET_KEYS.SERPAPI); if (!key) { console.log('No SerpApi key found'); diff --git a/src/endpoints/settings.js b/src/endpoints/settings.js index aae32b84d..c3d0caa74 100644 --- a/src/endpoints/settings.js +++ b/src/endpoints/settings.js @@ -2,14 +2,20 @@ const fs = require('fs'); const path = require('path'); const express = require('express'); const writeFileAtomicSync = require('write-file-atomic').sync; -const { DIRECTORIES } = require('../constants'); +const { PUBLIC_DIRECTORIES, SETTINGS_FILE } = require('../constants'); const { getConfigValue, generateTimestamp, removeOldBackups } = require('../util'); const { jsonParser } = require('../express-common'); -const { migrateSecrets } = require('./secrets'); +const { getAllUserHandles, getUserDirectories } = require('../users'); -const enableExtensions = getConfigValue('enableExtensions', true); -const SETTINGS_FILE = './public/settings.json'; +const ENABLE_EXTENSIONS = getConfigValue('enableExtensions', true); +const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); +/** + * Reads and parses files from a directory. + * @param {string} directoryPath Path to the directory + * @param {string} fileExtension File extension + * @returns {Array} Parsed files + */ function readAndParseFromDirectory(directoryPath, fileExtension = '.json') { const files = fs .readdirSync(directoryPath) @@ -31,10 +37,24 @@ function readAndParseFromDirectory(directoryPath, fileExtension = '.json') { return parsedFiles; } +/** + * Gets a sort function for sorting strings. + * @param {*} _ + * @returns {(a: string, b: string) => number} Sort function + */ function sortByName(_) { return (a, b) => a.localeCompare(b); } +/** + * Gets backup file prefix for user settings. + * @param {string} handle User handle + * @returns {string} File prefix + */ +function getFilePrefix(handle) { + return `settings_${handle}_`; +} + function readPresetsFromDirectory(directoryPath, options = {}) { const { sortFunction, @@ -61,26 +81,46 @@ function readPresetsFromDirectory(directoryPath, options = {}) { return { fileContents, fileNames }; } -function backupSettings() { +async function backupSettings() { try { - if (!fs.existsSync(DIRECTORIES.backups)) { - fs.mkdirSync(DIRECTORIES.backups); + if (!fs.existsSync(PUBLIC_DIRECTORIES.backups)) { + fs.mkdirSync(PUBLIC_DIRECTORIES.backups); } - const backupFile = path.join(DIRECTORIES.backups, `settings_${generateTimestamp()}.json`); - fs.copyFileSync(SETTINGS_FILE, backupFile); + const userHandles = await getAllUserHandles(); - removeOldBackups('settings_'); + for (const handle of userHandles) { + backupUserSettings(handle); + } } catch (err) { console.log('Could not backup settings file', err); } } +/** + * Makes a backup of the user's settings file. + * @param {string} handle User handle + * @returns {void} + */ +function backupUserSettings(handle) { + const userDirectories = getUserDirectories(handle); + const backupFile = path.join(PUBLIC_DIRECTORIES.backups, `${getFilePrefix(handle)}${generateTimestamp()}.json`); + const sourceFile = path.join(userDirectories.root, SETTINGS_FILE); + + if (!fs.existsSync(sourceFile)) { + return; + } + + fs.copyFileSync(sourceFile, backupFile); + removeOldBackups(`settings_${handle}`); +} + const router = express.Router(); router.post('/save', jsonParser, function (request, response) { try { - writeFileAtomicSync('public/settings.json', JSON.stringify(request.body, null, 4), 'utf8'); + const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE); + writeFileAtomicSync(pathToSettings, JSON.stringify(request.body, null, 4), 'utf8'); response.send({ result: 'ok' }); } catch (err) { console.log(err); @@ -92,48 +132,49 @@ router.post('/save', jsonParser, function (request, response) { router.post('/get', jsonParser, (request, response) => { let settings; try { - settings = fs.readFileSync('public/settings.json', 'utf8'); + const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE); + settings = fs.readFileSync(pathToSettings, 'utf8'); } catch (e) { return response.sendStatus(500); } // NovelAI Settings const { fileContents: novelai_settings, fileNames: novelai_setting_names } - = readPresetsFromDirectory(DIRECTORIES.novelAI_Settings, { - sortFunction: sortByName(DIRECTORIES.novelAI_Settings), + = readPresetsFromDirectory(request.user.directories.novelAI_Settings, { + sortFunction: sortByName(request.user.directories.novelAI_Settings), removeFileExtension: true, }); // OpenAI Settings const { fileContents: openai_settings, fileNames: openai_setting_names } - = readPresetsFromDirectory(DIRECTORIES.openAI_Settings, { - sortFunction: sortByName(DIRECTORIES.openAI_Settings), removeFileExtension: true, + = readPresetsFromDirectory(request.user.directories.openAI_Settings, { + sortFunction: sortByName(request.user.directories.openAI_Settings), removeFileExtension: true, }); // TextGenerationWebUI Settings const { fileContents: textgenerationwebui_presets, fileNames: textgenerationwebui_preset_names } - = readPresetsFromDirectory(DIRECTORIES.textGen_Settings, { - sortFunction: sortByName(DIRECTORIES.textGen_Settings), removeFileExtension: true, + = readPresetsFromDirectory(request.user.directories.textGen_Settings, { + sortFunction: sortByName(request.user.directories.textGen_Settings), removeFileExtension: true, }); //Kobold const { fileContents: koboldai_settings, fileNames: koboldai_setting_names } - = readPresetsFromDirectory(DIRECTORIES.koboldAI_Settings, { - sortFunction: sortByName(DIRECTORIES.koboldAI_Settings), removeFileExtension: true, + = readPresetsFromDirectory(request.user.directories.koboldAI_Settings, { + sortFunction: sortByName(request.user.directories.koboldAI_Settings), removeFileExtension: true, }); const worldFiles = fs - .readdirSync(DIRECTORIES.worlds) + .readdirSync(request.user.directories.worlds) .filter(file => path.extname(file).toLowerCase() === '.json') .sort((a, b) => a.localeCompare(b)); const world_names = worldFiles.map(item => path.parse(item).name); - const themes = readAndParseFromDirectory(DIRECTORIES.themes); - const movingUIPresets = readAndParseFromDirectory(DIRECTORIES.movingUI); - const quickReplyPresets = readAndParseFromDirectory(DIRECTORIES.quickreplies); + const themes = readAndParseFromDirectory(request.user.directories.themes); + const movingUIPresets = readAndParseFromDirectory(request.user.directories.movingUI); + const quickReplyPresets = readAndParseFromDirectory(request.user.directories.quickreplies); - const instruct = readAndParseFromDirectory(DIRECTORIES.instruct); - const context = readAndParseFromDirectory(DIRECTORIES.context); + const instruct = readAndParseFromDirectory(request.user.directories.instruct); + const context = readAndParseFromDirectory(request.user.directories.context); response.send({ settings, @@ -151,14 +192,94 @@ router.post('/get', jsonParser, (request, response) => { quickReplyPresets, instruct, context, - enable_extensions: enableExtensions, + enable_extensions: ENABLE_EXTENSIONS, + enable_accounts: ENABLE_ACCOUNTS, }); }); -// Sync for now, but should probably be migrated to async file APIs +router.post('/get-snapshots', jsonParser, async (request, response) => { + try { + const snapshots = fs.readdirSync(PUBLIC_DIRECTORIES.backups); + const userFilesPattern = getFilePrefix(request.user.profile.handle); + const userSnapshots = snapshots.filter(x => x.startsWith(userFilesPattern)); + + const result = userSnapshots.map(x => { + const stat = fs.statSync(path.join(PUBLIC_DIRECTORIES.backups, x)); + return { date: stat.ctimeMs, name: x, size: stat.size }; + }); + + response.json(result); + } catch (error) { + console.log(error); + response.sendStatus(500); + } +}); + +router.post('/load-snapshot', jsonParser, async (request, response) => { + try { + const userFilesPattern = getFilePrefix(request.user.profile.handle); + + if (!request.body.name || !request.body.name.startsWith(userFilesPattern)) { + return response.status(400).send({ error: 'Invalid snapshot name' }); + } + + const snapshotName = request.body.name; + const snapshotPath = path.join(PUBLIC_DIRECTORIES.backups, snapshotName); + + if (!fs.existsSync(snapshotPath)) { + return response.sendStatus(404); + } + + const content = fs.readFileSync(snapshotPath, 'utf8'); + + response.send(content); + } catch (error) { + console.log(error); + response.sendStatus(500); + } +}); + +router.post('/make-snapshot', jsonParser, async (request, response) => { + try { + backupUserSettings(request.user.profile.handle); + response.sendStatus(204); + } catch (error) { + console.log(error); + response.sendStatus(500); + } +}); + +router.post('/restore-snapshot', jsonParser, async (request, response) => { + try { + const userFilesPattern = getFilePrefix(request.user.profile.handle); + + if (!request.body.name || !request.body.name.startsWith(userFilesPattern)) { + return response.status(400).send({ error: 'Invalid snapshot name' }); + } + + const snapshotName = request.body.name; + const snapshotPath = path.join(PUBLIC_DIRECTORIES.backups, snapshotName); + + if (!fs.existsSync(snapshotPath)) { + return response.sendStatus(404); + } + + const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE); + fs.rmSync(pathToSettings, { force: true }); + fs.copyFileSync(snapshotPath, pathToSettings); + + response.sendStatus(204); + } catch (error) { + console.log(error); + response.sendStatus(500); + } +}); + +/** + * Initializes the settings endpoint + */ async function init() { - backupSettings(); - migrateSecrets(SETTINGS_FILE); + await backupSettings(); } module.exports = { router, init }; diff --git a/src/endpoints/sprites.js b/src/endpoints/sprites.js index d43efefc4..88a577b3b 100644 --- a/src/endpoints/sprites.js +++ b/src/endpoints/sprites.js @@ -5,17 +5,18 @@ 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 { 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 + * @param {import('../users').UserDirectoryList} directories - User directories * @param {string} name - The name of the character * @param {boolean} isSubfolder - Whether the name contains a subfolder * @returns {string | null} The path to the sprites folder. Null if the name is invalid. */ -function getSpritesPath(name, isSubfolder) { +function getSpritesPath(directories, name, isSubfolder) { if (isSubfolder) { const nameParts = name.split('/'); const characterName = sanitize(nameParts[0]); @@ -25,7 +26,7 @@ function getSpritesPath(name, isSubfolder) { return null; } - return path.join(DIRECTORIES.characters, characterName, subfolderName); + return path.join(directories.characters, characterName, subfolderName); } name = sanitize(name); @@ -34,15 +35,18 @@ function getSpritesPath(name, isSubfolder) { return null; } - return path.join(DIRECTORIES.characters, name); + return path.join(directories.characters, name); } /** * Imports base64 encoded sprites from RisuAI character data. + * The sprites are saved in the character's sprites folder. + * The additionalAssets and emotions are removed from the data. + * @param {import('../users').UserDirectoryList} directories User directories * @param {object} data RisuAI character data * @returns {void} */ -function importRisuSprites(data) { +function importRisuSprites(directories, data) { try { const name = data?.data?.name; const risuData = data?.data?.extensions?.risuai; @@ -68,7 +72,7 @@ function importRisuSprites(data) { } // Create sprites folder if it doesn't exist - const spritesPath = path.join(DIRECTORIES.characters, name); + const spritesPath = path.join(directories.characters, name); if (!fs.existsSync(spritesPath)) { fs.mkdirSync(spritesPath); } @@ -108,7 +112,7 @@ const router = express.Router(); router.get('/get', jsonParser, function (request, response) { const name = String(request.query.name); const isSubfolder = name.includes('/'); - const spritesPath = getSpritesPath(name, isSubfolder); + const spritesPath = getSpritesPath(request.user.directories, name, isSubfolder); let sprites = []; try { @@ -142,7 +146,7 @@ router.post('/delete', jsonParser, async (request, response) => { } try { - const spritesPath = path.join(DIRECTORIES.characters, name); + const spritesPath = path.join(request.user.directories.characters, name); // No sprites folder exists, or not a directory if (!fs.existsSync(spritesPath) || !fs.statSync(spritesPath).isDirectory()) { @@ -174,7 +178,7 @@ router.post('/upload-zip', urlencodedParser, async (request, response) => { } try { - const spritesPath = path.join(DIRECTORIES.characters, name); + const spritesPath = path.join(request.user.directories.characters, name); // Create sprites folder if it doesn't exist if (!fs.existsSync(spritesPath)) { @@ -222,7 +226,7 @@ router.post('/upload', urlencodedParser, async (request, response) => { } try { - const spritesPath = path.join(DIRECTORIES.characters, name); + const spritesPath = path.join(request.user.directories.characters, name); // Create sprites folder if it doesn't exist if (!fs.existsSync(spritesPath)) { diff --git a/src/endpoints/stable-diffusion.js b/src/endpoints/stable-diffusion.js index 3902fa3ce..55669adc2 100644 --- a/src/endpoints/stable-diffusion.js +++ b/src/endpoints/stable-diffusion.js @@ -3,7 +3,7 @@ const fetch = require('node-fetch').default; const sanitize = require('sanitize-filename'); const { getBasicAuthHeader, delay, getHexString } = require('../util.js'); const fs = require('fs'); -const { DIRECTORIES } = require('../constants.js'); +const path = require('path'); const writeFileAtomicSync = require('write-file-atomic').sync; const { jsonParser } = require('../express-common'); const { readSecret, SECRET_KEYS } = require('./secrets.js'); @@ -43,9 +43,14 @@ function removePattern(x, pattern) { return x; } -function getComfyWorkflows() { +/** + * Gets the comfy workflows. + * @param {import('../users.js').UserDirectoryList} directories + * @returns {string[]} List of comfy workflows + */ +function getComfyWorkflows(directories) { return fs - .readdirSync(DIRECTORIES.comfyWorkflows) + .readdirSync(directories.comfyWorkflows) .filter(file => file[0] != '.' && file.toLowerCase().endsWith('.json')) .sort(Intl.Collator().compare); } @@ -448,7 +453,7 @@ comfy.post('/vaes', jsonParser, async (request, response) => { comfy.post('/workflows', jsonParser, async (request, response) => { try { - const data = getComfyWorkflows(); + const data = getComfyWorkflows(request.user.directories); return response.send(data); } catch (error) { console.log(error); @@ -458,14 +463,11 @@ comfy.post('/workflows', jsonParser, async (request, response) => { comfy.post('/workflow', jsonParser, async (request, response) => { try { - let path = `${DIRECTORIES.comfyWorkflows}/${sanitize(String(request.body.file_name))}`; - if (!fs.existsSync(path)) { - path = `${DIRECTORIES.comfyWorkflows}/Default_Comfy_Workflow.json`; + let filePath = path.join(request.user.directories.comfyWorkflows, sanitize(String(request.body.file_name))); + if (!fs.existsSync(filePath)) { + filePath = path.join(request.user.directories.comfyWorkflows, 'Default_Comfy_Workflow.json'); } - const data = fs.readFileSync( - path, - { encoding: 'utf-8' }, - ); + const data = fs.readFileSync(filePath, { encoding: 'utf-8' }); return response.send(JSON.stringify(data)); } catch (error) { console.log(error); @@ -475,12 +477,9 @@ comfy.post('/workflow', jsonParser, async (request, response) => { comfy.post('/save-workflow', jsonParser, async (request, response) => { try { - writeFileAtomicSync( - `${DIRECTORIES.comfyWorkflows}/${sanitize(String(request.body.file_name))}`, - request.body.workflow, - 'utf8', - ); - const data = getComfyWorkflows(); + const filePath = path.join(request.user.directories.comfyWorkflows, sanitize(String(request.body.file_name))); + writeFileAtomicSync(filePath, request.body.workflow, 'utf8'); + const data = getComfyWorkflows(request.user.directories); return response.send(data); } catch (error) { console.log(error); @@ -490,9 +489,9 @@ comfy.post('/save-workflow', jsonParser, async (request, response) => { comfy.post('/delete-workflow', jsonParser, async (request, response) => { try { - let path = `${DIRECTORIES.comfyWorkflows}/${sanitize(String(request.body.file_name))}`; - if (fs.existsSync(path)) { - fs.unlinkSync(path); + const filePath = path.join(request.user.directories.comfyWorkflows, sanitize(String(request.body.file_name))); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); } return response.sendStatus(200); } catch (error) { @@ -548,9 +547,9 @@ comfy.post('/generate', jsonParser, async (request, response) => { const together = express.Router(); -together.post('/models', jsonParser, async (_, response) => { +together.post('/models', jsonParser, async (request, response) => { try { - const key = readSecret(SECRET_KEYS.TOGETHERAI); + const key = readSecret(request.user.directories, SECRET_KEYS.TOGETHERAI); if (!key) { console.log('TogetherAI key not found.'); @@ -589,7 +588,7 @@ together.post('/models', jsonParser, async (_, response) => { together.post('/generate', jsonParser, async (request, response) => { try { - const key = readSecret(SECRET_KEYS.TOGETHERAI); + const key = readSecret(request.user.directories, SECRET_KEYS.TOGETHERAI); if (!key) { console.log('TogetherAI key not found.'); diff --git a/src/endpoints/stats.js b/src/endpoints/stats.js index b4ff37ab2..4d20c8723 100644 --- a/src/endpoints/stats.js +++ b/src/endpoints/stats.js @@ -8,11 +8,15 @@ const readFile = fs.promises.readFile; const readdir = fs.promises.readdir; const { jsonParser } = require('../express-common'); -const { DIRECTORIES } = require('../constants'); +const { getAllUserHandles, getUserDirectories } = require('../users'); -let charStats = {}; +const STATS_FILE = 'stats.json'; + +/** + * @type {Map} The stats object for each user. + */ +const STATS = new Map(); let lastSaveTimestamp = 0; -const statsFilePath = 'public/stats.json'; /** * Convert a timestamp to an integer timestamp. @@ -26,19 +30,19 @@ const statsFilePath = 'public/stats.json'; * the Unix Epoch, which can be converted to a JavaScript Date object with new Date(). * * @param {string|number} timestamp - The timestamp to convert. - * @returns {number|null} The timestamp in milliseconds since the Unix Epoch, or null if the input cannot be parsed. + * @returns {number} The timestamp in milliseconds since the Unix Epoch, or 0 if the input cannot be parsed. * * @example * // Unix timestamp * timestampToMoment(1609459200); * // ST humanized timestamp - * timestampToMoment("2021-01-01 @00h 00m 00s 000ms"); + * timestampToMoment("2021-01-01 \@00h 00m 00s 000ms"); * // Date string * timestampToMoment("January 1, 2021 12:00am"); */ function timestampToMoment(timestamp) { if (!timestamp) { - return null; + return 0; } if (typeof timestamp === 'number') { @@ -66,7 +70,7 @@ function timestampToMoment(timestamp) { )}:${second.padStart(2, '0')}.${millisecond.padStart(3, '0')}Z`; }; const isoTimestamp1 = timestamp.replace(pattern1, replacement1); - if (!isNaN(new Date(isoTimestamp1))) { + if (!isNaN(Number(new Date(isoTimestamp1)))) { return new Date(isoTimestamp1).getTime(); } @@ -100,11 +104,11 @@ function timestampToMoment(timestamp) { )}:00Z`; }; const isoTimestamp2 = timestamp.replace(pattern2, replacement2); - if (!isNaN(new Date(isoTimestamp2))) { + if (!isNaN(Number(new Date(isoTimestamp2)))) { return new Date(isoTimestamp2).getTime(); } - return null; + return 0; } /** @@ -112,7 +116,7 @@ function timestampToMoment(timestamp) { * * @param {string} chatsPath - The path to the directory containing the chat files. * @param {string} charactersPath - The path to the directory containing the character files. - * @returns {Object} The aggregated stats object. + * @returns {Promise} The aggregated stats object. */ async function collectAndCreateStats(chatsPath, charactersPath) { console.log('Collecting and creating stats...'); @@ -120,8 +124,8 @@ async function collectAndCreateStats(chatsPath, charactersPath) { const pngFiles = files.filter((file) => file.endsWith('.png')); - let processingPromises = pngFiles.map((file, index) => - calculateStats(chatsPath, file, index), + let processingPromises = pngFiles.map((file) => + calculateStats(chatsPath, file), ); const statsArr = await Promise.all(processingPromises); @@ -134,8 +138,15 @@ async function collectAndCreateStats(chatsPath, charactersPath) { return finalStats; } -async function recreateStats(chatsPath, charactersPath) { - charStats = await collectAndCreateStats(chatsPath, charactersPath); +/** + * Recreates the stats object for a user. + * @param {string} handle User handle + * @param {string} chatsPath Path to the directory containing the chat files. + * @param {string} charactersPath Path to the directory containing the character files. + */ +async function recreateStats(handle, chatsPath, charactersPath) { + const stats = await collectAndCreateStats(chatsPath, charactersPath); + STATS.set(handle, stats); await saveStatsToFile(); console.debug('Stats (re)created and saved to file.'); } @@ -146,15 +157,24 @@ async function recreateStats(chatsPath, charactersPath) { */ async function init() { try { - const statsFileContent = await readFile(statsFilePath, 'utf-8'); - charStats = JSON.parse(statsFileContent); - } catch (err) { - // If the file doesn't exist or is invalid, initialize stats - if (err.code === 'ENOENT' || err instanceof SyntaxError) { - recreateStats(DIRECTORIES.chats, DIRECTORIES.characters); - } else { - throw err; // Rethrow the error if it's something we didn't expect + const userHandles = await getAllUserHandles(); + for (const handle of userHandles) { + const directories = getUserDirectories(handle); + try { + const statsFilePath = path.join(directories.root, STATS_FILE); + const statsFileContent = await readFile(statsFilePath, 'utf-8'); + STATS.set(handle, JSON.parse(statsFileContent)); + } catch (err) { + // If the file doesn't exist or is invalid, initialize stats + if (err.code === 'ENOENT' || err instanceof SyntaxError) { + recreateStats(handle, directories.chats, directories.characters); + } else { + throw err; // Rethrow the error if it's something we didn't expect + } + } } + } catch (err) { + console.error('Failed to initialize stats:', err); } // Save stats every 5 minutes setInterval(saveStatsToFile, 5 * 60 * 1000); @@ -163,16 +183,19 @@ async function init() { * Saves the current state of charStats to a file, only if the data has changed since the last save. */ async function saveStatsToFile() { - if (charStats.timestamp > lastSaveTimestamp) { - //console.debug("Saving stats to file..."); - try { - await writeFileAtomic(statsFilePath, JSON.stringify(charStats)); - lastSaveTimestamp = Date.now(); - } catch (error) { - console.log('Failed to save stats to file.', error); + const userHandles = await getAllUserHandles(); + for (const handle of userHandles) { + const charStats = STATS.get(handle) || {}; + if (charStats.timestamp > lastSaveTimestamp) { + try { + const directories = getUserDirectories(handle); + const statsFilePath = path.join(directories.root, STATS_FILE); + await writeFileAtomic(statsFilePath, JSON.stringify(charStats)); + lastSaveTimestamp = Date.now(); + } catch (error) { + console.log('Failed to save stats to file.', error); + } } - } else { - //console.debug('Stats have not changed since last save. Skipping file write.'); } } @@ -216,7 +239,7 @@ function readAndParseFile(filepath) { function calculateGenTime(gen_started, gen_finished) { let startDate = new Date(gen_started); let endDate = new Date(gen_finished); - return endDate - startDate; + return Number(endDate) - Number(startDate); } /** @@ -233,12 +256,12 @@ function countWordsInString(str) { /** * calculateStats - Calculate statistics for a given character chat directory. * - * @param {string} char_dir The directory containing the chat files. + * @param {string} chatsPath The directory containing the chat files. * @param {string} item The name of the character. * @return {object} An object containing the calculated statistics. */ -const calculateStats = (chatsPath, item, index) => { - const char_dir = path.join(chatsPath, item.replace('.png', '')); +const calculateStats = (chatsPath, item) => { + const chatDir = path.join(chatsPath, item.replace('.png', '')); const stats = { total_gen_time: 0, user_word_count: 0, @@ -252,12 +275,12 @@ const calculateStats = (chatsPath, item, index) => { }; let uniqueGenStartTimes = new Set(); - if (fs.existsSync(char_dir)) { - const chats = fs.readdirSync(char_dir); + if (fs.existsSync(chatDir)) { + const chats = fs.readdirSync(chatDir); if (Array.isArray(chats) && chats.length) { for (const chat of chats) { const result = calculateTotalGenTimeAndWordCount( - char_dir, + chatDir, chat, uniqueGenStartTimes, ); @@ -268,7 +291,7 @@ const calculateStats = (chatsPath, item, index) => { stats.non_user_msg_count += result.nonUserMsgCount || 0; stats.total_swipe_count += result.totalSwipeCount || 0; - const chatStat = fs.statSync(path.join(char_dir, chat)); + const chatStat = fs.statSync(path.join(chatDir, chat)); stats.chat_size += chatStat.size; stats.date_last_chat = Math.max( stats.date_last_chat, @@ -285,37 +308,30 @@ const calculateStats = (chatsPath, item, index) => { return { [item]: stats }; }; -/** - * Returns the current charStats object. - * @returns {Object} The current charStats object. - **/ -function getCharStats() { - return charStats; -} - /** * Sets the current charStats object. + * @param {string} handle - The user handle. * @param {Object} stats - The new charStats object. **/ -function setCharStats(stats) { - charStats = stats; - charStats.timestamp = Date.now(); +function setCharStats(handle, stats) { + stats.timestamp = Date.now(); + STATS.set(handle, stats); } /** * Calculates the total generation time and word count for a chat with a character. * - * @param {string} char_dir - The directory path where character chat files are stored. + * @param {string} chatDir - The directory path where character chat files are stored. * @param {string} chat - The name of the chat file. * @returns {Object} - An object containing the total generation time, user word count, and non-user word count. * @throws Will throw an error if the file cannot be read or parsed. */ function calculateTotalGenTimeAndWordCount( - char_dir, + chatDir, chat, uniqueGenStartTimes, ) { - let filepath = path.join(char_dir, chat); + let filepath = path.join(chatDir, chat); let lines = readAndParseFile(filepath); let totalGenTime = 0; @@ -416,29 +432,18 @@ const router = express.Router(); /** * Handle a POST request to get the stats object - * - * This function returns the stats object that was calculated by the `calculateStats` function. - * - * - * @param {Object} request - The HTTP request object. - * @param {Object} response - The HTTP response object. - * @returns {void} */ router.post('/get', jsonParser, function (request, response) { - response.send(JSON.stringify(getCharStats())); + const stats = STATS.get(request.user.profile.handle) || {}; + response.send(stats); }); /** * Triggers the recreation of statistics from chat files. - * - If successful: returns a 200 OK status. - * - On failure: returns a 500 Internal Server Error status. - * - * @param {Object} request - Express request object. - * @param {Object} response - Express response object. */ router.post('/recreate', jsonParser, async function (request, response) { try { - await recreateStats(DIRECTORIES.chats, DIRECTORIES.characters); + await recreateStats(request.user.profile.handle, request.user.directories.chats, request.user.directories.characters); return response.sendStatus(200); } catch (error) { console.error(error); @@ -446,25 +451,18 @@ router.post('/recreate', jsonParser, async function (request, response) { } }); - /** * Handle a POST request to update the stats object - * - * This function updates the stats object with the data from the request body. - * - * @param {Object} request - The HTTP request object. - * @param {Object} response - The HTTP response object. - * @returns {void} - * */ router.post('/update', jsonParser, function (request, response) { if (!request.body) return response.sendStatus(400); - setCharStats(request.body); + setCharStats(request.user.profile.handle, request.body); return response.sendStatus(200); }); module.exports = { router, + recreateStats, init, onExit, }; diff --git a/src/endpoints/themes.js b/src/endpoints/themes.js index 4815c5c33..72f874b80 100644 --- a/src/endpoints/themes.js +++ b/src/endpoints/themes.js @@ -4,7 +4,6 @@ const fs = require('fs'); const sanitize = require('sanitize-filename'); const writeFileAtomicSync = require('write-file-atomic').sync; const { jsonParser } = require('../express-common'); -const { DIRECTORIES } = require('../constants'); const router = express.Router(); @@ -13,7 +12,7 @@ router.post('/save', jsonParser, (request, response) => { return response.sendStatus(400); } - const filename = path.join(DIRECTORIES.themes, sanitize(request.body.name) + '.json'); + const filename = path.join(request.user.directories.themes, sanitize(request.body.name) + '.json'); writeFileAtomicSync(filename, JSON.stringify(request.body, null, 4), 'utf8'); return response.sendStatus(200); @@ -25,7 +24,7 @@ router.post('/delete', jsonParser, function (request, response) { } try { - const filename = path.join(DIRECTORIES.themes, sanitize(request.body.name) + '.json'); + const filename = path.join(request.user.directories.themes, sanitize(request.body.name) + '.json'); if (!fs.existsSync(filename)) { console.error('Theme file not found:', filename); return response.sendStatus(404); diff --git a/src/endpoints/thumbnails.js b/src/endpoints/thumbnails.js index ad898db14..6ec1c6c81 100644 --- a/src/endpoints/thumbnails.js +++ b/src/endpoints/thumbnails.js @@ -1,27 +1,30 @@ const fs = require('fs'); +const fsPromises = require('fs').promises; const path = require('path'); +const mime = require('mime-types'); 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 { getAllUserHandles, getUserDirectories } = require('../users'); const { getConfigValue } = require('../util'); const { jsonParser } = require('../express-common'); /** * Gets a path to thumbnail folder based on the type. + * @param {import('../users').UserDirectoryList} directories User directories * @param {'bg' | 'avatar'} type Thumbnail type * @returns {string} Path to the thumbnails folder */ -function getThumbnailFolder(type) { +function getThumbnailFolder(directories, type) { let thumbnailFolder; switch (type) { case 'bg': - thumbnailFolder = DIRECTORIES.thumbnailsBg; + thumbnailFolder = directories.thumbnailsBg; break; case 'avatar': - thumbnailFolder = DIRECTORIES.thumbnailsAvatar; + thumbnailFolder = directories.thumbnailsAvatar; break; } @@ -30,18 +33,19 @@ function getThumbnailFolder(type) { /** * Gets a path to the original images folder based on the type. + * @param {import('../users').UserDirectoryList} directories User directories * @param {'bg' | 'avatar'} type Thumbnail type * @returns {string} Path to the original images folder */ -function getOriginalFolder(type) { +function getOriginalFolder(directories, type) { let originalFolder; switch (type) { case 'bg': - originalFolder = DIRECTORIES.backgrounds; + originalFolder = directories.backgrounds; break; case 'avatar': - originalFolder = DIRECTORIES.characters; + originalFolder = directories.characters; break; } @@ -50,11 +54,12 @@ function getOriginalFolder(type) { /** * Removes the generated thumbnail from the disk. + * @param {import('../users').UserDirectoryList} directories User directories * @param {'bg' | 'avatar'} type Type of the thumbnail * @param {string} file Name of the file */ -function invalidateThumbnail(type, file) { - const folder = getThumbnailFolder(type); +function invalidateThumbnail(directories, type, file) { + const folder = getThumbnailFolder(directories, type); if (folder === undefined) throw new Error('Invalid thumbnail type'); const pathToThumbnail = path.join(folder, file); @@ -66,13 +71,14 @@ function invalidateThumbnail(type, file) { /** * Generates a thumbnail for the given file. + * @param {import('../users').UserDirectoryList} directories User directories * @param {'bg' | 'avatar'} type Type of the thumbnail * @param {string} file Name of the file * @returns */ -async function generateThumbnail(type, file) { - let thumbnailFolder = getThumbnailFolder(type); - let originalFolder = getOriginalFolder(type); +async function generateThumbnail(directories, type, file) { + let thumbnailFolder = getThumbnailFolder(directories, type); + let originalFolder = getOriginalFolder(directories, type); if (thumbnailFolder === undefined || originalFolder === undefined) throw new Error('Invalid thumbnail type'); const pathToCachedFile = path.join(thumbnailFolder, file); @@ -133,62 +139,91 @@ async function generateThumbnail(type, file) { * @returns {Promise} Promise that resolves when the cache is validated */ async function ensureThumbnailCache() { - const cacheFiles = fs.readdirSync(DIRECTORIES.thumbnailsBg); + const userHandles = await getAllUserHandles(); + for (const handle of userHandles) { + const directories = getUserDirectories(handle); + const cacheFiles = fs.readdirSync(directories.thumbnailsBg); - // files exist, all ok - if (cacheFiles.length) { - return; + // files exist, all ok + if (cacheFiles.length) { + return; + } + + console.log('Generating thumbnails cache. Please wait...'); + + const bgFiles = fs.readdirSync(directories.backgrounds); + const tasks = []; + + for (const file of bgFiles) { + tasks.push(generateThumbnail(directories, 'bg', file)); + } + + await Promise.all(tasks); + console.log(`Done! Generated: ${bgFiles.length} preview images`); } - - console.log('Generating thumbnails cache. Please wait...'); - - const bgFiles = fs.readdirSync(DIRECTORIES.backgrounds); - const tasks = []; - - for (const file of bgFiles) { - tasks.push(generateThumbnail('bg', file)); - } - - await Promise.all(tasks); - console.log(`Done! Generated: ${bgFiles.length} preview images`); } const router = express.Router(); // 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); + try{ + 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 (sanitize(file) !== file) { + console.error('Malicious filename prevented'); + return response.sendStatus(403); + } + + const thumbnailsDisabled = getConfigValue('disableThumbnails', false); + if (thumbnailsDisabled) { + const folder = getOriginalFolder(request.user.directories, type); + + if (folder === undefined) { + return response.sendStatus(400); + } + + const pathToOriginalFile = path.join(folder, file); + if (!fs.existsSync(pathToOriginalFile)) { + return response.sendStatus(404); + } + const contentType = mime.lookup(pathToOriginalFile) || 'image/png'; + const originalFile = await fsPromises.readFile(pathToOriginalFile); + response.setHeader('Content-Type', contentType); + return response.send(originalFile); + } + + const pathToCachedFile = await generateThumbnail(request.user.directories, type, file); + + if (!pathToCachedFile) { + return response.sendStatus(404); + } + + if (!fs.existsSync(pathToCachedFile)) { + return response.sendStatus(404); + } + + const contentType = mime.lookup(pathToCachedFile) || 'image/jpeg'; + const cachedFile = await fsPromises.readFile(pathToCachedFile); + response.setHeader('Content-Type', contentType); + return response.send(cachedFile); + } catch (error) { + console.error('Failed getting thumbnail', error); + return response.sendStatus(500); } - - if (!(type == 'bg' || type == 'avatar')) { - return response.sendStatus(400); - } - - 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() }); - } - - const pathToCachedFile = await generateThumbnail(type, file); - - if (!pathToCachedFile) { - return response.sendStatus(404); - } - - return response.sendFile(pathToCachedFile, { root: process.cwd() }); }); module.exports = { diff --git a/src/endpoints/tokenizers.js b/src/endpoints/tokenizers.js index 10cce1c2a..d7dddf5f6 100644 --- a/src/endpoints/tokenizers.js +++ b/src/endpoints/tokenizers.js @@ -370,12 +370,13 @@ const router = express.Router(); router.post('/ai21/count', jsonParser, async function (req, res) { if (!req.body) return res.sendStatus(400); + const key = readSecret(req.user.directories, SECRET_KEYS.AI21); const options = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', - Authorization: `Bearer ${readSecret(SECRET_KEYS.AI21)}`, + Authorization: `Bearer ${key}`, }, body: JSON.stringify({ text: req.body[0].content }), }; @@ -401,7 +402,8 @@ router.post('/google/count', jsonParser, async function (req, res) { body: JSON.stringify({ contents: convertGooglePrompt(req.body, String(req.query.model)).contents }), }; try { - const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${req.query.model}:countTokens?key=${readSecret(SECRET_KEYS.MAKERSUITE)}`, options); + const key = readSecret(req.user.directories, SECRET_KEYS.MAKERSUITE); + const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${req.query.model}:countTokens?key=${key}`, options); const data = await response.json(); return res.send({ 'token_count': data?.totalTokens || 0 }); } catch (err) { diff --git a/src/endpoints/translate.js b/src/endpoints/translate.js index 635b5cc8d..d1e8f98ae 100644 --- a/src/endpoints/translate.js +++ b/src/endpoints/translate.js @@ -11,8 +11,8 @@ const ONERING_URL_DEFAULT = 'http://127.0.0.1:4990/translate'; const router = express.Router(); router.post('/libre', jsonParser, async (request, response) => { - const key = readSecret(SECRET_KEYS.LIBRE); - const url = readSecret(SECRET_KEYS.LIBRE_URL); + const key = readSecret(request.user.directories, SECRET_KEYS.LIBRE); + const url = readSecret(request.user.directories, SECRET_KEYS.LIBRE_URL); if (!url) { console.log('LibreTranslate URL is not configured.'); @@ -104,7 +104,7 @@ router.post('/google', jsonParser, async (request, response) => { router.post('/lingva', jsonParser, async (request, response) => { try { - const baseUrl = readSecret(SECRET_KEYS.LINGVA_URL); + const baseUrl = readSecret(request.user.directories, SECRET_KEYS.LINGVA_URL); if (!baseUrl) { console.log('Lingva URL is not configured.'); @@ -149,7 +149,7 @@ router.post('/lingva', jsonParser, async (request, response) => { }); router.post('/deepl', jsonParser, async (request, response) => { - const key = readSecret(SECRET_KEYS.DEEPL); + const key = readSecret(request.user.directories, SECRET_KEYS.DEEPL); if (!key) { console.log('DeepL key is not configured.'); @@ -208,7 +208,7 @@ router.post('/deepl', jsonParser, async (request, response) => { }); router.post('/onering', jsonParser, async (request, response) => { - const secretUrl = readSecret(SECRET_KEYS.ONERING_URL); + const secretUrl = readSecret(request.user.directories, SECRET_KEYS.ONERING_URL); const url = secretUrl || ONERING_URL_DEFAULT; if (!url) { @@ -261,7 +261,7 @@ router.post('/onering', jsonParser, async (request, response) => { }); router.post('/deeplx', jsonParser, async (request, response) => { - const secretUrl = readSecret(SECRET_KEYS.DEEPLX_URL); + const secretUrl = readSecret(request.user.directories, SECRET_KEYS.DEEPLX_URL); const url = secretUrl || DEEPLX_URL_DEFAULT; if (!url) { diff --git a/src/endpoints/users-admin.js b/src/endpoints/users-admin.js new file mode 100644 index 000000000..5d7879f64 --- /dev/null +++ b/src/endpoints/users-admin.js @@ -0,0 +1,249 @@ +const fsPromises = require('fs').promises; +const storage = require('node-persist'); +const express = require('express'); +const lodash = require('lodash'); +const { jsonParser } = require('../express-common'); +const { checkForNewContent } = require('./content-manager'); +const { + KEY_PREFIX, + toKey, + requireAdminMiddleware, + getUserAvatar, + getAllUserHandles, + getPasswordSalt, + getPasswordHash, + getUserDirectories, + ensurePublicDirectoriesExist, +} = require('../users'); +const { DEFAULT_USER } = require('../constants'); + +const router = express.Router(); + +router.post('/get', requireAdminMiddleware, jsonParser, async (request, response) => { + try { + /** @type {import('../users').User[]} */ + const users = await storage.values(x => x.key.startsWith(KEY_PREFIX)); + + const viewModels = users + .sort((x, y) => x.created - y.created) + .map(user => ({ + handle: user.handle, + name: user.name, + avatar: getUserAvatar(user.handle), + admin: user.admin, + enabled: user.enabled, + created: user.created, + password: !!user.password, + })); + + return response.json(viewModels); + } catch (error) { + console.error('User list failed:', error); + return response.sendStatus(500); + } +}); + +router.post('/disable', requireAdminMiddleware, jsonParser, async (request, response) => { + try { + if (!request.body.handle) { + console.log('Disable user failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + if (request.body.handle === request.user.profile.handle) { + console.log('Disable user failed: Cannot disable yourself'); + return response.status(400).json({ error: 'Cannot disable yourself' }); + } + + /** @type {import('../users').User} */ + const user = await storage.getItem(toKey(request.body.handle)); + + if (!user) { + console.log('Disable user failed: User not found'); + return response.status(404).json({ error: 'User not found' }); + } + + user.enabled = false; + await storage.setItem(toKey(request.body.handle), user); + return response.sendStatus(204); + } catch (error) { + console.error('User disable failed:', error); + return response.sendStatus(500); + } +}); + +router.post('/enable', requireAdminMiddleware, jsonParser, async (request, response) => { + try { + if (!request.body.handle) { + console.log('Enable user failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + /** @type {import('../users').User} */ + const user = await storage.getItem(toKey(request.body.handle)); + + if (!user) { + console.log('Enable user failed: User not found'); + return response.status(404).json({ error: 'User not found' }); + } + + user.enabled = true; + await storage.setItem(toKey(request.body.handle), user); + return response.sendStatus(204); + } catch (error) { + console.error('User enable failed:', error); + return response.sendStatus(500); + } +}); + +router.post('/promote', requireAdminMiddleware, jsonParser, async (request, response) => { + try { + if (!request.body.handle) { + console.log('Promote user failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + /** @type {import('../users').User} */ + const user = await storage.getItem(toKey(request.body.handle)); + + if (!user) { + console.log('Promote user failed: User not found'); + return response.status(404).json({ error: 'User not found' }); + } + + user.admin = true; + await storage.setItem(toKey(request.body.handle), user); + return response.sendStatus(204); + } catch (error) { + console.error('User promote failed:', error); + return response.sendStatus(500); + } +}); + +router.post('/demote', requireAdminMiddleware, jsonParser, async (request, response) => { + try { + if (!request.body.handle) { + console.log('Demote user failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + if (request.body.handle === request.user.profile.handle) { + console.log('Demote user failed: Cannot demote yourself'); + return response.status(400).json({ error: 'Cannot demote yourself' }); + } + + /** @type {import('../users').User} */ + const user = await storage.getItem(toKey(request.body.handle)); + + if (!user) { + console.log('Demote user failed: User not found'); + return response.status(404).json({ error: 'User not found' }); + } + + user.admin = false; + await storage.setItem(toKey(request.body.handle), user); + return response.sendStatus(204); + } catch (error) { + console.error('User demote failed:', error); + return response.sendStatus(500); + } +}); + +router.post('/create', requireAdminMiddleware, jsonParser, async (request, response) => { + try { + if (!request.body.handle || !request.body.name) { + console.log('Create user failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + const handles = await getAllUserHandles(); + const handle = lodash.kebabCase(String(request.body.handle).toLowerCase().trim()); + + if (!handle) { + console.log('Create user failed: Invalid handle'); + return response.status(400).json({ error: 'Invalid handle' }); + } + + if (handles.some(x => x === handle)) { + console.log('Create user failed: User with that handle already exists'); + return response.status(409).json({ error: 'User already exists' }); + } + + const salt = getPasswordSalt(); + const password = request.body.password ? getPasswordHash(request.body.password, salt) : ''; + + const newUser = { + handle: handle, + name: request.body.name || 'Anonymous', + created: Date.now(), + password: password, + salt: salt, + admin: !!request.body.admin, + enabled: true, + }; + + await storage.setItem(toKey(handle), newUser); + + // Create user directories + console.log('Creating data directories for', newUser.handle); + await ensurePublicDirectoriesExist(); + const directories = getUserDirectories(newUser.handle); + await checkForNewContent([directories]); + return response.json({ handle: newUser.handle }); + } catch (error) { + console.error('User create failed:', error); + return response.sendStatus(500); + } +}); + +router.post('/delete', requireAdminMiddleware, jsonParser, async (request, response) => { + try { + if (!request.body.handle) { + console.log('Delete user failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + if (request.body.handle === request.user.profile.handle) { + console.log('Delete user failed: Cannot delete yourself'); + return response.status(400).json({ error: 'Cannot delete yourself' }); + } + + if (request.body.handle === DEFAULT_USER.handle) { + console.log('Delete user failed: Cannot delete default user'); + return response.status(400).json({ error: 'Sorry, but the default user cannot be deleted. It is required as a fallback.' }); + } + + await storage.removeItem(toKey(request.body.handle)); + + if (request.body.purge) { + const directories = getUserDirectories(request.body.handle); + console.log('Deleting data directories for', request.body.handle); + await fsPromises.rm(directories.root, { recursive: true, force: true }); + } + + return response.sendStatus(204); + } catch (error) { + console.error('User delete failed:', error); + return response.sendStatus(500); + } +}); + +router.post('/slugify', requireAdminMiddleware, jsonParser, async (request, response) => { + try { + if (!request.body.text) { + console.log('Slugify failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + const text = lodash.kebabCase(String(request.body.text).toLowerCase().trim()); + + return response.send(text); + } catch (error) { + console.error('Slugify failed:', error); + return response.sendStatus(500); + } +}); + +module.exports = { + router, +}; diff --git a/src/endpoints/users-private.js b/src/endpoints/users-private.js new file mode 100644 index 000000000..0bec02b42 --- /dev/null +++ b/src/endpoints/users-private.js @@ -0,0 +1,222 @@ +const path = require('path'); +const fsPromises = require('fs').promises; +const storage = require('node-persist'); +const express = require('express'); +const crypto = require('crypto'); +const { jsonParser } = require('../express-common'); +const { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist } = require('../users'); +const { SETTINGS_FILE } = require('../constants'); +const contentManager = require('./content-manager'); +const { color, Cache } = require('../util'); +const { checkForNewContent } = require('./content-manager'); + +const RESET_CACHE = new Cache(5 * 60 * 1000); + +const router = express.Router(); + +router.post('/logout', async (request, response) => { + try { + if (!request.session) { + console.error('Session not available'); + return response.sendStatus(500); + } + + request.session.handle = null; + return response.sendStatus(204); + } catch (error) { + console.error(error); + return response.sendStatus(500); + } +}); + +router.get('/me', async (request, response) => { + try { + if (!request.user) { + return response.sendStatus(403); + } + + const user = request.user.profile; + const viewModel = { + handle: user.handle, + name: user.name, + avatar: getUserAvatar(user.handle), + admin: user.admin, + password: !!user.password, + created: user.created, + }; + + return response.json(viewModel); + } catch (error) { + console.error(error); + return response.sendStatus(500); + } +}); + +router.post('/change-password', jsonParser, async (request, response) => { + try { + if (!request.body.handle) { + console.log('Change password failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + if (request.body.handle !== request.user.profile.handle && !request.user.profile.admin) { + console.log('Change password failed: Unauthorized'); + return response.status(403).json({ error: 'Unauthorized' }); + } + + /** @type {import('../users').User} */ + const user = await storage.getItem(toKey(request.body.handle)); + + if (!user) { + console.log('Change password failed: User not found'); + return response.status(404).json({ error: 'User not found' }); + } + + if (!user.enabled) { + console.log('Change password failed: User is disabled'); + return response.status(403).json({ error: 'User is disabled' }); + } + + if (!request.user.profile.admin && user.password && user.password !== getPasswordHash(request.body.oldPassword, user.salt)) { + console.log('Change password failed: Incorrect password'); + return response.status(403).json({ error: 'Incorrect password' }); + } + + if (request.body.newPassword) { + const salt = getPasswordSalt(); + user.password = getPasswordHash(request.body.newPassword, salt); + user.salt = salt; + } else { + user.password = ''; + user.salt = ''; + } + + await storage.setItem(toKey(request.body.handle), user); + return response.sendStatus(204); + } catch (error) { + console.error(error); + return response.sendStatus(500); + } +}); + +router.post('/backup', jsonParser, async (request, response) => { + try { + const handle = request.body.handle; + + if (!handle) { + console.log('Backup failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + if (handle !== request.user.profile.handle && !request.user.profile.admin) { + console.log('Backup failed: Unauthorized'); + return response.status(403).json({ error: 'Unauthorized' }); + } + + await createBackupArchive(handle, response); + } catch (error) { + console.error('Backup failed', error); + return response.sendStatus(500); + } +}); + +router.post('/reset-settings', jsonParser, async (request, response) => { + try { + const password = request.body.password; + + if (request.user.profile.password && request.user.profile.password !== getPasswordHash(password, request.user.profile.salt)) { + console.log('Reset settings failed: Incorrect password'); + return response.status(403).json({ error: 'Incorrect password' }); + } + + const pathToFile = path.join(request.user.directories.root, SETTINGS_FILE); + await fsPromises.rm(pathToFile, { force: true }); + await contentManager.checkForNewContent([request.user.directories], [contentManager.CONTENT_TYPES.SETTINGS]); + + return response.sendStatus(204); + } catch (error) { + console.error('Reset settings failed', error); + return response.sendStatus(500); + } +}); + +router.post('/change-name', jsonParser, async (request, response) => { + try { + if (!request.body.name || !request.body.handle) { + console.log('Change name failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + if (request.body.handle !== request.user.profile.handle && !request.user.profile.admin) { + console.log('Change name failed: Unauthorized'); + return response.status(403).json({ error: 'Unauthorized' }); + } + + /** @type {import('../users').User} */ + const user = await storage.getItem(toKey(request.body.handle)); + + if (!user) { + console.log('Change name failed: User not found'); + return response.status(404).json({ error: 'User not found' }); + } + + user.name = request.body.name; + await storage.setItem(toKey(request.body.handle), user); + + return response.sendStatus(204); + } catch (error) { + console.error('Change name failed', error); + return response.sendStatus(500); + } +}); + +router.post('/reset-step1', jsonParser, async (request, response) => { + try { + const resetCode = String(crypto.randomInt(1000, 9999)); + console.log(); + console.log(color.magenta(`${request.user.profile.name}, your account reset code is: `) + color.red(resetCode)); + console.log(); + RESET_CACHE.set(request.user.profile.handle, resetCode); + return response.sendStatus(204); + } catch (error) { + console.error('Recover step 1 failed:', error); + return response.sendStatus(500); + } +}); + +router.post('/reset-step2', jsonParser, async (request, response) => { + try{ + if (!request.body.code) { + console.log('Recover step 2 failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + if (request.user.profile.password && request.user.profile.password !== getPasswordHash(request.body.password, request.user.profile.salt)) { + console.log('Recover step 2 failed: Incorrect password'); + return response.status(400).json({ error: 'Incorrect password' }); + } + + const code = RESET_CACHE.get(request.user.profile.handle); + + if (!code || code !== request.body.code) { + console.log('Recover step 2 failed: Incorrect code'); + return response.status(400).json({ error: 'Incorrect code' }); + } + + console.log('Resetting account data:', request.user.profile.handle); + await fsPromises.rm(request.user.directories.root, { recursive: true, force: true }); + + await ensurePublicDirectoriesExist(); + await checkForNewContent([request.user.directories]); + + RESET_CACHE.remove(request.user.profile.handle); + return response.sendStatus(204); + } catch (error) { + console.error('Recover step 2 failed:', error); + return response.sendStatus(500); + } +}); + +module.exports = { + router, +}; diff --git a/src/endpoints/users-public.js b/src/endpoints/users-public.js new file mode 100644 index 000000000..1dea9173a --- /dev/null +++ b/src/endpoints/users-public.js @@ -0,0 +1,191 @@ +const crypto = require('crypto'); +const storage = require('node-persist'); +const express = require('express'); +const { RateLimiterMemory, RateLimiterRes } = require('rate-limiter-flexible'); +const { jsonParser, getIpFromRequest } = require('../express-common'); +const { color, Cache, getConfigValue } = require('../util'); +const { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } = require('../users'); + +const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false); +const MFA_CACHE = new Cache(5 * 60 * 1000); + +const router = express.Router(); +const loginLimiter = new RateLimiterMemory({ + points: 5, + duration: 60, +}); +const recoverLimiter = new RateLimiterMemory({ + points: 5, + duration: 300, +}); + +router.post('/list', async (_request, response) => { + try { + if (DISCREET_LOGIN) { + return response.sendStatus(204); + } + + /** @type {import('../users').User[]} */ + const users = await storage.values(x => x.key.startsWith(KEY_PREFIX)); + const viewModels = users + .filter(x => x.enabled) + .sort((x, y) => x.created - y.created) + .map(user => ({ + handle: user.handle, + name: user.name, + avatar: getUserAvatar(user.handle), + password: !!user.password, + })); + + return response.json(viewModels); + } catch (error) { + console.error('User list failed:', error); + return response.sendStatus(500); + } +}); + +router.post('/login', jsonParser, async (request, response) => { + try { + if (!request.body.handle) { + console.log('Login failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + const ip = getIpFromRequest(request); + await loginLimiter.consume(ip); + + /** @type {import('../users').User} */ + const user = await storage.getItem(toKey(request.body.handle)); + + if (!user) { + console.log('Login failed: User not found'); + return response.status(403).json({ error: 'Incorrect credentials' }); + } + + if (!user.enabled) { + console.log('Login failed: User is disabled'); + return response.status(403).json({ error: 'User is disabled' }); + } + + if (user.password && user.password !== getPasswordHash(request.body.password, user.salt)) { + console.log('Login failed: Incorrect password'); + return response.status(403).json({ error: 'Incorrect credentials' }); + } + + if (!request.session) { + console.error('Session not available'); + return response.sendStatus(500); + } + + await loginLimiter.delete(ip); + request.session.handle = user.handle; + console.log('Login successful:', user.handle, request.session); + return response.json({ handle: user.handle }); + } catch (error) { + if (error instanceof RateLimiterRes) { + console.log('Login failed: Rate limited from', getIpFromRequest(request)); + return response.status(429).send({ error: 'Too many attempts. Try again later or recover your password.' }); + } + + console.error('Login failed:', error); + return response.sendStatus(500); + } +}); + +router.post('/recover-step1', jsonParser, async (request, response) => { + try { + if (!request.body.handle) { + console.log('Recover step 1 failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + const ip = getIpFromRequest(request); + await recoverLimiter.consume(ip); + + /** @type {import('../users').User} */ + const user = await storage.getItem(toKey(request.body.handle)); + + if (!user) { + console.log('Recover step 1 failed: User not found'); + return response.status(404).json({ error: 'User not found' }); + } + + if (!user.enabled) { + console.log('Recover step 1 failed: User is disabled'); + return response.status(403).json({ error: 'User is disabled' }); + } + + const mfaCode = String(crypto.randomInt(1000, 9999)); + console.log(); + console.log(color.blue(`${user.name}, your password recovery code is: `) + color.magenta(mfaCode)); + console.log(); + MFA_CACHE.set(user.handle, mfaCode); + return response.sendStatus(204); + } catch (error) { + if (error instanceof RateLimiterRes) { + console.log('Recover step 1 failed: Rate limited from', getIpFromRequest(request)); + return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' }); + } + + console.error('Recover step 1 failed:', error); + return response.sendStatus(500); + } +}); + +router.post('/recover-step2', jsonParser, async (request, response) => { + try { + if (!request.body.handle || !request.body.code) { + console.log('Recover step 2 failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + /** @type {import('../users').User} */ + const user = await storage.getItem(toKey(request.body.handle)); + const ip = getIpFromRequest(request); + + if (!user) { + console.log('Recover step 2 failed: User not found'); + return response.status(404).json({ error: 'User not found' }); + } + + if (!user.enabled) { + console.log('Recover step 2 failed: User is disabled'); + return response.status(403).json({ error: 'User is disabled' }); + } + + const mfaCode = MFA_CACHE.get(user.handle); + + if (request.body.code !== mfaCode) { + await recoverLimiter.consume(ip); + console.log('Recover step 2 failed: Incorrect code'); + return response.status(403).json({ error: 'Incorrect code' }); + } + + if (request.body.newPassword) { + const salt = getPasswordSalt(); + user.password = getPasswordHash(request.body.newPassword, salt); + user.salt = salt; + await storage.setItem(toKey(user.handle), user); + } else { + user.password = ''; + user.salt = ''; + await storage.setItem(toKey(user.handle), user); + } + + await recoverLimiter.delete(ip); + MFA_CACHE.remove(user.handle); + return response.sendStatus(204); + } catch (error) { + if (error instanceof RateLimiterRes) { + console.log('Recover step 2 failed: Rate limited from', getIpFromRequest(request)); + return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' }); + } + + console.error('Recover step 2 failed:', error); + return response.sendStatus(500); + } +}); + +module.exports = { + router, +}; diff --git a/src/endpoints/vectors.js b/src/endpoints/vectors.js index 7bd38372d..239b5e9f7 100644 --- a/src/endpoints/vectors.js +++ b/src/endpoints/vectors.js @@ -12,22 +12,23 @@ const SOURCES = ['transformers', 'mistral', 'openai', 'extras', 'palm', 'togethe * @param {string} source - The source of the vector * @param {Object} sourceSettings - Settings for the source, if it needs any * @param {string} text - The text to get the vector for + * @param {import('../users').UserDirectoryList} directories - The directories object for the user * @returns {Promise} - The vector for the text */ -async function getVector(source, sourceSettings, text) { +async function getVector(source, sourceSettings, text, directories) { switch (source) { case 'nomicai': - return require('../nomicai-vectors').getNomicAIVector(text, source); + return require('../nomicai-vectors').getNomicAIVector(text, source, directories); case 'togetherai': case 'mistral': case 'openai': - return require('../openai-vectors').getOpenAIVector(text, source, sourceSettings.model); + return require('../openai-vectors').getOpenAIVector(text, source, directories, sourceSettings.model); case 'transformers': return require('../embedding').getTransformersVector(text); case 'extras': return require('../extras-vectors').getExtrasVector(text, sourceSettings.extrasUrl, sourceSettings.extrasKey); case 'palm': - return require('../makersuite-vectors').getMakerSuiteVector(text); + return require('../makersuite-vectors').getMakerSuiteVector(text, directories); } throw new Error(`Unknown vector source ${source}`); @@ -38,9 +39,10 @@ async function getVector(source, sourceSettings, text) { * @param {string} source - The source of the vector * @param {Object} sourceSettings - Settings for the source, if it needs any * @param {string[]} texts - The array of texts to get the vector for + * @param {import('../users').UserDirectoryList} directories - The directories object for the user * @returns {Promise} - The array of vectors for the texts */ -async function getBatchVector(source, sourceSettings, texts) { +async function getBatchVector(source, sourceSettings, texts, directories) { const batchSize = 10; const batches = Array(Math.ceil(texts.length / batchSize)).fill(undefined).map((_, i) => texts.slice(i * batchSize, i * batchSize + batchSize)); @@ -48,7 +50,7 @@ async function getBatchVector(source, sourceSettings, texts) { for (let batch of batches) { switch (source) { case 'nomicai': - results.push(...await require('../nomicai-vectors').getNomicAIBatchVector(batch, source)); + results.push(...await require('../nomicai-vectors').getNomicAIBatchVector(batch, source, directories)); break; case 'togetherai': case 'mistral': @@ -62,7 +64,7 @@ async function getBatchVector(source, sourceSettings, texts) { results.push(...await require('../extras-vectors').getExtrasBatchVector(batch, sourceSettings.extrasUrl, sourceSettings.extrasKey)); break; case 'palm': - results.push(...await require('../makersuite-vectors').getMakerSuiteBatchVector(batch)); + results.push(...await require('../makersuite-vectors').getMakerSuiteBatchVector(batch, directories)); break; default: throw new Error(`Unknown vector source ${source}`); @@ -74,13 +76,15 @@ async function getBatchVector(source, sourceSettings, texts) { /** * Gets the index for the vector collection + * @param {import('../users').UserDirectoryList} directories - User directories * @param {string} collectionId - The collection ID * @param {string} source - The source of the vector * @param {boolean} create - Whether to create the index if it doesn't exist * @returns {Promise} - The index for the collection */ -async function getIndex(collectionId, source, create = true) { - const store = new vectra.LocalIndex(path.join(process.cwd(), 'vectors', sanitize(source), sanitize(collectionId))); +async function getIndex(directories, collectionId, source, create = true) { + const pathToFile = path.join(directories.vectors, sanitize(source), sanitize(collectionId)); + const store = new vectra.LocalIndex(pathToFile); if (create && !await store.isIndexCreated()) { await store.createIndex(); @@ -91,17 +95,18 @@ async function getIndex(collectionId, source, create = true) { /** * Inserts items into the vector collection + * @param {import('../users').UserDirectoryList} directories - User directories * @param {string} collectionId - The collection ID * @param {string} source - The source of the vector * @param {Object} sourceSettings - Settings for the source, if it needs any * @param {{ hash: number; text: string; index: number; }[]} items - The items to insert */ -async function insertVectorItems(collectionId, source, sourceSettings, items) { - const store = await getIndex(collectionId, source); +async function insertVectorItems(directories, collectionId, source, sourceSettings, items) { + const store = await getIndex(directories, collectionId, source); await store.beginUpdate(); - const vectors = await getBatchVector(source, sourceSettings, items.map(x => x.text)); + const vectors = await getBatchVector(source, sourceSettings, items.map(x => x.text), directories); for (let i = 0; i < items.length; i++) { const item = items[i]; @@ -114,12 +119,13 @@ async function insertVectorItems(collectionId, source, sourceSettings, items) { /** * Gets the hashes of the items in the vector collection + * @param {import('../users').UserDirectoryList} directories - User directories * @param {string} collectionId - The collection ID * @param {string} source - The source of the vector * @returns {Promise} - The hashes of the items in the collection */ -async function getSavedHashes(collectionId, source) { - const store = await getIndex(collectionId, source); +async function getSavedHashes(directories, collectionId, source) { + const store = await getIndex(directories, collectionId, source); const items = await store.listItems(); const hashes = items.map(x => Number(x.metadata.hash)); @@ -129,12 +135,13 @@ async function getSavedHashes(collectionId, source) { /** * Deletes items from the vector collection by hash + * @param {import('../users').UserDirectoryList} directories - User directories * @param {string} collectionId - The collection ID * @param {string} source - The source of the vector * @param {number[]} hashes - The hashes of the items to delete */ -async function deleteVectorItems(collectionId, source, hashes) { - const store = await getIndex(collectionId, source); +async function deleteVectorItems(directories, collectionId, source, hashes) { + const store = await getIndex(directories, collectionId, source); const items = await store.listItemsByMetadata({ hash: { '$in': hashes } }); await store.beginUpdate(); @@ -155,9 +162,9 @@ async function deleteVectorItems(collectionId, source, hashes) { * @param {number} topK - The number of results to return * @returns {Promise<{hashes: number[], metadata: object[]}>} - The metadata of the items that match the search text */ -async function queryCollection(collectionId, source, sourceSettings, searchText, topK) { - const store = await getIndex(collectionId, source); - const vector = await getVector(source, sourceSettings, searchText); +async function queryCollection(directories, collectionId, source, sourceSettings, searchText, topK) { + const store = await getIndex(directories, collectionId, source); + const vector = await getVector(source, sourceSettings, searchText, directories); const result = await store.queryItems(vector, topK); const metadata = result.map(x => x.item.metadata); @@ -214,7 +221,7 @@ router.post('/query', jsonParser, async (req, res) => { const source = String(req.body.source) || 'transformers'; const sourceSettings = getSourceSettings(source, req); - const results = await queryCollection(collectionId, source, sourceSettings, searchText, topK); + const results = await queryCollection(req.user.directories, collectionId, source, sourceSettings, searchText, topK); return res.json(results); } catch (error) { console.error(error); @@ -233,7 +240,7 @@ router.post('/insert', jsonParser, async (req, res) => { const source = String(req.body.source) || 'transformers'; const sourceSettings = getSourceSettings(source, req); - await insertVectorItems(collectionId, source, sourceSettings, items); + await insertVectorItems(req.user.directories, collectionId, source, sourceSettings, items); return res.sendStatus(200); } catch (error) { console.error(error); @@ -250,7 +257,7 @@ router.post('/list', jsonParser, async (req, res) => { const collectionId = String(req.body.collectionId); const source = String(req.body.source) || 'transformers'; - const hashes = await getSavedHashes(collectionId, source); + const hashes = await getSavedHashes(req.user.directories, collectionId, source); return res.json(hashes); } catch (error) { console.error(error); @@ -268,7 +275,7 @@ router.post('/delete', jsonParser, async (req, res) => { const hashes = req.body.hashes.map(x => Number(x)); const source = String(req.body.source) || 'transformers'; - await deleteVectorItems(collectionId, source, hashes); + await deleteVectorItems(req.user.directories, collectionId, source, hashes); return res.sendStatus(200); } catch (error) { console.error(error); @@ -285,7 +292,7 @@ router.post('/purge', jsonParser, async (req, res) => { const collectionId = String(req.body.collectionId); for (const source of SOURCES) { - const index = await getIndex(collectionId, source, false); + const index = await getIndex(req.user.directories, collectionId, source, false); const exists = await index.isIndexCreated(); diff --git a/src/endpoints/worldinfo.js b/src/endpoints/worldinfo.js index 19c67c157..f8ab2d498 100644 --- a/src/endpoints/worldinfo.js +++ b/src/endpoints/worldinfo.js @@ -5,15 +5,16 @@ const sanitize = require('sanitize-filename'); const writeFileAtomicSync = require('write-file-atomic').sync; const { jsonParser, urlencodedParser } = require('../express-common'); -const { DIRECTORIES, UPLOADS_PATH } = require('../constants'); +const { UPLOADS_PATH } = require('../constants'); /** * Reads a World Info file and returns its contents + * @param {import('../users').UserDirectoryList} directories User directories * @param {string} worldInfoName Name of the World Info file * @param {boolean} allowDummy If true, returns an empty object if the file doesn't exist * @returns {object} World Info file contents */ -function readWorldInfoFile(worldInfoName, allowDummy) { +function readWorldInfoFile(directories, worldInfoName, allowDummy) { const dummyObject = allowDummy ? { entries: {} } : null; if (!worldInfoName) { @@ -21,7 +22,7 @@ function readWorldInfoFile(worldInfoName, allowDummy) { } const filename = `${worldInfoName}.json`; - const pathToWorldInfo = path.join(DIRECTORIES.worlds, filename); + const pathToWorldInfo = path.join(directories.worlds, filename); if (!fs.existsSync(pathToWorldInfo)) { console.log(`World info file ${filename} doesn't exist.`); @@ -40,7 +41,7 @@ router.post('/get', jsonParser, (request, response) => { return response.sendStatus(400); } - const file = readWorldInfoFile(request.body.name, true); + const file = readWorldInfoFile(request.user.directories, request.body.name, true); return response.send(file); }); @@ -52,7 +53,7 @@ router.post('/delete', jsonParser, (request, response) => { const worldInfoName = request.body.name; const filename = sanitize(`${worldInfoName}.json`); - const pathToWorldInfo = path.join(DIRECTORIES.worlds, filename); + const pathToWorldInfo = path.join(request.user.directories.worlds, filename); if (!fs.existsSync(pathToWorldInfo)) { throw new Error(`World info file ${filename} doesn't exist.`); @@ -87,7 +88,7 @@ router.post('/import', urlencodedParser, (request, response) => { return response.status(400).send('Is not a valid world info file'); } - const pathToNewFile = path.join(DIRECTORIES.worlds, filename); + const pathToNewFile = path.join(request.user.directories.worlds, filename); const worldName = path.parse(pathToNewFile).name; if (!worldName) { @@ -116,7 +117,7 @@ router.post('/edit', jsonParser, (request, response) => { } const filename = `${sanitize(request.body.name)}.json`; - const pathToFile = path.join(DIRECTORIES.worlds, filename); + const pathToFile = path.join(request.user.directories.worlds, filename); writeFileAtomicSync(pathToFile, JSON.stringify(request.body.data, null, 4)); diff --git a/src/express-common.js b/src/express-common.js index 4e630c5b6..e1c862d53 100644 --- a/src/express-common.js +++ b/src/express-common.js @@ -1,7 +1,28 @@ const express = require('express'); +const ipaddr = require('ipaddr.js'); // Instantiate parser middleware here with application-level size limits const jsonParser = express.json({ limit: '200mb' }); const urlencodedParser = express.urlencoded({ extended: true, limit: '200mb' }); -module.exports = { jsonParser, urlencodedParser }; +/** + * Gets the IP address of the client from the request object. + * @param {import('express'.Request)} req Request object + * @returns {string} IP address of the client + */ +function getIpFromRequest(req) { + let clientIp = req.connection.remoteAddress; + let ip = ipaddr.parse(clientIp); + // Check if the IP address is IPv4-mapped IPv6 address + if (ip.kind() === 'ipv6' && ip instanceof ipaddr.IPv6 && ip.isIPv4MappedAddress()) { + const ipv4 = ip.toIPv4Address().toString(); + clientIp = ipv4; + } else { + clientIp = ip; + clientIp = clientIp.toString(); + } + return clientIp; +} + + +module.exports = { jsonParser, urlencodedParser, getIpFromRequest }; diff --git a/src/makersuite-vectors.js b/src/makersuite-vectors.js index efb3dd7ad..279e7c253 100644 --- a/src/makersuite-vectors.js +++ b/src/makersuite-vectors.js @@ -4,10 +4,11 @@ const { SECRET_KEYS, readSecret } = require('./endpoints/secrets'); /** * Gets the vector for the given text from gecko model * @param {string[]} texts - The array of texts to get the vector for + * @param {import('./users').UserDirectoryList} directories - The directories object for the user * @returns {Promise} - The array of vectors for the texts */ -async function getMakerSuiteBatchVector(texts) { - const promises = texts.map(text => getMakerSuiteVector(text)); +async function getMakerSuiteBatchVector(texts, directories) { + const promises = texts.map(text => getMakerSuiteVector(text, directories)); const vectors = await Promise.all(promises); return vectors; } @@ -15,10 +16,11 @@ async function getMakerSuiteBatchVector(texts) { /** * Gets the vector for the given text from PaLM gecko model * @param {string} text - The text to get the vector for + * @param {import('./users').UserDirectoryList} directories - The directories object for the user * @returns {Promise} - The vector for the text */ -async function getMakerSuiteVector(text) { - const key = readSecret(SECRET_KEYS.MAKERSUITE); +async function getMakerSuiteVector(text, directories) { + const key = readSecret(directories, SECRET_KEYS.MAKERSUITE); if (!key) { console.log('No MakerSuite key found'); diff --git a/src/middleware/whitelist.js b/src/middleware/whitelist.js index 87d5ac5a5..906c9beb6 100644 --- a/src/middleware/whitelist.js +++ b/src/middleware/whitelist.js @@ -1,14 +1,13 @@ const path = require('path'); const fs = require('fs'); -const ipaddr = require('ipaddr.js'); const ipMatching = require('ip-matching'); +const { getIpFromRequest } = require('../express-common'); const { color, getConfigValue } = require('../util'); const whitelistPath = path.join(process.cwd(), './whitelist.txt'); let whitelist = getConfigValue('whitelist', []); let knownIPs = new Set(); -const whitelistMode = getConfigValue('whitelistMode', true); if (fs.existsSync(whitelistPath)) { try { @@ -19,26 +18,13 @@ if (fs.existsSync(whitelistPath)) { } } -function getIpFromRequest(req) { - let clientIp = req.connection.remoteAddress; - let ip = ipaddr.parse(clientIp); - // Check if the IP address is IPv4-mapped IPv6 address - if (ip.kind() === 'ipv6' && ip instanceof ipaddr.IPv6 && ip.isIPv4MappedAddress()) { - const ipv4 = ip.toIPv4Address().toString(); - clientIp = ipv4; - } else { - clientIp = ip; - clientIp = clientIp.toString(); - } - return clientIp; -} - /** * Returns a middleware function that checks if the client IP is in the whitelist. + * @param {boolean} whitelistMode If whitelist mode is enabled via config or command line * @param {boolean} listen If listen mode is enabled via config or command line * @returns {import('express').RequestHandler} The middleware function */ -function whitelistMiddleware(listen) { +function whitelistMiddleware(whitelistMode, listen) { return function (req, res, next) { const clientIp = getIpFromRequest(req); diff --git a/src/nomicai-vectors.js b/src/nomicai-vectors.js index 6415291eb..2ac682b7d 100644 --- a/src/nomicai-vectors.js +++ b/src/nomicai-vectors.js @@ -13,9 +13,10 @@ const SOURCES = { * Gets the vector for the given text batch from an OpenAI compatible endpoint. * @param {string[]} texts - The array of texts to get the vector for * @param {string} source - The source of the vector + * @param {import('./users').UserDirectoryList} directories - The directories object for the user * @returns {Promise} - The array of vectors for the texts */ -async function getNomicAIBatchVector(texts, source) { +async function getNomicAIBatchVector(texts, source, directories) { const config = SOURCES[source]; if (!config) { @@ -23,7 +24,7 @@ async function getNomicAIBatchVector(texts, source) { throw new Error('Unknown source'); } - const key = readSecret(config.secretKey); + const key = readSecret(directories, config.secretKey); if (!key) { console.log('No API key found'); @@ -63,10 +64,11 @@ async function getNomicAIBatchVector(texts, source) { * Gets the vector for the given text from an OpenAI compatible endpoint. * @param {string} text - The text to get the vector for * @param {string} source - The source of the vector + * @param {import('./users').UserDirectoryList} directories - The directories object for the user * @returns {Promise} - The vector for the text */ -async function getNomicAIVector(text, source) { - const vectors = await getNomicAIBatchVector([text], source); +async function getNomicAIVector(text, source, directories) { + const vectors = await getNomicAIBatchVector([text], source, directories); return vectors[0]; } diff --git a/src/openai-vectors.js b/src/openai-vectors.js index 60cf9a602..d748658bb 100644 --- a/src/openai-vectors.js +++ b/src/openai-vectors.js @@ -23,10 +23,11 @@ const SOURCES = { * Gets the vector for the given text batch from an OpenAI compatible endpoint. * @param {string[]} texts - The array of texts to get the vector for * @param {string} source - The source of the vector + * @param {import('./users').UserDirectoryList} directories - The directories object for the user * @param {string} model - The model to use for the embedding * @returns {Promise} - The array of vectors for the texts */ -async function getOpenAIBatchVector(texts, source, model = '') { +async function getOpenAIBatchVector(texts, source, directories, model = '') { const config = SOURCES[source]; if (!config) { @@ -34,7 +35,7 @@ async function getOpenAIBatchVector(texts, source, model = '') { throw new Error('Unknown source'); } - const key = readSecret(config.secretKey); + const key = readSecret(directories, config.secretKey); if (!key) { console.log('No API key found'); @@ -78,11 +79,12 @@ async function getOpenAIBatchVector(texts, source, model = '') { * Gets the vector for the given text from an OpenAI compatible endpoint. * @param {string} text - The text to get the vector for * @param {string} source - The source of the vector - * @param model + * @param {import('./users').UserDirectoryList} directories - The directories object for the user + * @param {string} model - The model to use for the embedding * @returns {Promise} - The vector for the text */ -async function getOpenAIVector(text, source, model = '') { - const vectors = await getOpenAIBatchVector([text], source, model); +async function getOpenAIVector(text, source, directories, model = '') { + const vectors = await getOpenAIBatchVector([text], source, directories, model); return vectors[0]; } diff --git a/src/polyfill.js b/src/polyfill.js index 7bed18a1f..2cc9d64e3 100644 --- a/src/polyfill.js +++ b/src/polyfill.js @@ -6,3 +6,5 @@ if (!Array.prototype.findLastIndex) { return -1; }; } + +module.exports = {}; diff --git a/src/users.js b/src/users.js new file mode 100644 index 000000000..eeb69063f --- /dev/null +++ b/src/users.js @@ -0,0 +1,687 @@ +// 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 ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); +const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64'); + +/** + * The root directory for user data. + * @type {string} + */ +let DATA_ROOT = './data'; + +/** + * Cache for user directories. + * @type {Map} + */ +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 - SHA256 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 + */ + +/** + * Ensures that the content directories exist. + * @returns {Promise} - 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; +} + +/** + * 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 ${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.renameSync(migration.old, path.join(backupDirectory, path.basename(migration.old))); + } 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.renameSync(migration.old, path.join(backupDirectory, path.basename(migration.old))); + } + } 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}`; +} + +/** + * Initializes the user storage. Currently a no-op. + * @param {string} dataRoot The root directory for user data + * @returns {Promise} + */ +async function initUserStorage(dataRoot) { + DATA_ROOT = dataRoot; + console.log('Using data root:', color.green(DATA_ROOT)); + console.log(); + await storage.init({ + dir: path.join(DATA_ROOT, '_storage'), + ttl: true, + }); + + 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 SHA256. + * @param {string} password Password to hash + * @param {string} salt Salt to use for hashing + * @returns {string} Hashed password + */ +function getPasswordHash(password, salt) { + return crypto.createHash('sha256').update(password + salt).digest('hex'); +} + +/** + * 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} - 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(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 {string} User avatar URL + */ +function getUserAvatar(handle) { + try { + 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} 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.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, + }; + 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]); + return res.sendFile(filePath, { root: directory }); + } catch (error) { + return res.sendStatus(404); + } + }; +} + +/** + * 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} 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(); +} + +async function checkAccountsProtection() { + if (!ENABLE_ACCOUNTS) { + return; + } + + /** + * @type {User[]} + */ + const users = await storage.values(); + const unprotectedUsers = users.filter(x => x.enabled && x.admin && !x.password); + if (unprotectedUsers.length > 0) { + console.warn(color.red('The following admin users are not password protected:')); + unprotectedUsers.forEach(x => console.warn(color.yellow(x.handle))); + console.log(); + console.warn('Please disable them or set a password in the admin panel.'); + console.log(); + await delay(3000); + } +} + +/** + * 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, + initUserStorage, + ensurePublicDirectoriesExist, + getAllUserHandles, + getUserDirectories, + setUserDataMiddleware, + requireLoginMiddleware, + requireAdminMiddleware, + migrateUserData, + getPasswordSalt, + getPasswordHash, + getCsrfSecret, + getCookieSecret, + getCookieSessionName, + getUserAvatar, + shouldRedirectToLogin, + createBackupArchive, + tryAutoLogin, + checkAccountsProtection, + router, +}; diff --git a/src/util.js b/src/util.js index e23acb689..e1410eee8 100644 --- a/src/util.js +++ b/src/util.js @@ -8,45 +8,26 @@ const yaml = require('yaml'); const { default: simpleGit } = require('simple-git'); const { Readable } = require('stream'); -const { DIRECTORIES } = require('./constants'); +const { PUBLIC_DIRECTORIES } = require('./constants'); /** * Returns the config object from the config.yaml file. * @returns {object} Config object */ function getConfig() { - function getNewConfig() { - try { - const config = yaml.parse(fs.readFileSync(path.join(process.cwd(), './config.yaml'), 'utf8')); - return config; - } catch (error) { - console.warn('Failed to read config.yaml'); - return {}; - } + if (!fs.existsSync('./config.yaml')) { + console.error(color.red('No config file found. Please create a config.yaml file. The default config file can be found in the /default folder.')); + console.error(color.red('The program will now exit.')); + process.exit(1); } - function getLegacyConfig() { - try { - console.log(color.yellow('WARNING: config.conf is deprecated. Please run "npm run postinstall" to convert to config.yaml')); - const config = require(path.join(process.cwd(), './config.conf')); - return config; - } catch (error) { - console.warn('Failed to read config.conf'); - return {}; - } + try { + const config = yaml.parse(fs.readFileSync(path.join(process.cwd(), './config.yaml'), 'utf8')); + return config; + } catch (error) { + console.warn('Failed to read config.yaml'); + return {}; } - - if (fs.existsSync('./config.yaml')) { - return getNewConfig(); - } - - if (fs.existsSync('./config.conf')) { - return getLegacyConfig(); - } - - console.error(color.red('No config file found. Please create a config.yaml file. The default config file can be found in the /default folder.')); - console.error(color.red('The program will now exit.')); - process.exit(1); } /** @@ -60,6 +41,17 @@ function getConfigValue(key, defaultValue = null) { return _.get(config, key, defaultValue); } +/** + * Sets a value for the given key in the config object and writes it to the config.yaml file. + * @param {string} key Key to set + * @param {any} value Value to set + */ +function setConfigValue(key, value) { + const config = getConfig(); + _.set(config, key, value); + fs.writeFileSync('./config.yaml', yaml.stringify(config)); +} + /** * Encodes the Basic Auth header value for the given user and password. * @param {string} auth username:password @@ -321,11 +313,16 @@ function tryParse(str) { /** * Takes a path to a client-accessible file in the `public` folder and converts it to a relative URL segment that the * client can fetch it from. This involves stripping the `public/` prefix and always using `/` as the separator. + * @param {string} root The root directory of the public folder. * @param {string} inputPath The path to be converted. * @returns The relative URL path from which the client can access the file. */ -function clientRelativePath(inputPath) { - return path.normalize(inputPath).split(path.sep).slice(1).join('/'); +function clientRelativePath(root, inputPath) { + if (!inputPath.startsWith(root)) { + throw new Error('Input path does not start with the root directory'); + } + + return inputPath.slice(root.length).split(path.sep).join('/'); } /** @@ -355,9 +352,9 @@ function generateTimestamp() { function removeOldBackups(prefix) { const MAX_BACKUPS = 25; - let files = fs.readdirSync(DIRECTORIES.backups).filter(f => f.startsWith(prefix)); + let files = fs.readdirSync(PUBLIC_DIRECTORIES.backups).filter(f => f.startsWith(prefix)); if (files.length > MAX_BACKUPS) { - files = files.map(f => path.join(DIRECTORIES.backups, f)); + files = files.map(f => path.join(PUBLIC_DIRECTORIES.backups, f)); files.sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs); fs.rmSync(files[0]); @@ -595,6 +592,7 @@ class Cache { module.exports = { getConfig, getConfigValue, + setConfigValue, getVersion, getBasicAuthHeader, extractFileFromZipBuffer, diff --git a/start.sh b/start.sh index 4b96b1c3d..23bf5b87d 100755 --- a/start.sh +++ b/start.sh @@ -21,12 +21,6 @@ then esac fi -# if running on replit patch whitelist -if [ ! -z "$REPL_ID" ]; then - echo -e "Running on Repl.it... \nPatching Whitelist..." - sed -i 's|whitelistMode = true|whitelistMode = false|g' "config.conf" -fi - echo "Installing Node Modules..." export NODE_ENV=production npm i --no-audit --no-fund --quiet --omit=dev