From ec896b8a12ac8fd47f22a4eea0bbd0e93dac3e99 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 6 Apr 2024 17:28:57 +0300 Subject: [PATCH 01/46] Add themes to content manager --- default/content/index.json | 8 ++++++ default/content/themes/Cappuccino.json | 35 +++++++++++++++++++++++++ default/content/themes/Dark Lite.json | 35 +++++++++++++++++++++++++ default/settings.json | 11 ++++---- public/themes/Default (Dark) 1.7.1.json | 21 --------------- public/themes/Ross v2.json | 21 --------------- 6 files changed, 84 insertions(+), 47 deletions(-) create mode 100644 default/content/themes/Cappuccino.json create mode 100644 default/content/themes/Dark Lite.json delete mode 100644 public/themes/Default (Dark) 1.7.1.json delete mode 100644 public/themes/Ross v2.json diff --git a/default/content/index.json b/default/content/index.json index 8a914b959..1ea4d7b95 100644 --- a/default/content/index.json +++ b/default/content/index.json @@ -1,4 +1,12 @@ [ + { + "filename": "themes/Dark Lite.json", + "type": "theme" + }, + { + "filename": "themes/Cappuccino.json", + "type": "theme" + }, { "filename": "default_Seraphina.png", "type": "character" 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/default/settings.json b/default/settings.json index dbd731c45..ef7f4f0b1 100644 --- a/default/settings.json +++ b/default/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/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 From 59daeeb37a69a4e8364d20156ec0426b4f67893e Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 6 Apr 2024 17:43:59 +0300 Subject: [PATCH 02/46] Move default backgrounds to content manager --- .../content}/backgrounds/__transparent.png | Bin .../content}/backgrounds/_black.jpg | Bin .../content}/backgrounds/_white.jpg | Bin .../content}/backgrounds/bedroom clean.jpg | Bin .../backgrounds/bedroom cyberpunk.jpg | Bin .../content}/backgrounds/bedroom red.jpg | Bin .../content}/backgrounds/bedroom tatami.jpg | Bin .../backgrounds/cityscape medieval market.jpg | Bin .../backgrounds/cityscape medieval night.jpg | Bin .../backgrounds/cityscape postapoc.jpg | Bin ...fireworks air baloons (by kallmeflocc).jpg | Bin .../backgrounds/japan classroom side.jpg | Bin .../content}/backgrounds/japan classroom.jpg | Bin .../backgrounds/japan path cherry blossom.jpg | Bin .../content}/backgrounds/japan university.jpg | Bin .../landscape autumn great tree.jpg | Bin .../backgrounds/landscape beach day.png | Bin .../backgrounds/landscape beach night.jpg | Bin .../backgrounds/landscape mountain lake.jpg | Bin .../backgrounds/landscape postapoc.jpg | Bin .../landscape winter lake house.jpg | Bin .../content}/backgrounds/royal.jpg | Bin .../content}/backgrounds/tavern day.jpg | Bin default/content/index.json | 93 +++++++++++++++++- public/style.css | 2 +- server.js | 2 +- 26 files changed, 94 insertions(+), 3 deletions(-) rename {public => default/content}/backgrounds/__transparent.png (100%) rename {public => default/content}/backgrounds/_black.jpg (100%) rename {public => default/content}/backgrounds/_white.jpg (100%) rename {public => default/content}/backgrounds/bedroom clean.jpg (100%) rename {public => default/content}/backgrounds/bedroom cyberpunk.jpg (100%) rename {public => default/content}/backgrounds/bedroom red.jpg (100%) rename {public => default/content}/backgrounds/bedroom tatami.jpg (100%) rename {public => default/content}/backgrounds/cityscape medieval market.jpg (100%) rename {public => default/content}/backgrounds/cityscape medieval night.jpg (100%) rename {public => default/content}/backgrounds/cityscape postapoc.jpg (100%) rename {public => default/content}/backgrounds/forest treehouse fireworks air baloons (by kallmeflocc).jpg (100%) rename {public => default/content}/backgrounds/japan classroom side.jpg (100%) rename {public => default/content}/backgrounds/japan classroom.jpg (100%) rename {public => default/content}/backgrounds/japan path cherry blossom.jpg (100%) rename {public => default/content}/backgrounds/japan university.jpg (100%) rename {public => default/content}/backgrounds/landscape autumn great tree.jpg (100%) rename {public => default/content}/backgrounds/landscape beach day.png (100%) rename {public => default/content}/backgrounds/landscape beach night.jpg (100%) rename {public => default/content}/backgrounds/landscape mountain lake.jpg (100%) rename {public => default/content}/backgrounds/landscape postapoc.jpg (100%) rename {public => default/content}/backgrounds/landscape winter lake house.jpg (100%) rename {public => default/content}/backgrounds/royal.jpg (100%) rename {public => default/content}/backgrounds/tavern day.jpg (100%) 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 1ea4d7b95..3cb55f8be 100644 --- a/default/content/index.json +++ b/default/content/index.json @@ -7,6 +7,98 @@ "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" @@ -219,7 +311,6 @@ "filename": "presets/novel/Writers-Daemon-Kayra.json", "type": "novel_preset" }, - { "filename": "presets/textgen/Asterism.json", "type": "textgen_preset" diff --git a/public/style.css b/public/style.css index f6516beed..dedc2460b 100644 --- a/public/style.css +++ b/public/style.css @@ -456,7 +456,7 @@ body.reduced-motion #bg_custom { } #bg1 { - background-image: url('backgrounds/__transparent.png'); + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='); z-index: -3; } diff --git a/server.js b/server.js index b6f59f0a3..85ebf555f 100644 --- a/server.js +++ b/server.js @@ -489,8 +489,8 @@ const setupTasks = async function () { // in any order for encapsulation reasons, but right now it's unknown if that would break anything. await settingsEndpoint.init(); ensurePublicDirectoriesExist(); - await ensureThumbnailCache(); contentManager.checkForNewContent(); + await ensureThumbnailCache(); cleanUploads(); await loadTokenizers(); From b3b7017bf22627f2610bdee5c591e8bbe77d78e3 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 6 Apr 2024 17:55:53 +0300 Subject: [PATCH 03/46] Move default QR and MovingUI to content manager --- default/content/index.json | 12 ++++++++++++ .../content/presets/moving-ui}/Black Magic Time.json | 0 .../content/presets/moving-ui}/Default.json | 0 .../content/presets/quick-replies}/Default.json | 0 public/QuickReplies/.gitkeep | 0 public/backgrounds/.gitkeep | 0 public/movingUI/.gitkeep | 0 src/endpoints/content-manager.js | 4 ++++ 8 files changed, 16 insertions(+) rename {public/movingUI => default/content/presets/moving-ui}/Black Magic Time.json (100%) rename {public/movingUI => default/content/presets/moving-ui}/Default.json (100%) rename {public/QuickReplies => default/content/presets/quick-replies}/Default.json (100%) create mode 100644 public/QuickReplies/.gitkeep create mode 100644 public/backgrounds/.gitkeep create mode 100644 public/movingUI/.gitkeep diff --git a/default/content/index.json b/default/content/index.json index 3cb55f8be..80726f2fb 100644 --- a/default/content/index.json +++ b/default/content/index.json @@ -610,5 +610,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/public/QuickReplies/.gitkeep b/public/QuickReplies/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/public/backgrounds/.gitkeep b/public/backgrounds/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/public/movingUI/.gitkeep b/public/movingUI/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/endpoints/content-manager.js b/src/endpoints/content-manager.js index bbb444faf..ea9b5f5ca 100644 --- a/src/endpoints/content-manager.js +++ b/src/endpoints/content-manager.js @@ -163,6 +163,10 @@ function getTargetByType(type) { return DIRECTORIES.instruct; case 'context': return DIRECTORIES.context; + case 'moving_ui': + return DIRECTORIES.movingUI; + case 'quick_replies': + return DIRECTORIES.quickreplies; default: return null; } From cd5aec7368a44a14e3bf87de2a65db6749634a3c Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 6 Apr 2024 20:09:39 +0300 Subject: [PATCH 04/46] Split user directories from public, part 1 --- .gitignore | 1 + data/.gitkeep | 0 default/config.yaml | 2 + default/content/index.json | 4 + default/{ => content}/settings.json | 0 index.d.ts | 12 +++ jsconfig.json | 2 +- post-install.js | 26 ----- server.js | 41 +++----- src/constants.js | 73 ++++++++----- src/endpoints/content-manager.js | 154 ++++++++++++++------------- src/endpoints/presets.js | 28 ++--- src/endpoints/secrets.js | 59 ----------- src/endpoints/settings.js | 65 +++++++----- src/endpoints/themes.js | 5 +- src/endpoints/thumbnails.js | 68 ++++++------ src/users.js | 155 ++++++++++++++++++++++++++++ src/util.js | 6 +- 18 files changed, 406 insertions(+), 295 deletions(-) create mode 100644 data/.gitkeep rename default/{ => content}/settings.json (100%) create mode 100644 index.d.ts create mode 100644 src/users.js diff --git a/.gitignore b/.gitignore index 72a123efd..64b33ddb2 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ access.log /cache/ public/css/user.css /plugins/ +/data diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/default/config.yaml b/default/config.yaml index dedb5ac5f..506a270e9 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -1,4 +1,6 @@ # -- NETWORK CONFIGURATION -- +# Root directory for user data storage +dataRoot: ./data # Listen for incoming connections listen: false # Server port diff --git a/default/content/index.json b/default/content/index.json index 80726f2fb..d81d37552 100644 --- a/default/content/index.json +++ b/default/content/index.json @@ -1,4 +1,8 @@ [ + { + "filename": "settings.json", + "type": "settings" + }, { "filename": "themes/Dark Lite.json", "type": "theme" diff --git a/default/settings.json b/default/content/settings.json similarity index 100% rename from default/settings.json rename to default/content/settings.json diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 000000000..417bf315c --- /dev/null +++ b/index.d.ts @@ -0,0 +1,12 @@ +import { UserDirectoryList, User } from "./src/users"; + +declare global { + namespace Express { + export interface Request { + user: { + profile: User; + directories: UserDirectoryList; + }; + } + } +} diff --git a/jsconfig.json b/jsconfig.json index 652e04b1c..6f8f1a02d 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -14,4 +14,4 @@ "node_modules", "**/node_modules/*" ] -} \ No newline at end of file +} 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/server.js b/server.js index 85ebf555f..4edabbf77 100644 --- a/server.js +++ b/server.js @@ -33,6 +33,7 @@ util.inspect.defaultOptions.maxStringLength = null; util.inspect.defaultOptions.depth = 4; // local library imports +const { initUserStorage, userDataMiddleware, getUserDirectories, getAllUserHandles } = require('./src/users'); const basicAuthMiddleware = require('./src/middleware/basicAuth'); const whitelistMiddleware = require('./src/middleware/whitelist'); const contentManager = require('./src/endpoints/content-manager'); @@ -112,7 +113,7 @@ const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN); const enableCorsProxy = cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', DEFAULT_CORS_PROXY); const basicAuthMode = getConfigValue('basicAuthMode', false); -const { DIRECTORIES, UPLOADS_PATH } = require('./src/constants'); +const { UPLOADS_PATH, PUBLIC_DIRECTORIES } = require('./src/constants'); // CORS Settings // const CORS = cors({ @@ -211,29 +212,8 @@ if (enableCorsProxy) { } app.use(express.static(process.cwd() + '/public', {})); +app.use(userDataMiddleware(app)); -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); - }); -}); - -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); - }); -}); 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'); @@ -487,6 +467,7 @@ 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 initUserStorage(); await settingsEndpoint.init(); ensurePublicDirectoriesExist(); contentManager.checkForNewContent(); @@ -579,10 +560,20 @@ if (cliArguments.ssl) { ); } -function ensurePublicDirectoriesExist() { - for (const dir of Object.values(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(); + for (const handle of userHandles) { + const userDirectories = getUserDirectories(handle); + for (const dir of Object.values(userDirectories)) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + } + } } diff --git a/src/constants.js b/src/constants.js index 918374eab..d6a639ea3 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,34 +1,51 @@ -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', }; +/** + * @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: 'scripts/extensions', + instruct: 'instruct', + context: 'context', + quickreplies: 'QuickReplies', + assets: 'assets', + comfyWorkflows: 'user/workflows', + files: 'user/files', +}); + +const DEFAULT_USER = Object.freeze({ + uuid: '00000000-0000-0000-0000-000000000000', + handle: 'user0', + name: 'User', + created: 0, + password: '', +}); + const UNSAFE_EXTENSIONS = [ '.php', '.exe', @@ -270,7 +287,9 @@ const OPENROUTER_KEYS = [ ]; module.exports = { - DIRECTORIES, + DEFAULT_USER, + PUBLIC_DIRECTORIES, + USER_DIRECTORY_TEMPLATE, UNSAFE_EXTENSIONS, UPLOADS_PATH, GEMINI_SAFETY, diff --git a/src/endpoints/content-manager.js b/src/endpoints/content-manager.js index ea9b5f5ca..202411043 100644 --- a/src/endpoints/content-manager.js +++ b/src/endpoints/content-manager.js @@ -6,17 +6,16 @@ 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 { getAllUserHandles, getUserDirectories } = require('../users'); const characterCardParser = require('../character-card-parser.js'); /** * 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 +25,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,120 +58,117 @@ 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}`); - } - } - } - } -} - -function checkForNewContent() { +async function checkForNewContent() { try { - migratePresets(); - if (getConfigValue('skipContentCheck', false)) { return; } - const contentLog = getContentLog(); const contentIndexText = fs.readFileSync(contentIndexPath, 'utf8'); const contentIndex = JSON.parse(contentIndexText); + const userHandles = await getAllUserHandles(); - for (const contentItem of contentIndex) { - // If the content item is already in the log, skip it - if (contentLog.includes(contentItem.filename)) { - continue; + for (const userHandle of userHandles) { + const directories = getUserDirectories(userHandle); + + if (!fs.existsSync(directories.root)) { + fs.mkdirSync(directories.root, { recursive: true }); } - contentLog.push(contentItem.filename); - const contentPath = path.join(contentDirectory, contentItem.filename); + const contentLogPath = path.join(directories.root, 'content.log'); + const contentLog = getContentLog(contentLogPath); - if (!fs.existsSync(contentPath)) { - console.log(`Content file ${contentItem.filename} is missing`); - continue; + 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, 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(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}`); } - 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}`); + fs.writeFileSync(contentLogPath, contentLog.join('\n')); } - - 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 {string} type Asset type + * @param {import('../users').UserDirectoryList} directories User directories + * @returns {string | null} Target directory + */ +function getTargetByType(type, directories) { switch (type) { + case 'settings': + return directories.root; case 'character': - return DIRECTORIES.characters; + return directories.characters; case 'sprites': - return DIRECTORIES.characters; + return directories.characters; case 'background': - return DIRECTORIES.backgrounds; + return directories.backgrounds; case 'world': - return DIRECTORIES.worlds; - case 'sound': - return DIRECTORIES.sounds; + return directories.worlds; case 'avatar': - return DIRECTORIES.avatars; + return directories.avatars; case 'theme': - return DIRECTORIES.themes; + return directories.themes; case 'workflow': - return DIRECTORIES.comfyWorkflows; + return directories.comfyWorkflows; case 'kobold_preset': - return DIRECTORIES.koboldAI_Settings; + return directories.koboldAI_Settings; case 'openai_preset': - return DIRECTORIES.openAI_Settings; + return directories.openAI_Settings; case 'novel_preset': - return DIRECTORIES.novelAI_Settings; + return directories.novelAI_Settings; case 'textgen_preset': - return DIRECTORIES.textGen_Settings; + return directories.textGen_Settings; case 'instruct': - return DIRECTORIES.instruct; + return directories.instruct; case 'context': - return DIRECTORIES.context; + return directories.context; case 'moving_ui': - return DIRECTORIES.movingUI; + return directories.movingUI; case 'quick_replies': - return DIRECTORIES.quickreplies; + 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 []; } 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/secrets.js b/src/endpoints/secrets.js index afd41a1f7..980c5eb7b 100644 --- a/src/endpoints/secrets.js +++ b/src/endpoints/secrets.js @@ -109,64 +109,6 @@ 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 * @returns {Record | undefined} Secrets @@ -251,7 +193,6 @@ module.exports = { writeSecret, readSecret, readSecretState, - migrateSecrets, getAllSecrets, SECRET_KEYS, router, diff --git a/src/endpoints/settings.js b/src/endpoints/settings.js index aae32b84d..ad5914912 100644 --- a/src/endpoints/settings.js +++ b/src/endpoints/settings.js @@ -2,13 +2,13 @@ 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 } = require('../constants'); const { getConfigValue, generateTimestamp, removeOldBackups } = require('../util'); const { jsonParser } = require('../express-common'); -const { migrateSecrets } = require('./secrets'); +const { getAllUserHandles, getUserDirectories } = require('../users'); +const SETTINGS_FILE = 'settings.json'; const enableExtensions = getConfigValue('enableExtensions', true); -const SETTINGS_FILE = './public/settings.json'; function readAndParseFromDirectory(directoryPath, fileExtension = '.json') { const files = fs @@ -61,16 +61,22 @@ 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) { + const userDirectories = getUserDirectories(handle); + const backupFile = path.join(PUBLIC_DIRECTORIES.backups, `settings_${handle}_${generateTimestamp()}.json`); + const sourceFile = path.join(userDirectories.root, SETTINGS_FILE); + fs.copyFileSync(sourceFile, backupFile); + + removeOldBackups(`settings_${handle}`); + } } catch (err) { console.log('Could not backup settings file', err); } @@ -80,7 +86,8 @@ 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 +99,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, @@ -155,10 +163,11 @@ router.post('/get', jsonParser, (request, response) => { }); }); -// Sync for now, but should probably be migrated to async file APIs +/** + * Initializes the settings endpoint + */ async function init() { - backupSettings(); - migrateSecrets(SETTINGS_FILE); + await backupSettings(); } module.exports = { router, init }; 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..6815efa4c 100644 --- a/src/endpoints/thumbnails.js +++ b/src/endpoints/thumbnails.js @@ -4,24 +4,25 @@ 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 +31,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 +52,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 +69,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,24 +137,28 @@ 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(); @@ -176,13 +184,13 @@ router.get('/', jsonParser, async function (request, response) { } if (getConfigValue('disableThumbnails', false) == true) { - let folder = getOriginalFolder(type); + let folder = getOriginalFolder(request.user.directories, 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); + const pathToCachedFile = await generateThumbnail(request.user.directories, type, file); if (!pathToCachedFile) { return response.sendStatus(404); diff --git a/src/users.js b/src/users.js new file mode 100644 index 000000000..dab9f1391 --- /dev/null +++ b/src/users.js @@ -0,0 +1,155 @@ +const fsPromises = require('fs').promises; +const path = require('path'); +const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER } = require('./constants'); +const { getConfigValue } = require('./util'); + +const DATA_ROOT = getConfigValue('dataRoot', './data'); + +/** + * @typedef {Object} User + * @property {string} uuid - The user's id + * @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 + */ + +/** + * @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 + */ + +/** + * Initializes the user storage. Currently a no-op. + * @returns {Promise} + */ +async function initUserStorage() { + return Promise.resolve(); +} + +/** + * Gets a user for the current request. Hard coded to return the default user. + * @param {import('express').Request} _req - The request object. Currently unused. + * @returns {Promise} - The user's handle + */ +async function getCurrentUserHandle(_req) { + return DEFAULT_USER.handle; +} + +/** + * Gets a list of all user handles. Currently hard coded to return the default user's handle. + * @returns {Promise} - The list of user handles + */ +async function getAllUserHandles() { + return [DEFAULT_USER.handle]; +} + +/** + * Gets the directories listing for the provided user. + * @param {import('express').Request} req - The request object + * @returns {Promise} - The user's directories like {worlds: 'data/user0/worlds/', ... + */ +async function getCurrentUserDirectories(req) { + const handle = await getCurrentUserHandle(req); + return getUserDirectories(handle); +} + +/** + * Gets the directories listing for the provided user. + * @param {string} handle User handle + * @returns {UserDirectoryList} User directories + */ +function getUserDirectories(handle) { + const directories = structuredClone(USER_DIRECTORY_TEMPLATE); + for (const key in directories) { + directories[key] = path.join(DATA_ROOT, handle, USER_DIRECTORY_TEMPLATE[key]); + } + return directories; +} + +/** + * Middleware to add user data to the request object. + * @param {import('express').Application} app - The express app + * @returns {import('express').RequestHandler} + */ +function userDataMiddleware(app) { + app.use('/backgrounds/:path', async (req, res) => { + try { + const filePath = path.join(process.cwd(), req.user.directories.backgrounds, decodeURIComponent(req.params.path)); + const data = await fsPromises.readFile(filePath); + return res.send(data); + } + catch { + return res.sendStatus(404); + } + }); + + app.use('/characters/:path', async (req, res) => { + try { + const filePath = path.join(process.cwd(), req.user.directories.characters, decodeURIComponent(req.params.path)); + const data = await fsPromises.readFile(filePath); + return res.send(data); + } + catch { + return res.sendStatus(404); + } + }); + + app.use('/User Avatars/:path', async (req, res) => { + try { + const filePath = path.join(process.cwd(), req.user.directories.avatars, decodeURIComponent(req.params.path)); + const data = await fsPromises.readFile(filePath); + return res.send(data); + } + catch { + return res.sendStatus(404); + } + }); + + /** + * Middleware to add user data to the request object. + * @param {import('express').Request} req Request object + * @param {import('express').Response} res Response object + * @param {import('express').NextFunction} next Next function + */ + return async (req, res, next) => { + const directories = await getCurrentUserDirectories(req); + req.user.profile = DEFAULT_USER; + req.user.directories = directories; + next(); + }; +} + +module.exports = { + initUserStorage, + getCurrentUserDirectories, + getCurrentUserHandle, + getAllUserHandles, + getUserDirectories, + userDataMiddleware, +}; diff --git a/src/util.js b/src/util.js index e23acb689..dfbc1e6db 100644 --- a/src/util.js +++ b/src/util.js @@ -8,7 +8,7 @@ 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. @@ -355,9 +355,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]); From b07a6a9a788310016ff0b3b1a6bbad03d3d065fe Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sun, 7 Apr 2024 01:47:07 +0300 Subject: [PATCH 05/46] Update all endpoints to use user directories --- jsconfig.json | 3 +- public/script.js | 10 +- server.js | 3 +- src/additional-headers.js | 94 ++- src/constants.js | 4 +- src/endpoints/anthropic.js | 2 +- src/endpoints/assets.js | 48 +- src/endpoints/avatars.js | 8 +- src/endpoints/backends/chat-completions.js | 36 +- src/endpoints/backends/scale-alt.js | 2 +- src/endpoints/backgrounds.js | 19 +- src/endpoints/characters.js | 647 +++++++++++---------- src/endpoints/chats.js | 192 +++--- src/endpoints/extensions.js | 26 +- src/endpoints/files.js | 5 +- src/endpoints/google.js | 3 +- src/endpoints/groups.js | 27 +- src/endpoints/horde.js | 10 +- src/endpoints/images.js | 15 +- src/endpoints/moving-ui.js | 3 +- src/endpoints/novelai.js | 8 +- src/endpoints/openai.js | 16 +- src/endpoints/quick-replies.js | 5 +- src/endpoints/secrets.js | 71 ++- src/endpoints/serpapi.js | 2 +- src/endpoints/settings.js | 6 +- src/endpoints/sprites.js | 24 +- src/endpoints/stable-diffusion.js | 47 +- src/endpoints/stats.js | 149 +++-- src/endpoints/tokenizers.js | 6 +- src/endpoints/translate.js | 12 +- src/endpoints/vectors.js | 55 +- src/endpoints/worldinfo.js | 15 +- src/makersuite-vectors.js | 10 +- src/nomicai-vectors.js | 10 +- src/openai-vectors.js | 12 +- src/polyfill.js | 2 + src/users.js | 76 +-- src/util.js | 9 +- 39 files changed, 941 insertions(+), 751 deletions(-) diff --git a/jsconfig.json b/jsconfig.json index 6f8f1a02d..bcf9db917 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -12,6 +12,7 @@ }, "exclude": [ "node_modules", - "**/node_modules/*" + "**/node_modules/*", + "public/lib" ] } diff --git a/public/script.js b/public/script.js index 18e15e8e2..e81adefef 100644 --- a/public/script.js +++ b/public/script.js @@ -1543,7 +1543,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({ @@ -1551,11 +1551,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']); diff --git a/server.js b/server.js index 4edabbf77..c9615896b 100644 --- a/server.js +++ b/server.js @@ -212,7 +212,8 @@ if (enableCorsProxy) { } app.use(express.static(process.cwd() + '/public', {})); -app.use(userDataMiddleware(app)); +app.use(userDataMiddleware()); +app.use('/', require('./src/users').router); app.use(multer({ dest: UPLOADS_PATH, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); app.get('/', function (request, response) { 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 d6a639ea3..01ca9af96 100644 --- a/src/constants.js +++ b/src/constants.js @@ -2,6 +2,7 @@ const PUBLIC_DIRECTORIES = { images: 'public/img/', backups: 'backups/', sounds: 'public/sounds', + extensions: 'public/scripts/extensions', }; /** @@ -29,13 +30,14 @@ const USER_DIRECTORY_TEMPLATE = Object.freeze({ textGen_Settings: 'TextGen Settings', themes: 'themes', movingUI: 'movingUI', - extensions: 'scripts/extensions', + extensions: 'extensions', instruct: 'instruct', context: 'context', quickreplies: 'QuickReplies', assets: 'assets', comfyWorkflows: 'user/workflows', files: 'user/files', + vectors: 'vectors', }); const DEFAULT_USER = Object.freeze({ 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..352eb2476 100644 --- a/src/endpoints/assets.js +++ b/src/endpoints/assets.js @@ -4,11 +4,11 @@ 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 +48,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 +82,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 +115,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 +131,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 +142,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 +185,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 +194,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,6 +212,7 @@ 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') { @@ -235,7 +251,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 +260,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 +306,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 593f034b2..8f3bea56a 100644 --- a/src/endpoints/backends/chat-completions.js +++ b/src/endpoints/backends/chat-completions.js @@ -81,7 +81,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) { @@ -162,7 +162,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.'); @@ -213,7 +213,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.'); @@ -367,7 +367,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, @@ -431,7 +431,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.'); @@ -522,8 +522,14 @@ async function sendMistralAIRequest(request, response) { } } +/** + * Sends a request to Cohere API. + * @param {import('express').Request} request + * @param {import('express').Response} response + * @returns {Promise} + */ 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 () { @@ -612,25 +618,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.'); @@ -795,10 +801,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; } @@ -812,7 +819,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'] }; @@ -834,10 +841,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/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..4ed9e7c0c 100644 --- a/src/endpoints/characters.js +++ b/src/endpoints/characters.js @@ -9,7 +9,7 @@ const _ = require('lodash'); 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 +19,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 +148,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 +212,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 +233,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 +299,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 +370,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 +449,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 +473,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 +655,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 +685,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 +706,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 +746,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 +769,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 +791,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 +829,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 +845,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 +865,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 +907,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 +918,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 +968,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 +998,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 +1037,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 +1059,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++; } @@ -1025,7 +1082,7 @@ router.post('/export', jsonParser, async function (request, response) { 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); @@ -1036,9 +1093,9 @@ router.post('/export', jsonParser, async function (request, response) { return response.sendFile(filename, { root: process.cwd() }); case 'json': { try { - let json = await charaRead(filename); + let json = await readCharacterData(filename); if (json === undefined) return response.sendStatus(400); - let jsonObject = getCharaCardV2(JSON.parse(json)); + let jsonObject = getCharaCardV2(JSON.parse(json), request.user.directories); return response.type('json').send(JSON.stringify(jsonObject, null, 4)); } catch { 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/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 f8a117e84..ac83cb06b 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); } @@ -75,11 +74,11 @@ router.post('/create', jsonParser, (request, response) => { chats: request.body.chats ?? [id], auto_mode_delay: request.body.auto_mode_delay ?? 5, }; - 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); @@ -91,7 +90,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); @@ -104,7 +103,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 @@ -113,7 +112,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/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 980c5eb7b..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 = {}; @@ -111,15 +128,18 @@ function readSecretState() { /** * 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; } @@ -130,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); @@ -144,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) { @@ -153,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); @@ -176,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); @@ -192,6 +212,7 @@ router.post('/find', jsonParser, (request, response) => { module.exports = { writeSecret, readSecret, + deleteSecret, readSecretState, getAllSecrets, SECRET_KEYS, 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 ad5914912..ce5baf6cc 100644 --- a/src/endpoints/settings.js +++ b/src/endpoints/settings.js @@ -73,8 +73,12 @@ async function backupSettings() { const userDirectories = getUserDirectories(handle); const backupFile = path.join(PUBLIC_DIRECTORIES.backups, `settings_${handle}_${generateTimestamp()}.json`); const sourceFile = path.join(userDirectories.root, SETTINGS_FILE); - fs.copyFileSync(sourceFile, backupFile); + if (!fs.existsSync(sourceFile)) { + continue; + } + + fs.copyFileSync(sourceFile, backupFile); removeOldBackups(`settings_${handle}`); } } catch (err) { 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 e2168cd80..ed2c15b82 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.'); @@ -684,7 +683,7 @@ drawthings.post('/generate', jsonParser, async (request, response) => { const url = new URL(request.body.url); url.pathname = '/sdapi/v1/txt2img'; - const body = {...request.body}; + const body = { ...request.body }; delete body.url; const result = await fetch(url, { diff --git a/src/endpoints/stats.js b/src/endpoints/stats.js index b4ff37ab2..b6195c8cc 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,20 +451,12 @@ 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); }); diff --git a/src/endpoints/tokenizers.js b/src/endpoints/tokenizers.js index e6fba800a..3da2e6d30 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)) }), }; 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/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/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/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 index dab9f1391..10409e2f2 100644 --- a/src/users.js +++ b/src/users.js @@ -1,7 +1,7 @@ -const fsPromises = require('fs').promises; const path = require('path'); const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER } = require('./constants'); const { getConfigValue } = require('./util'); +const express = require('express'); const DATA_ROOT = getConfigValue('dataRoot', './data'); @@ -42,6 +42,7 @@ const DATA_ROOT = getConfigValue('dataRoot', './data'); * @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 */ /** @@ -94,43 +95,9 @@ function getUserDirectories(handle) { /** * Middleware to add user data to the request object. - * @param {import('express').Application} app - The express app * @returns {import('express').RequestHandler} */ -function userDataMiddleware(app) { - app.use('/backgrounds/:path', async (req, res) => { - try { - const filePath = path.join(process.cwd(), req.user.directories.backgrounds, decodeURIComponent(req.params.path)); - const data = await fsPromises.readFile(filePath); - return res.send(data); - } - catch { - return res.sendStatus(404); - } - }); - - app.use('/characters/:path', async (req, res) => { - try { - const filePath = path.join(process.cwd(), req.user.directories.characters, decodeURIComponent(req.params.path)); - const data = await fsPromises.readFile(filePath); - return res.send(data); - } - catch { - return res.sendStatus(404); - } - }); - - app.use('/User Avatars/:path', async (req, res) => { - try { - const filePath = path.join(process.cwd(), req.user.directories.avatars, decodeURIComponent(req.params.path)); - const data = await fsPromises.readFile(filePath); - return res.send(data); - } - catch { - return res.sendStatus(404); - } - }); - +function userDataMiddleware() { /** * Middleware to add user data to the request object. * @param {import('express').Request} req Request object @@ -139,12 +106,44 @@ function userDataMiddleware(app) { */ return async (req, res, next) => { const directories = await getCurrentUserDirectories(req); - req.user.profile = DEFAULT_USER; - req.user.directories = directories; + req.user = { + profile: DEFAULT_USER, + directories: directories, + }; 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) { + console.error(error); + return res.sendStatus(404); + } + }; +} + +/** + * 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 Avatars/*', 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 = { initUserStorage, getCurrentUserDirectories, @@ -152,4 +151,5 @@ module.exports = { getAllUserHandles, getUserDirectories, userDataMiddleware, + router, }; diff --git a/src/util.js b/src/util.js index dfbc1e6db..b823419a5 100644 --- a/src/util.js +++ b/src/util.js @@ -321,11 +321,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('/'); } /** From 11193896b225a79eb017049c828eea63e0f23863 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sun, 7 Apr 2024 03:01:55 +0300 Subject: [PATCH 06/46] Add data migration procedure --- public/KoboldAI Settings/.gitkeep | 0 public/NovelAI Settings/.gitkeep | 0 public/OpenAI Settings/.gitkeep | 0 public/QuickReplies/.gitkeep | 0 public/TextGen Settings/.gitkeep | 0 public/User Avatars/README.md | 1 - public/assets/ambient/.placeholder | 1 - public/assets/bgm/.placeholder | 1 - public/assets/blip/.placeholder | 1 - public/assets/live2d/.placeholder | 1 - public/assets/temp/.placeholder | 0 public/assets/vrm/animation/.placeholder | 1 - public/assets/vrm/model/.placeholder | 1 - public/backgrounds/.gitkeep | 0 public/characters/.gitkeep | 8 - public/chats/.gitkeep | 5 - public/context/.gitkeep | 0 public/group chats/.gitkeep | 1 - public/groups/.gitkeep | 1 - public/instruct/.gitkeep | 0 public/movingUI/.gitkeep | 0 public/themes/.gitkeep | 0 public/user/.gitkeep | 0 public/worlds/README.md | 1 - server.js | 9 +- src/users.js | 203 ++++++++++++++++++++++- 26 files changed, 208 insertions(+), 27 deletions(-) delete mode 100644 public/KoboldAI Settings/.gitkeep delete mode 100644 public/NovelAI Settings/.gitkeep delete mode 100644 public/OpenAI Settings/.gitkeep delete mode 100644 public/QuickReplies/.gitkeep delete mode 100644 public/TextGen Settings/.gitkeep delete mode 100644 public/User Avatars/README.md delete mode 100644 public/assets/ambient/.placeholder delete mode 100644 public/assets/bgm/.placeholder delete mode 100644 public/assets/blip/.placeholder delete mode 100644 public/assets/live2d/.placeholder delete mode 100644 public/assets/temp/.placeholder delete mode 100644 public/assets/vrm/animation/.placeholder delete mode 100644 public/assets/vrm/model/.placeholder delete mode 100644 public/backgrounds/.gitkeep delete mode 100644 public/characters/.gitkeep delete mode 100644 public/chats/.gitkeep delete mode 100644 public/context/.gitkeep delete mode 100644 public/group chats/.gitkeep delete mode 100644 public/groups/.gitkeep delete mode 100644 public/instruct/.gitkeep delete mode 100644 public/movingUI/.gitkeep delete mode 100644 public/themes/.gitkeep delete mode 100644 public/user/.gitkeep delete mode 100644 public/worlds/README.md diff --git a/public/KoboldAI Settings/.gitkeep b/public/KoboldAI Settings/.gitkeep deleted file mode 100644 index e69de29bb..000000000 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/QuickReplies/.gitkeep b/public/QuickReplies/.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/backgrounds/.gitkeep b/public/backgrounds/.gitkeep deleted file mode 100644 index e69de29bb..000000000 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/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/instruct/.gitkeep b/public/instruct/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/public/movingUI/.gitkeep b/public/movingUI/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/public/themes/.gitkeep b/public/themes/.gitkeep deleted file mode 100644 index e69de29bb..000000000 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/server.js b/server.js index c9615896b..de2123cd2 100644 --- a/server.js +++ b/server.js @@ -33,7 +33,13 @@ util.inspect.defaultOptions.maxStringLength = null; util.inspect.defaultOptions.depth = 4; // local library imports -const { initUserStorage, userDataMiddleware, getUserDirectories, getAllUserHandles } = require('./src/users'); +const { + initUserStorage, + userDataMiddleware, + getUserDirectories, + getAllUserHandles, + migrateUserData, +} = require('./src/users'); const basicAuthMiddleware = require('./src/middleware/basicAuth'); const whitelistMiddleware = require('./src/middleware/whitelist'); const contentManager = require('./src/endpoints/content-manager'); @@ -471,6 +477,7 @@ const setupTasks = async function () { await initUserStorage(); await settingsEndpoint.init(); ensurePublicDirectoriesExist(); + await migrateUserData(); contentManager.checkForNewContent(); await ensureThumbnailCache(); cleanUploads(); diff --git a/src/users.js b/src/users.js index 10409e2f2..6c9a62e53 100644 --- a/src/users.js +++ b/src/users.js @@ -1,6 +1,7 @@ const path = require('path'); -const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER } = require('./constants'); -const { getConfigValue } = require('./util'); +const fs = require('fs'); +const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES } = require('./constants'); +const { getConfigValue, color, delay } = require('./util'); const express = require('express'); const DATA_ROOT = getConfigValue('dataRoot', './data'); @@ -45,6 +46,201 @@ const DATA_ROOT = getConfigValue('dataRoot', './data'); * @property {string} vectors - The directory where the vectors are stored */ +/** + * 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!')); +} + /** * Initializes the user storage. Currently a no-op. * @returns {Promise} @@ -138,7 +334,7 @@ function createRouteHandler(directoryFn) { 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 Avatars/*', createRouteHandler(req => req.user.directories.avatars)); +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)); @@ -151,5 +347,6 @@ module.exports = { getAllUserHandles, getUserDirectories, userDataMiddleware, + migrateUserData, router, }; From b07aef02c78a3ac4992e3b97b862073334963bff Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sun, 7 Apr 2024 16:41:23 +0300 Subject: [PATCH 07/46] Persist CSRF and cookie secrets across server launches --- default/config.yaml | 2 ++ jsconfig.json | 3 ++- package-lock.json | 9 +++++++ package.json | 1 + server.js | 10 ++++---- src/constants.js | 6 +++++ src/users.js | 61 +++++++++++++++++++++++++++++++++++++++++++-- src/util.js | 51 ++++++++++++++++--------------------- 8 files changed, 106 insertions(+), 37 deletions(-) diff --git a/default/config.yaml b/default/config.yaml index 506a270e9..2dde00f76 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -18,6 +18,8 @@ basicAuthUser: password: "password" # Enables CORS proxy middleware enableCorsProxy: 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/jsconfig.json b/jsconfig.json index bcf9db917..e4298105a 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -13,6 +13,7 @@ "exclude": [ "node_modules", "**/node_modules/*", - "public/lib" + "public/lib", + "backups/*", ] } diff --git a/package-lock.json b/package-lock.json index dca9e969d..11c382d41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "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", @@ -2735,6 +2736,14 @@ } } }, + "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-url": { "version": "6.1.0", "license": "MIT", diff --git a/package.json b/package.json index d491384c0..610d2df7f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "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", diff --git a/server.js b/server.js index de2123cd2..add84e1b6 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'); @@ -39,6 +38,8 @@ const { getUserDirectories, getAllUserHandles, migrateUserData, + getCsrfSecret, + getCookieSecret, } = require('./src/users'); const basicAuthMiddleware = require('./src/middleware/basicAuth'); const whitelistMiddleware = require('./src/middleware/whitelist'); @@ -132,14 +133,14 @@ app.use(CORS); if (listen && basicAuthMode) app.use(basicAuthMiddleware); app.use(whitelistMiddleware(listen)); +app.use(userDataMiddleware()); // CSRF Protection // if (!cliArguments.disableCsrf) { - const CSRF_SECRET = crypto.randomBytes(8).toString('hex'); - const COOKIES_SECRET = crypto.randomBytes(8).toString('hex'); + const COOKIES_SECRET = getCookieSecret(); const { generateToken, doubleCsrfProtection } = doubleCsrf({ - getSecret: () => CSRF_SECRET, + getSecret: getCsrfSecret, cookieName: 'X-CSRF-Token', cookieOptions: { httpOnly: true, @@ -218,7 +219,6 @@ if (enableCorsProxy) { } app.use(express.static(process.cwd() + '/public', {})); -app.use(userDataMiddleware()); app.use('/', require('./src/users').router); app.use(multer({ dest: UPLOADS_PATH, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); diff --git a/src/constants.js b/src/constants.js index 01ca9af96..550ff90e7 100644 --- a/src/constants.js +++ b/src/constants.js @@ -40,12 +40,18 @@ const USER_DIRECTORY_TEMPLATE = Object.freeze({ vectors: 'vectors', }); +/** + * @type {import('./users').User} + * @readonly + */ const DEFAULT_USER = Object.freeze({ uuid: '00000000-0000-0000-0000-000000000000', handle: 'user0', name: 'User', created: 0, password: '', + admin: true, + enabled: true, }); const UNSAFE_EXTENSIONS = [ diff --git a/src/users.js b/src/users.js index 6c9a62e53..6ff6084ea 100644 --- a/src/users.js +++ b/src/users.js @@ -1,11 +1,20 @@ const path = require('path'); const fs = require('fs'); +const crypto = require('crypto'); +const storage = require('node-persist'); const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES } = require('./constants'); -const { getConfigValue, color, delay } = require('./util'); +const { getConfigValue, color, delay, setConfigValue } = require('./util'); const express = require('express'); +const { readSecret, writeSecret } = require('./endpoints/secrets'); const DATA_ROOT = getConfigValue('dataRoot', './data'); +const STORAGE_KEYS = { + users: 'users', + csrfSecret: 'csrfSecret', + cookieSecret: 'cookieSecret', +}; + /** * @typedef {Object} User * @property {string} uuid - The user's id @@ -13,6 +22,8 @@ const DATA_ROOT = getConfigValue('dataRoot', './data'); * @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 {boolean} enabled - Whether the user is enabled + * @property {boolean} admin - Whether the user is an admin (can manage other users) */ /** @@ -246,7 +257,51 @@ async function migrateUserData() { * @returns {Promise} */ async function initUserStorage() { - return Promise.resolve(); + await storage.init({ + dir: path.join(DATA_ROOT, '_storage'), + }); + + const users = await storage.getItem('users'); + + if (!users) { + await storage.setItem('users', [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; +} + +/** + * 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) { + throw new Error('Request object is required to get the 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; } /** @@ -348,5 +403,7 @@ module.exports = { getUserDirectories, userDataMiddleware, migrateUserData, + getCsrfSecret, + getCookieSecret, router, }; diff --git a/src/util.js b/src/util.js index b823419a5..e1410eee8 100644 --- a/src/util.js +++ b/src/util.js @@ -15,38 +15,19 @@ const { PUBLIC_DIRECTORIES } = require('./constants'); * @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 @@ -600,6 +592,7 @@ class Cache { module.exports = { getConfig, getConfigValue, + setConfigValue, getVersion, getBasicAuthHeader, extractFileFromZipBuffer, From c6ffe4502ab940e3d6c3c51ef7918fb638bf7022 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sun, 7 Apr 2024 17:44:40 +0300 Subject: [PATCH 08/46] Add user management endpoints --- default/config.yaml | 6 +- package-lock.json | 19 ++- package.json | 2 + server.js | 24 +--- src/constants.js | 4 + src/endpoints/content-manager.js | 137 +++++++++++++------ src/users.js | 227 ++++++++++++++++++++++++++++++- 7 files changed, 346 insertions(+), 73 deletions(-) diff --git a/default/config.yaml b/default/config.yaml index 2dde00f76..3e914009a 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -1,10 +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 @@ -18,6 +20,8 @@ basicAuthUser: password: "password" # Enables CORS proxy middleware enableCorsProxy: false +# Enable multi-user mode +enableUserAccounts: true # Used to sign session cookies. Will be auto-generated if not set cookieSecret: '' # Disable security checks - NOT RECOMMENDED diff --git a/package-lock.json b/package-lock.json index 11c382d41..d9aaf7959 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,8 @@ "sanitize-filename": "^1.6.3", "sillytavern-transformers": "^2.14.6", "simple-git": "^3.19.1", + "slugify": "^1.6.6", + "uuid": "^9.0.1", "vectra": "^0.2.2", "wavefile": "^11.0.0", "write-file-atomic": "^5.0.1", @@ -3510,6 +3512,14 @@ "version": "1.0.1", "license": "MIT" }, + "node_modules/slugify": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", + "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/statuses": { "version": "2.0.1", "license": "MIT", @@ -3703,8 +3713,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" } diff --git a/package.json b/package.json index 610d2df7f..fcc4fb039 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "sanitize-filename": "^1.6.3", "sillytavern-transformers": "^2.14.6", "simple-git": "^3.19.1", + "slugify": "^1.6.6", + "uuid": "^9.0.1", "vectra": "^0.2.2", "wavefile": "^11.0.0", "write-file-atomic": "^5.0.1", diff --git a/server.js b/server.js index add84e1b6..9e3497d03 100644 --- a/server.js +++ b/server.js @@ -35,8 +35,6 @@ util.inspect.defaultOptions.depth = 4; const { initUserStorage, userDataMiddleware, - getUserDirectories, - getAllUserHandles, migrateUserData, getCsrfSecret, getCookieSecret, @@ -120,7 +118,7 @@ const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN); const enableCorsProxy = cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', DEFAULT_CORS_PROXY); const basicAuthMode = getConfigValue('basicAuthMode', false); -const { UPLOADS_PATH, PUBLIC_DIRECTORIES } = require('./src/constants'); +const { UPLOADS_PATH } = require('./src/constants'); // CORS Settings // const CORS = cors({ @@ -476,7 +474,7 @@ const setupTasks = async function () { // in any order for encapsulation reasons, but right now it's unknown if that would break anything. await initUserStorage(); await settingsEndpoint.init(); - ensurePublicDirectoriesExist(); + await contentManager.ensurePublicDirectoriesExist(); await migrateUserData(); contentManager.checkForNewContent(); await ensureThumbnailCache(); @@ -567,21 +565,3 @@ if (cliArguments.ssl) { setupTasks, ); } - -async function ensurePublicDirectoriesExist() { - for (const dir of Object.values(PUBLIC_DIRECTORIES)) { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - } - - const userHandles = await getAllUserHandles(); - for (const handle of userHandles) { - const userDirectories = getUserDirectories(handle); - for (const dir of Object.values(userDirectories)) { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - } - } -} diff --git a/src/constants.js b/src/constants.js index 550ff90e7..2c5bd5e7d 100644 --- a/src/constants.js +++ b/src/constants.js @@ -5,6 +5,8 @@ const PUBLIC_DIRECTORIES = { extensions: 'public/scripts/extensions', }; +const DEFAULT_AVATAR = '/img/ai4.png'; + /** * @type {import('./users').UserDirectoryList} * @readonly @@ -52,6 +54,7 @@ const DEFAULT_USER = Object.freeze({ password: '', admin: true, enabled: true, + salt: '', }); const UNSAFE_EXTENSIONS = [ @@ -296,6 +299,7 @@ const OPENROUTER_KEYS = [ module.exports = { DEFAULT_USER, + DEFAULT_AVATAR, PUBLIC_DIRECTORIES, USER_DIRECTORY_TEMPLATE, UNSAFE_EXTENSIONS, diff --git a/src/endpoints/content-manager.js b/src/endpoints/content-manager.js index 202411043..42aa45576 100644 --- a/src/endpoints/content-manager.js +++ b/src/endpoints/content-manager.js @@ -9,6 +9,35 @@ const contentDirectory = path.join(process.cwd(), 'default/content'); const contentIndexPath = path.join(contentDirectory, 'index.json'); const { getAllUserHandles, getUserDirectories } = require('../users'); const characterCardParser = require('../character-card-parser.js'); +const { PUBLIC_DIRECTORIES } = require('../constants'); + +/** + * @typedef {Object} ContentItem + * @property {string} filename + * @property {string} type + */ + +/** + * Ensures that the content directories exist. + * @returns {Promise} + */ +async function ensurePublicDirectoriesExist() { + for (const dir of Object.values(PUBLIC_DIRECTORIES)) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + } + + const userHandles = await getAllUserHandles(); + for (const handle of userHandles) { + const userDirectories = getUserDirectories(handle); + for (const dir of Object.values(userDirectories)) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + } + } +} /** * Gets the default presets from the content directory. @@ -58,7 +87,63 @@ function getDefaultPresetFile(filename) { } } -async function checkForNewContent() { +/** + * Seeds content for a user. + * @param {ContentItem[]} contentIndex Content index + * @param {string} userHandle User handle + */ +async function seedContentForUser(contentIndex, userHandle) { + const directories = getUserDirectories(userHandle); + + 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)) { + 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(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}`); + } + + fs.writeFileSync(contentLogPath, contentLog.join('\n')); +} + +/** + * Checks for new content and seeds it for all users. + * @param {string} [userHandle] User to check the content for (optional) + * @returns {Promise} + */ +async function checkForNewContent(userHandle) { try { if (getConfigValue('skipContentCheck', false)) { return; @@ -68,50 +153,13 @@ async function checkForNewContent() { const contentIndex = JSON.parse(contentIndexText); const userHandles = await getAllUserHandles(); + if (userHandle && userHandles.includes(userHandle)) { + await seedContentForUser(contentIndex, userHandle); + return; + } + for (const userHandle of userHandles) { - const directories = getUserDirectories(userHandle); - - 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)) { - 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(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}`); - } - - fs.writeFileSync(contentLogPath, contentLog.join('\n')); + await seedContentForUser(contentIndex, userHandle); } } catch (err) { console.log('Content check failed', err); @@ -461,6 +509,7 @@ router.post('/importUUID', jsonParser, async (request, response) => { }); module.exports = { + ensurePublicDirectoriesExist, checkForNewContent, getDefaultPresets, getDefaultPresetFile, diff --git a/src/users.js b/src/users.js index 6ff6084ea..47b366575 100644 --- a/src/users.js +++ b/src/users.js @@ -2,12 +2,18 @@ const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const storage = require('node-persist'); -const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES } = require('./constants'); -const { getConfigValue, color, delay, setConfigValue } = require('./util'); +const uuid = require('uuid'); +const mime = require('mime-types'); +const slugify = require('slugify').default; +const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, DEFAULT_AVATAR } = require('./constants'); +const { getConfigValue, color, delay, setConfigValue, Cache } = require('./util'); const express = require('express'); const { readSecret, writeSecret } = require('./endpoints/secrets'); +const { jsonParser } = require('./express-common'); +const contentManager = require('./endpoints/content-manager'); const DATA_ROOT = getConfigValue('dataRoot', './data'); +const MFA_CACHE = new Cache(5 * 60 * 1000); const STORAGE_KEYS = { users: 'users', @@ -22,10 +28,20 @@ const STORAGE_KEYS = { * @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 + */ + /** * @typedef {Object} UserDirectoryList * @property {string} root - The root directory for the user @@ -284,6 +300,10 @@ function getCookieSecret() { return secret; } +function getPasswordSalt() { + return crypto.randomBytes(16).toString('base64'); +} + /** * Get the CSRF secret from the storage. * @param {import('express').Request} [request] HTTP request object @@ -314,11 +334,12 @@ async function getCurrentUserHandle(_req) { } /** - * Gets a list of all user handles. Currently hard coded to return the default user's handle. + * Gets a list of all user handles. * @returns {Promise} - The list of user handles */ async function getAllUserHandles() { - return [DEFAULT_USER.handle]; + const users = await storage.getItem(STORAGE_KEYS.users); + return users.map(user => user.handle); } /** @@ -383,6 +404,26 @@ function createRouteHandler(directoryFn) { }; } +/** + * 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(401); + } + + if (request.user.profile.admin) { + return next(); + } + + console.warn('Unauthorized access to admin endpoint:', request.originalUrl); + return response.sendStatus(403); +} + /** * Express router for serving files from the user's directories. */ @@ -395,6 +436,184 @@ router.use('/user/images/*', createRouteHandler(req => req.user.directories.user router.use('/user/files/*', createRouteHandler(req => req.user.directories.files)); router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.user.directories.extensions)); +const endpoints = express.Router(); + +/** + * 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'); +} + +endpoints.get('/list', async (_request, response) => { + /** @type {User[]} */ + const users = await storage.getItem(STORAGE_KEYS.users); + const viewModels = users.filter(x => x.enabled).map(user => ({ + handle: user.handle, + name: user.name, + avatar: DEFAULT_AVATAR, + admin: user.admin, + password: !!user.password, + })); + + // Load avatars for each user + for (const user of viewModels) { + try { + const directory = getUserDirectories(user.handle); + const pathToSettings = path.join(directory.root, 'settings.json'); + const settings = fs.existsSync(pathToSettings) ? JSON.parse(fs.readFileSync(pathToSettings, 'utf8')) : {}; + const avatarFile = settings?.power_user?.default_persona || settings?.user_avatar; + if (!avatarFile) { + continue; + } + const avatarPath = path.join(directory.avatars, avatarFile); + if (!fs.existsSync(avatarPath)) { + continue; + } + const mimeType = mime.lookup(avatarPath); + const base64Content = fs.readFileSync(avatarPath, 'base64'); + user.avatar = `data:${mimeType};base64,${base64Content}`; + } catch { + // Ignore errors + } + } + + return response.json(viewModels); +}); + +endpoints.post('/recover-step1', jsonParser, async (request, response) => { + if (!request.body.handle) { + console.log('Recover step 1 failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + /** @type {User[]} */ + const users = await storage.getItem(STORAGE_KEYS.users); + const user = users.find(user => user.handle === 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 = Math.floor(Math.random() * 1000000).toString().padStart(6, '0'); + console.log(color.blue(`${user.name} YOUR PASSWORD RECOVERY CODE IS: `) + color.magenta(mfaCode)); + MFA_CACHE.set(user.handle, mfaCode); + return response.sendStatus(204); +}); + +endpoints.post('/recover-step2', jsonParser, async (request, response) => { + if (!request.body.handle || !request.body.code || !request.body.password) { + console.log('Recover step 2 failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + /** @type {User[]} */ + const users = await storage.getItem(STORAGE_KEYS.users); + const user = users.find(user => user.handle === request.body.handle); + + 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) { + console.log('Recover step 2 failed: Incorrect code'); + return response.status(401).json({ error: 'Incorrect code' }); + } + + const salt = getPasswordSalt(); + user.password = getPasswordHash(request.body.password, salt); + user.salt = salt; + await storage.setItem(STORAGE_KEYS.users, users); + return response.sendStatus(204); +}); + +endpoints.post('/login', jsonParser, async (request, response) => { + if (!request.body.handle || !request.body.password) { + console.log('Login failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + /** @type {User[]} */ + const users = await storage.getItem(STORAGE_KEYS.users); + const user = users.find(user => user.handle === request.body.handle); + + if (!user) { + console.log('Login failed: User not found'); + return response.status(401).json({ error: 'User not found' }); + } + + if (!user.enabled) { + console.log('Login failed: User is disabled'); + return response.status(403).json({ error: 'User is disabled' }); + } + + if (user.password !== getPasswordHash(request.body.password, user.salt)) { + console.log('Login failed: Incorrect password'); + return response.status(401).json({ error: 'Incorrect password' }); + } + + console.log('Login successful:', user.handle); + return response.json({ handle: user.handle }); +}); + +endpoints.post('/create', requireAdminMiddleware, jsonParser, async (request, response) => { + if (!request.body.handle || !request.body.name) { + console.log('Create user failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + /** @type {User[]} */ + const users = await storage.getItem(STORAGE_KEYS.users); + const handle = slugify(request.body.handle, { lower: true, trim: true }); + + if (users.some(user => user.handle === request.body.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 = { + uuid: uuid.v4(), + handle: handle, + name: request.body.name || 'Anonymous', + created: Date.now(), + password: password, + salt: salt, + admin: !!request.body.admin, + enabled: !!request.body.enabled, + }; + + users.push(newUser); + await storage.setItem(STORAGE_KEYS.users, users); + + // Create user directories + console.log('Creating data directories for', newUser.handle); + await contentManager.ensurePublicDirectoriesExist(); + await contentManager.checkForNewContent(newUser.handle); + return response.json({ handle: newUser.handle }); +}); + +router.use('/api/users', endpoints); + module.exports = { initUserStorage, getCurrentUserDirectories, From 0f105e030024397b417f18550cac2fdfd209c63d Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sun, 7 Apr 2024 18:11:23 +0300 Subject: [PATCH 09/46] Fix circular deps, add Helmet https://helmetjs.github.io/ --- package-lock.json | 9 +++++++ package.json | 1 + server.js | 9 +++++-- src/endpoints/content-manager.js | 45 +++++--------------------------- src/users.js | 30 ++++++++++++++++++--- 5 files changed, 50 insertions(+), 44 deletions(-) diff --git a/package-lock.json b/package-lock.json index d9aaf7959..b4e5d82c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "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", @@ -2176,6 +2177,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": [ diff --git a/package.json b/package.json index fcc4fb039..d358ef5d4 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "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", diff --git a/server.js b/server.js index 9e3497d03..c20bd30b0 100644 --- a/server.js +++ b/server.js @@ -20,6 +20,7 @@ const compression = require('compression'); const cookieParser = require('cookie-parser'); const multer = require('multer'); const responseTime = require('response-time'); +const helmet = require('helmet').default; // net related library imports const net = require('net'); @@ -34,6 +35,7 @@ util.inspect.defaultOptions.depth = 4; // local library imports const { initUserStorage, + ensurePublicDirectoriesExist, userDataMiddleware, migrateUserData, getCsrfSecret, @@ -109,6 +111,9 @@ const serverDirectory = __dirname; process.chdir(serverDirectory); const app = express(); +app.use(helmet({ + contentSecurityPolicy: false, +})); app.use(compression()); app.use(responseTime()); @@ -474,9 +479,9 @@ const setupTasks = async function () { // in any order for encapsulation reasons, but right now it's unknown if that would break anything. await initUserStorage(); await settingsEndpoint.init(); - await contentManager.ensurePublicDirectoriesExist(); + const directories = await ensurePublicDirectoriesExist(); await migrateUserData(); - contentManager.checkForNewContent(); + await contentManager.checkForNewContent(directories); await ensureThumbnailCache(); cleanUploads(); diff --git a/src/endpoints/content-manager.js b/src/endpoints/content-manager.js index 42aa45576..905d8f1bc 100644 --- a/src/endpoints/content-manager.js +++ b/src/endpoints/content-manager.js @@ -7,9 +7,7 @@ const { getConfigValue } = require('../util'); const { jsonParser } = require('../express-common'); const contentDirectory = path.join(process.cwd(), 'default/content'); const contentIndexPath = path.join(contentDirectory, 'index.json'); -const { getAllUserHandles, getUserDirectories } = require('../users'); const characterCardParser = require('../character-card-parser.js'); -const { PUBLIC_DIRECTORIES } = require('../constants'); /** * @typedef {Object} ContentItem @@ -17,28 +15,6 @@ const { PUBLIC_DIRECTORIES } = require('../constants'); * @property {string} type */ -/** - * Ensures that the content directories exist. - * @returns {Promise} - */ -async function ensurePublicDirectoriesExist() { - for (const dir of Object.values(PUBLIC_DIRECTORIES)) { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - } - - const userHandles = await getAllUserHandles(); - for (const handle of userHandles) { - const userDirectories = getUserDirectories(handle); - for (const dir of Object.values(userDirectories)) { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - } - } -} - /** * Gets the default presets from the content directory. * @param {import('../users').UserDirectoryList} directories User directories @@ -90,11 +66,9 @@ function getDefaultPresetFile(filename) { /** * Seeds content for a user. * @param {ContentItem[]} contentIndex Content index - * @param {string} userHandle User handle + * @param {import('../users').UserDirectoryList} directories User directories */ -async function seedContentForUser(contentIndex, userHandle) { - const directories = getUserDirectories(userHandle); - +async function seedContentForUser(contentIndex, directories) { if (!fs.existsSync(directories.root)) { fs.mkdirSync(directories.root, { recursive: true }); } @@ -140,10 +114,10 @@ async function seedContentForUser(contentIndex, userHandle) { /** * Checks for new content and seeds it for all users. - * @param {string} [userHandle] User to check the content for (optional) + * @param {import('../users').UserDirectoryList[]} directoriesList List of user directories * @returns {Promise} */ -async function checkForNewContent(userHandle) { +async function checkForNewContent(directoriesList) { try { if (getConfigValue('skipContentCheck', false)) { return; @@ -151,15 +125,9 @@ async function checkForNewContent(userHandle) { const contentIndexText = fs.readFileSync(contentIndexPath, 'utf8'); const contentIndex = JSON.parse(contentIndexText); - const userHandles = await getAllUserHandles(); - if (userHandle && userHandles.includes(userHandle)) { - await seedContentForUser(contentIndex, userHandle); - return; - } - - for (const userHandle of userHandles) { - await seedContentForUser(contentIndex, userHandle); + for (const directories of directoriesList) { + await seedContentForUser(contentIndex, directories); } } catch (err) { console.log('Content check failed', err); @@ -509,7 +477,6 @@ router.post('/importUUID', jsonParser, async (request, response) => { }); module.exports = { - ensurePublicDirectoriesExist, checkForNewContent, getDefaultPresets, getDefaultPresetFile, diff --git a/src/users.js b/src/users.js index 47b366575..41f69c0f4 100644 --- a/src/users.js +++ b/src/users.js @@ -10,7 +10,7 @@ const { getConfigValue, color, delay, setConfigValue, Cache } = require('./util' const express = require('express'); const { readSecret, writeSecret } = require('./endpoints/secrets'); const { jsonParser } = require('./express-common'); -const contentManager = require('./endpoints/content-manager'); +const { checkForNewContent } = require('./endpoints/content-manager'); const DATA_ROOT = getConfigValue('dataRoot', './data'); const MFA_CACHE = new Cache(5 * 60 * 1000); @@ -73,6 +73,29 @@ const STORAGE_KEYS = { * @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. */ @@ -607,8 +630,8 @@ endpoints.post('/create', requireAdminMiddleware, jsonParser, async (request, re // Create user directories console.log('Creating data directories for', newUser.handle); - await contentManager.ensurePublicDirectoriesExist(); - await contentManager.checkForNewContent(newUser.handle); + const directories = await ensurePublicDirectoriesExist(); + await checkForNewContent(directories); return response.json({ handle: newUser.handle }); }); @@ -616,6 +639,7 @@ router.use('/api/users', endpoints); module.exports = { initUserStorage, + ensurePublicDirectoriesExist, getCurrentUserDirectories, getCurrentUserHandle, getAllUserHandles, From c0264f1cd62d6bfae426dab101a6420f313c21bf Mon Sep 17 00:00:00 2001 From: RossAscends <124905043+RossAscends@users.noreply.github.com> Date: Mon, 8 Apr 2024 00:18:21 +0900 Subject: [PATCH 10/46] mockup user select modal (disabled) --- public/scripts/loader.js | 75 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/public/scripts/loader.js b/public/scripts/loader.js index 534bd1609..432484096 100644 --- a/public/scripts/loader.js +++ b/public/scripts/loader.js @@ -1,28 +1,95 @@ const ELEMENT_ID = 'loader'; +import { delay } from "./utils.js"; + export function showLoader() { const container = $('
').attr('id', ELEMENT_ID); const loader = $('
').attr('id', 'load-spinner').addClass('fa-solid fa-gear fa-spin fa-3x'); container.append(loader); $('body').append(container); - } -export function hideLoader() { + +//placeholder user data +const user1 = +{ + handle: 'user0', + avatarSrc: 'https://cdn-icons-png.flaticon.com/256/147/147144.png', + name: 'Admin', + password: true +} + +const user2 = +{ + handle: 'user1', + avatarSrc: 'https://cdn.iconscout.com/icon/free/png-256/free-avatar-370-456322.png', + name: 'Guest', + password: true +} + +const userSelectMessage = ` +
+

Select User

+ This is merely a test.
Click a user, and then click Login to proceed.
+
+ + + +
+ +
+ ` + + +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') + //$('#loader-spinner') $(`#${ELEMENT_ID}`) + //only fade out the spinner and replace with login screen .animate({ opacity: 0 }, 300, function () { - //console.log('REMOVING LOADER') + //dont remove the loader container just yet $(`#${ELEMENT_ID}`).remove(); }); }); + //console.log('BLURRING SPINNER') $('#load-spinner') .css({ 'filter': 'blur(15px)', 'opacity': '0', }); + + //add login screen + //$('#loader').append(userSelectMessage) + + $(".userSelect").on("click", function () { + let selectedUserName = $(this).data('foruser') + $('.userSelect').removeClass('avatar-container selected') + $(this).addClass('avatar-container selected') + console.log(selectedUserName) + $("#passwordHeaderText").text(`Enter password for ${selectedUserName}`) + $("#passwordEntryBlock").show() + }) + + $("#loginButton").on('click', function () { + $('#loader') + .animate({ opacity: 0 }, 300, function () { + + //insert user handle/password verification code here + + //.finally: + $('#loader').remove(); + }); + }) + + + } + + From 6be86be0a7fe2f7922241299b19065db6fc7510e Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sun, 7 Apr 2024 19:12:22 +0300 Subject: [PATCH 11/46] Save user session to cookies --- index.d.ts | 7 +++ package-lock.json | 78 ++++++++++++++++++++++++ package.json | 1 + public/login.html | 0 server.js | 6 +- src/users.js | 148 ++++++++++++++++++++++++++++++++++------------ 6 files changed, 200 insertions(+), 40 deletions(-) create mode 100644 public/login.html diff --git a/index.d.ts b/index.d.ts index 417bf315c..8e30e75e6 100644 --- a/index.d.ts +++ b/index.d.ts @@ -10,3 +10,10 @@ declare global { } } } + +declare module 'express-session' { + export interface SessionData { + handle: string; + // other properties... + } + } diff --git a/package-lock.json b/package-lock.json index b4e5d82c1..4f9bfd49c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "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", @@ -1291,10 +1292,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" @@ -2511,6 +2570,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", @@ -3643,6 +3713,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, diff --git a/package.json b/package.json index d358ef5d4..313de3b7a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "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", diff --git a/public/login.html b/public/login.html new file mode 100644 index 000000000..e69de29bb diff --git a/server.js b/server.js index c20bd30b0..bf35c9f1e 100644 --- a/server.js +++ b/server.js @@ -136,7 +136,7 @@ app.use(CORS); if (listen && basicAuthMode) app.use(basicAuthMiddleware); app.use(whitelistMiddleware(listen)); -app.use(userDataMiddleware()); +app.use(userDataMiddleware(app)); // CSRF Protection // if (!cliArguments.disableCsrf) { @@ -228,6 +228,10 @@ app.use(multer({ dest: UPLOADS_PATH, limits: { fieldSize: 10 * 1024 * 1024 } }). app.get('/', function (request, response) { response.sendFile(process.cwd() + '/public/index.html'); }); +// Host login page +app.get('/login', (_request, response) => { + return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') }); +}); app.get('/version', async function (_, response) { const data = await getVersion(); response.send(data); diff --git a/src/users.js b/src/users.js index 41f69c0f4..473833b41 100644 --- a/src/users.js +++ b/src/users.js @@ -1,19 +1,32 @@ +// 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 cookieSession = require('cookie-session'); const uuid = require('uuid'); const mime = require('mime-types'); const slugify = require('slugify').default; + +// Local imports +const { jsonParser } = require('./express-common'); const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, DEFAULT_AVATAR } = require('./constants'); const { getConfigValue, color, delay, setConfigValue, Cache } = require('./util'); -const express = require('express'); const { readSecret, writeSecret } = require('./endpoints/secrets'); -const { jsonParser } = require('./express-common'); const { checkForNewContent } = require('./endpoints/content-manager'); const DATA_ROOT = getConfigValue('dataRoot', './data'); +const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); const MFA_CACHE = new Cache(5 * 60 * 1000); +/** + * Cache for user directories. + * @type {Map} + */ +const DIRECTORIES_CACHE = new Map(); const STORAGE_KEYS = { users: 'users', @@ -298,6 +311,7 @@ async function migrateUserData() { async function initUserStorage() { await storage.init({ dir: path.join(DATA_ROOT, '_storage'), + ttl: true, }); const users = await storage.getItem('users'); @@ -323,10 +337,34 @@ function getCookieSecret() { 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 @@ -347,15 +385,6 @@ function getCsrfSecret(request) { return csrfSecret; } -/** - * Gets a user for the current request. Hard coded to return the default user. - * @param {import('express').Request} _req - The request object. Currently unused. - * @returns {Promise} - The user's handle - */ -async function getCurrentUserHandle(_req) { - return DEFAULT_USER.handle; -} - /** * Gets a list of all user handles. * @returns {Promise} - The list of user handles @@ -365,15 +394,6 @@ async function getAllUserHandles() { return users.map(user => user.handle); } -/** - * Gets the directories listing for the provided user. - * @param {import('express').Request} req - The request object - * @returns {Promise} - The user's directories like {worlds: 'data/user0/worlds/', ... - */ -async function getCurrentUserDirectories(req) { - const handle = await getCurrentUserHandle(req); - return getUserDirectories(handle); -} /** * Gets the directories listing for the provided user. @@ -381,18 +401,35 @@ async function getCurrentUserDirectories(req) { * @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; } /** * Middleware to add user data to the request object. + * @param {import('express').Express} app Express app * @returns {import('express').RequestHandler} */ -function userDataMiddleware() { +function userDataMiddleware(app) { + app.use(cookieSession({ + name: getCookieSessionName(), + sameSite: 'strict', + httpOnly: true, + maxAge: 24 * 60 * 60 * 1000, // 24 hours + secret: getCookieSecret(), + })); + /** * Middleware to add user data to the request object. * @param {import('express').Request} req Request object @@ -400,12 +437,56 @@ function userDataMiddleware() { * @param {import('express').NextFunction} next Next function */ return async (req, res, next) => { - const directories = await getCurrentUserDirectories(req); + // Skip for login page + if (req.path === '/login') { + return next(); + } + + // If user accounts are disabled, use the default user + if (!ENABLE_ACCOUNTS) { + const handle = DEFAULT_USER.handle; + const directories = getUserDirectories(handle); + req.user = { + profile: DEFAULT_USER, + directories: directories, + }; + return next(); + } + // If user accounts are enabled, get the user from the session + /** + * @type {User[]} + */ + const users = await storage.getItem(STORAGE_KEYS.users); + let handle = req.session?.handle; + + // If we have the only user and it's not password protected, use it + if (!handle && users.length === 1 && !users[0].password) { + handle = users[0].handle; + req.session.handle = handle; + } + + if (!handle) { + return res.redirect('/login'); + } + + const user = users.find(user => user.handle === handle); + + if (!user) { + console.error('User not found:', handle); + return res.redirect('/login'); + } + + if (!user.enabled) { + console.error('User is disabled:', handle); + return res.redirect('/login'); + } + + const directories = getUserDirectories(handle); req.user = { - profile: DEFAULT_USER, + profile: user, directories: directories, }; - next(); + return next(); }; } @@ -461,16 +542,6 @@ router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.us const endpoints = express.Router(); -/** - * 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'); -} - endpoints.get('/list', async (_request, response) => { /** @type {User[]} */ const users = await storage.getItem(STORAGE_KEYS.users); @@ -528,7 +599,7 @@ endpoints.post('/recover-step1', jsonParser, async (request, response) => { } const mfaCode = Math.floor(Math.random() * 1000000).toString().padStart(6, '0'); - console.log(color.blue(`${user.name} YOUR PASSWORD RECOVERY CODE IS: `) + color.magenta(mfaCode)); + console.log(color.blue(`${user.name} YOUR PASSWORD RECOVERY CODE IS: `) + color.magenta(mfaCode)); MFA_CACHE.set(user.handle, mfaCode); return response.sendStatus(204); }); @@ -587,12 +658,13 @@ endpoints.post('/login', jsonParser, async (request, response) => { return response.status(403).json({ error: 'User is disabled' }); } - if (user.password !== getPasswordHash(request.body.password, user.salt)) { + if (user.password && user.password !== getPasswordHash(request.body.password, user.salt)) { console.log('Login failed: Incorrect password'); return response.status(401).json({ error: 'Incorrect password' }); } - console.log('Login successful:', user.handle); + request.session.handle = user.handle; + console.log('Login successful:', user.handle, request.session); return response.json({ handle: user.handle }); }); @@ -640,8 +712,6 @@ router.use('/api/users', endpoints); module.exports = { initUserStorage, ensurePublicDirectoriesExist, - getCurrentUserDirectories, - getCurrentUserHandle, getAllUserHandles, getUserDirectories, userDataMiddleware, From f0aa0c5540ed64efab6ea8977d5a275fc2f6f06f Mon Sep 17 00:00:00 2001 From: RossAscends <124905043+RossAscends@users.noreply.github.com> Date: Mon, 8 Apr 2024 02:22:44 +0900 Subject: [PATCH 12/46] imp user creation, split out from loader.js (still disabled) --- public/scripts/loader.js | 71 ++------------- public/scripts/userManagement.js | 152 +++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 62 deletions(-) create mode 100644 public/scripts/userManagement.js diff --git a/public/scripts/loader.js b/public/scripts/loader.js index 432484096..179a0437b 100644 --- a/public/scripts/loader.js +++ b/public/scripts/loader.js @@ -1,6 +1,6 @@ const ELEMENT_ID = 'loader'; -import { delay } from "./utils.js"; +import { populateUserList } from './userManagement.js' export function showLoader() { const container = $('
').attr('id', ELEMENT_ID); @@ -9,55 +9,23 @@ export function showLoader() { $('body').append(container); } - -//placeholder user data -const user1 = -{ - handle: 'user0', - avatarSrc: 'https://cdn-icons-png.flaticon.com/256/147/147144.png', - name: 'Admin', - password: true -} - -const user2 = -{ - handle: 'user1', - avatarSrc: 'https://cdn.iconscout.com/icon/free/png-256/free-avatar-370-456322.png', - name: 'Guest', - password: true -} - -const userSelectMessage = ` -
-

Select User

- This is merely a test.
Click a user, and then click Login to proceed.
-
- - - -
- -
- ` - - 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 () { + //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 () { - //dont remove the loader container just yet + //when enabling user select, dont remove the loader container just yet + //comment this out $(`#${ELEMENT_ID}`).remove(); }); }); - //console.log('BLURRING SPINNER') $('#load-spinner') .css({ @@ -65,29 +33,8 @@ export async function hideLoader() { 'opacity': '0', }); - //add login screen - //$('#loader').append(userSelectMessage) - - $(".userSelect").on("click", function () { - let selectedUserName = $(this).data('foruser') - $('.userSelect').removeClass('avatar-container selected') - $(this).addClass('avatar-container selected') - console.log(selectedUserName) - $("#passwordHeaderText").text(`Enter password for ${selectedUserName}`) - $("#passwordEntryBlock").show() - }) - - $("#loginButton").on('click', function () { - $('#loader') - .animate({ opacity: 0 }, 300, function () { - - //insert user handle/password verification code here - - //.finally: - $('#loader').remove(); - }); - }) - + //uncomment to make user selection live + //await populateUserList() } diff --git a/public/scripts/userManagement.js b/public/scripts/userManagement.js new file mode 100644 index 000000000..d54869628 --- /dev/null +++ b/public/scripts/userManagement.js @@ -0,0 +1,152 @@ +async function getUserList() { + const response = await fetch('/api/users/list'); + const userListObj = await response.json(); // Assuming the response is in JSON format + console.log(userListObj) + return userListObj; +} + +async function registerNewUser() { + let handle = String($("#newUserHandle").val()); + let name = String($("#newUserName").val()); + let password = String($("#newUserPassword").val()); + let passwordConfirm = String($("#newUserPasswordConfirm").val()); + + if (handle.length < 4) { + alert('Username must be at least 4 characters long'); + return; + } + + if (password.length < 8) { + alert('Password must be at least 8 characters long'); + return; + } + + if (password !== passwordConfirm) { + alert("Passwords don't match!") + return + } + + const newUser = { + handle: handle, + name: name || 'Anonymous', + password: password, + }; + + try { + const response = await $.ajax({ + url: '/api/users/create', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify(newUser), + }); + + console.log(response); + if (response.handle) { + console.log('saw user created successfully') + alert('New user created!') + $("#userSelectBlock").empty() + populateUserList() + $("#userListBlock").show() + $("#registerNewUserBlock").hide() + $("#registerNewUserBlock input").val('') + + } + } catch (error) { + console.error('Error creating new user:', error); + alert(error.responseText) + } +} + +export async function populateUserList() { + const userList = await getUserList(); + + const registerNewUserButtonHTML = `` + + const newUserRegisterationHTML = ` +
+ Register New SillyTavern User +
Username:
+
Password:
+
Password confirm:
+ This will create a new subfolder in the /data/ directory. +
+ + +
+
+ ` + + const userSelectHTML = ` +
+

Select User

+ This is merely a test.
Click a user, and then click Login to proceed.
+
+
+ +
+ + +
+ `; + + // Add login screen + $('#loader').append(userSelectHTML); + + const parentDiv = $('#userList'); + + userList.forEach(user => { + const userDiv = $('
') + .attr('id', `userSelect-${user.handle}`) + .attr('data-foruser', user.name) + .addClass('userSelect menu_button flex-container flexFlowCol'); + + const avatarImg = $('') + .addClass('avatar') + .attr('src', user.avatar); + + userDiv.append(avatarImg); + + const userName = $('').text(user.name); + userDiv.append(userName); + + parentDiv.append(userDiv); + }); + + parentDiv.append(registerNewUserButtonHTML) + + $(".userSelect").off('click').on("click", function () { + let selectedUserName = $(this).data('foruser') + $('.userSelect').removeClass('avatar-container selected') + $(this).addClass('avatar-container selected') + console.log(selectedUserName) + $("#passwordHeaderText").text(`Enter password for ${selectedUserName}`) + $("#passwordEntryBlock").show() + }); + + $("#registerNewUserButton").off('click').on('click', function () { + $("#userListBlock").hide() + $("#registerNewUserBlock").show() + }) + + $("#newUserRegisterFinalizeButton").off('click').on('click', registerNewUser) + + $("#newUserRegisterCancelButton").off('click').on('click', function () { + $("#userListBlock").show() + $("#registerNewUserBlock").hide() + }) + + $("#loginButton").off('click').on('click', function () { + $('#loader') + .animate({ opacity: 0 }, 300, function () { + // Insert user handle/password verification code here + //.finally: + $('#loader').remove(); + }); + }); +} From 0230177d2739750c2cee5b030ee7e54d988caf0f Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sun, 7 Apr 2024 20:36:07 +0300 Subject: [PATCH 13/46] Optimize server user storage use --- public/style.css | 2 +- src/users.js | 235 ++++++++++++++++++++++++++++++++++++----------- 2 files changed, 182 insertions(+), 55 deletions(-) diff --git a/public/style.css b/public/style.css index dedc2460b..2a49af5db 100644 --- a/public/style.css +++ b/public/style.css @@ -3385,7 +3385,7 @@ a { } #ui_language_select { - width: 10em; + width: 8em; } #extensions_settings .inline-drawer-toggle.inline-drawer-header:hover, diff --git a/src/users.js b/src/users.js index 473833b41..373c0ebbe 100644 --- a/src/users.js +++ b/src/users.js @@ -19,6 +19,7 @@ const { getConfigValue, color, delay, setConfigValue, Cache } = require('./util' const { readSecret, writeSecret } = require('./endpoints/secrets'); const { checkForNewContent } = require('./endpoints/content-manager'); +const KEY_PREFIX = 'user:'; const DATA_ROOT = getConfigValue('dataRoot', './data'); const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); const MFA_CACHE = new Cache(5 * 60 * 1000); @@ -29,7 +30,6 @@ const MFA_CACHE = new Cache(5 * 60 * 1000); const DIRECTORIES_CACHE = new Map(); const STORAGE_KEYS = { - users: 'users', csrfSecret: 'csrfSecret', cookieSecret: 'cookieSecret', }; @@ -304,6 +304,15 @@ async function migrateUserData() { 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. * @returns {Promise} @@ -314,10 +323,11 @@ async function initUserStorage() { ttl: true, }); - const users = await storage.getItem('users'); + const keys = await getAllUserHandles(); - if (!users) { - await storage.setItem('users', [DEFAULT_USER]); + // If there are no users, create the default user + if (keys.length === 0) { + await storage.setItem(toKey(DEFAULT_USER.handle), DEFAULT_USER); } } @@ -390,11 +400,11 @@ function getCsrfSecret(request) { * @returns {Promise} - The list of user handles */ async function getAllUserHandles() { - const users = await storage.getItem(STORAGE_KEYS.users); - return users.map(user => user.handle); + 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 @@ -416,6 +426,34 @@ function getUserDirectories(handle) { 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.json'); + 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; + } +} + /** * Middleware to add user data to the request object. * @param {import('express').Express} app Express app @@ -452,24 +490,34 @@ function userDataMiddleware(app) { }; return next(); } + + if (!req.session) { + console.error('Session not available'); + return res.sendStatus(500); + } + // If user accounts are enabled, get the user from the session - /** - * @type {User[]} - */ - const users = await storage.getItem(STORAGE_KEYS.users); let handle = req.session?.handle; // If we have the only user and it's not password protected, use it - if (!handle && users.length === 1 && !users[0].password) { - handle = users[0].handle; - req.session.handle = handle; + if (!handle) { + const handles = await getAllUserHandles(); + if (handles.length === 1) { + /** @type {User} */ + const user = await storage.getItem(toKey(handles[0])); + if (!user.password) { + handle = user.handle; + req.session.handle = handle; + } + } } if (!handle) { return res.redirect('/login'); } - const user = users.find(user => user.handle === handle); + /** @type {User} */ + const user = await storage.getItem(toKey(handle)); if (!user) { console.error('User not found:', handle); @@ -544,38 +592,33 @@ const endpoints = express.Router(); endpoints.get('/list', async (_request, response) => { /** @type {User[]} */ - const users = await storage.getItem(STORAGE_KEYS.users); + const users = await storage.values(); const viewModels = users.filter(x => x.enabled).map(user => ({ handle: user.handle, name: user.name, - avatar: DEFAULT_AVATAR, + avatar: getUserAvatar(user.handle), admin: user.admin, password: !!user.password, })); - // Load avatars for each user - for (const user of viewModels) { - try { - const directory = getUserDirectories(user.handle); - const pathToSettings = path.join(directory.root, 'settings.json'); - const settings = fs.existsSync(pathToSettings) ? JSON.parse(fs.readFileSync(pathToSettings, 'utf8')) : {}; - const avatarFile = settings?.power_user?.default_persona || settings?.user_avatar; - if (!avatarFile) { - continue; - } - const avatarPath = path.join(directory.avatars, avatarFile); - if (!fs.existsSync(avatarPath)) { - continue; - } - const mimeType = mime.lookup(avatarPath); - const base64Content = fs.readFileSync(avatarPath, 'base64'); - user.avatar = `data:${mimeType};base64,${base64Content}`; - } catch { - // Ignore errors - } + return response.json(viewModels); +}); + +endpoints.get('/me', async (request, response) => { + if (!request.user) { + return response.sendStatus(401); } - return response.json(viewModels); + const user = request.user.profile; + const viewModel = { + handle: user.handle, + name: user.name, + avatar: getUserAvatar(user.handle), + admin: user.admin, + password: !!user.password, + }; + + return response.json(viewModel); }); endpoints.post('/recover-step1', jsonParser, async (request, response) => { @@ -584,9 +627,8 @@ endpoints.post('/recover-step1', jsonParser, async (request, response) => { return response.status(400).json({ error: 'Missing required fields' }); } - /** @type {User[]} */ - const users = await storage.getItem(STORAGE_KEYS.users); - const user = users.find(user => user.handle === request.body.handle); + /** @type {User} */ + const user = await storage.getItem(toKey(request.body.handle)); if (!user) { console.log('Recover step 1 failed: User not found'); @@ -610,9 +652,8 @@ endpoints.post('/recover-step2', jsonParser, async (request, response) => { return response.status(400).json({ error: 'Missing required fields' }); } - /** @type {User[]} */ - const users = await storage.getItem(STORAGE_KEYS.users); - const user = users.find(user => user.handle === request.body.handle); + /** @type {User} */ + const user = await storage.getItem(toKey(request.body.handle)); if (!user) { console.log('Recover step 2 failed: User not found'); @@ -634,7 +675,7 @@ endpoints.post('/recover-step2', jsonParser, async (request, response) => { const salt = getPasswordSalt(); user.password = getPasswordHash(request.body.password, salt); user.salt = salt; - await storage.setItem(STORAGE_KEYS.users, users); + await storage.setItem(toKey(user.handle), user); return response.sendStatus(204); }); @@ -644,9 +685,8 @@ endpoints.post('/login', jsonParser, async (request, response) => { return response.status(400).json({ error: 'Missing required fields' }); } - /** @type {User[]} */ - const users = await storage.getItem(STORAGE_KEYS.users); - const user = users.find(user => user.handle === request.body.handle); + /** @type {User} */ + const user = await storage.getItem(toKey(request.body.handle)); if (!user) { console.log('Login failed: User not found'); @@ -663,22 +703,110 @@ endpoints.post('/login', jsonParser, async (request, response) => { return response.status(401).json({ error: 'Incorrect password' }); } + if (!request.session) { + console.error('Login failed: Session not available'); + return response.status(500).json({ error: 'Session not available' }); + } + + // Regenerate session to prevent session fixation attacks + await new Promise(resolve => request.session?.regenerate(resolve)); + + request.session.handle = user.handle; console.log('Login successful:', user.handle, request.session); return response.json({ handle: user.handle }); }); +endpoints.post('/logout', async (request, response) => { + request.session?.destroy(() => { + return response.sendStatus(204); + }); +}); + +endpoints.post('/disable', requireAdminMiddleware, jsonParser, async (request, response) => { + 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 {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); +}); + +endpoints.post('/enable', requireAdminMiddleware, jsonParser, async (request, response) => { + if (!request.body.handle) { + console.log('Enable user failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + /** @type {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); +}); + +endpoints.post('/change-password', jsonParser, async (request, response) => { + if (!request.body.handle || !request.body.oldPassword || !request.body.newPassword) { + console.log('Change password failed: Missing required fields'); + return response.status(400).json({ error: 'Missing required fields' }); + } + + /** @type {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 (user.password && user.password !== getPasswordHash(request.body.oldPassword, user.salt)) { + console.log('Change password failed: Incorrect password'); + return response.status(401).json({ error: 'Incorrect password' }); + } + + const salt = getPasswordSalt(); + user.password = getPasswordHash(request.body.newPassword, salt); + user.salt = salt; + await storage.setItem(toKey(request.body.handle), user); + return response.sendStatus(204); +}); + endpoints.post('/create', requireAdminMiddleware, jsonParser, async (request, response) => { if (!request.body.handle || !request.body.name) { console.log('Create user failed: Missing required fields'); return response.status(400).json({ error: 'Missing required fields' }); } - /** @type {User[]} */ - const users = await storage.getItem(STORAGE_KEYS.users); + const handles = await getAllUserHandles(); const handle = slugify(request.body.handle, { lower: true, trim: true }); - if (users.some(user => user.handle === request.body.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' }); } @@ -694,11 +822,10 @@ endpoints.post('/create', requireAdminMiddleware, jsonParser, async (request, re password: password, salt: salt, admin: !!request.body.admin, - enabled: !!request.body.enabled, + enabled: true, }; - users.push(newUser); - await storage.setItem(STORAGE_KEYS.users, users); + await storage.setItem(toKey(handle), newUser); // Create user directories console.log('Creating data directories for', newUser.handle); From 6ad0364aceda2f18b70ef6606255f9d4776b0deb Mon Sep 17 00:00:00 2001 From: RossAscends <124905043+RossAscends@users.noreply.github.com> Date: Mon, 8 Apr 2024 03:07:53 +0900 Subject: [PATCH 14/46] add login --- public/scripts/userManagement.js | 43 ++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/public/scripts/userManagement.js b/public/scripts/userManagement.js index d54869628..eff0b02b3 100644 --- a/public/scripts/userManagement.js +++ b/public/scripts/userManagement.js @@ -30,6 +30,7 @@ async function registerNewUser() { handle: handle, name: name || 'Anonymous', password: password, + enabled: true }; try { @@ -57,6 +58,38 @@ async function registerNewUser() { } } +async function loginUser() { + const password = $("#userPassword").val(); + const handle = $('.userSelect.selected').data('foruser'); + const userInfo = { + handle: handle, + password: password, + }; + + try { + const response = await $.ajax({ + url: '/api/users/login', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify(userInfo), + }); + + console.log(response); + if (response.handle) { + console.log('successfully logged in'); + alert(`logged in as ${handle}!`); + $('#loader').animate({ opacity: 0 }, 300, function () { + // Insert user handle/password verification code here + // .finally: + $('#loader').remove(); + }); + } + } catch (error) { + console.error('Error logging in:', error); + alert(error.responseText); + } +} + export async function populateUserList() { const userList = await getUserList(); @@ -66,6 +99,7 @@ export async function populateUserList() {
Register New SillyTavern User
Username:
+
Display Name:
Password:
Password confirm:
This will create a new subfolder in the /data/ directory. @@ -141,12 +175,5 @@ export async function populateUserList() { $("#registerNewUserBlock").hide() }) - $("#loginButton").off('click').on('click', function () { - $('#loader') - .animate({ opacity: 0 }, 300, function () { - // Insert user handle/password verification code here - //.finally: - $('#loader').remove(); - }); - }); + $("#loginButton").off('click').on('click', loginUser) } From 3f3e23420dccb41e7d51b23fefb6abb21a57da92 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sun, 7 Apr 2024 23:08:19 +0300 Subject: [PATCH 15/46] Working login flow --- public/css/login.css | 35 +++ public/img/logo.png | Bin 0 -> 23237 bytes public/login.html | 71 ++++++ public/scripts/loader.js | 7 - public/scripts/login.js | 142 ++++++++++++ public/scripts/userManagement.js | 18 -- server.js | 131 +++++++---- src/users.js | 383 ++++++++++++++++--------------- 8 files changed, 531 insertions(+), 256 deletions(-) create mode 100644 public/css/login.css create mode 100644 public/img/logo.png create mode 100644 public/scripts/login.js 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/img/logo.png b/public/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..14768e82b32b78bd51e3233742667dc3b369a8b2 GIT binary patch literal 23237 zcmb@u1yoj1*DZVq5h>}Gd_cNey1P@lyIW8YL>iqBE@YwzeLMt9KCK}X|gBSvSyug0b3A!F;{sQAdsR_BqruJt2lYhfEY32_?H01+^N ztF4bEO@OP5o3}`S7~S7-MZnkaw>jx({yyU4EJh~+SCAWo;|x2c}66uf{_}#>>{y$K6ZU-QDG12c`M1Dbw(9 zbMVkG892JxxchrE|HlVxWi5Sd#puAP@v?LAvGZ{2^6-f8@QZK>vvLWGaB=*Jv7Ej^ILCy8P#;@T_dWh?abU!Zvo6!eAUL9(F-nK1+6CD@#6hJ{}={ zYddQ$I{_{s*zEsWl8BE;5*LXxMt>H@{MrRFMgDsfBKfdkw zU+l;K)|>zNd4C66FzElpO8)U0Z+AN%e@ic0DSNPY|4S?6{2xu;+tTlUK6S8Fe0Bmn zTi-{$O{VVwVe~cy{>9 ze}@$CpMM7>TQ{&zULfQUbrZ5fkWQtNtdwp*?p|AnkIqcmSsQ1vudU4x0;yTw#+gk; z=wnvX`-X6Whu=jfJhPT0??i-6e~F7s&0JSPCRs-YC9BH!t8N1-OT_NiWrNf6iZmj&f}oM~ z-~)rp-1^ZaS-K<$3oX3M#x8#x&4P~p(s~Y`X9LGkp zM6-~mZ{_h`D{-5|l4LB~mbSo#P zUPk}ecn1o#L#LYrKh&{rX^~Kwp()m+63DrmZB@&Xgq6kfy6v@ty|YH5LXS8%>1Rr5 zI#*<Wgwcpz)tQ|bYCNG_&nyk-ufPOdG5P<}} z*6Ug^%jmYucpZX;#*695!Wv{^m4pfvI>{?gOdIHPs6IXtcq!^4K%HucD2pq{Momx@ zMMDeEVwjxUn{w%ipPWa9B&U{I&$V|jb)=2l>nl^GD&5y==7Ke=YPm>N_q7Qy_(S97 zWf}1!)NE}N?}y~$5AJyU<`%|Af(8j=^`;hFSZyf@!n@W<&hYOvb{u< zAbqA?*QPWKQe;T7*7y&6oWywDq3@;jBD;-|X6fTF0#bBH*N*BQ<+zq4A|9lX7H|qt z5vIhh&tr|i2d?K4>}UFu=$rA`qJrCdzdd$MMJ8{;`MiRVi!Ys>6v`A~U4?Wq4#O_b z+&Ee0g-5BPg9#3A_Y=Kw3J#{n@%*@samk);U#oZ=R>%9h=Ij1gQWf2(`jcuF| z&hO>(zlfGyg-@ttuhXJ*%}eM9e{peLAN7d7$8+yhO&dC`sPHXrr*N^W=W^PpHJULf zwlTeLY7DI=k@GjAFo~6q3RPNJe7+M#9`B#8Q8rXA>7FK|7oX_f zV->B1LCMN24h&2pF-!2^`S|nCJRWfj@_6Kx)9i)_xk##kp-aVG+JORnSvtOzy9TPS z=OrN%oZBOt2}u$6Ha46yjxkY+N@^5r2&YPmudcQlE5*d}iBpu69(*@zyZ#6+lKyy9 z)@z(d>b2Us?rTsH7gXFr+i(fjOItYqC*ITb@lw3qG zFcLzMFM4ognT#ITQ+3)5a_QID@IR-GdNJQImi)!kR5eIzOnrqmU8oPA{ucAE%J-7I zsCa`!NvN4WSriyEHrFTbe<-f05kBwMrQCS=^r>L-i6;Zrz^#Wtk3{d?x;5OX!{#}Q zE6?_W$U?4{3#kxAv+ceip+a?Or;dVtM|kf=;k9qa}rmYv0esTR=` zlG6^4#CRpg%3BjCR%`fZ79Z`Op5we~56ICRH#Pk%SL~VC)3@G3cdSZDP8txtr3>{N zTA{)|V$~@A#Xb|0!^nn5<8(gN-O&wBU5Dp1(i&!M7d=1Czj{79g73VvHGERi9F>VS zMO_CW3Dt(*MAJ#d;kzrk+y7<%&!35b_-GSG=5#EnhNthzx`^pIycoN_KB40-Lp*u1*#PM!OUe2!;Vkv~xnPeZ zq5Da7eJOT?g9vb+(^HkBgR$TPJ?kwh602{dAz@9=5jK+tdkl_T>gj=)fUz8pQrmD- z^43#a$Zw()-s>CzTcSY(>@Vt_=|e4WfwL`|`oKwnRlRlm0UcwPm6fEb*)!ZgbO9LQ z5`V0_O`J!-$ivM^lG>rw8iUC0#5yWQ$<5Ii;6c~Nb~%GZL#`clx3G!2MhltfwbxZm@Z}F2KD4OL5dCX5=m`Hw_M2}@)BnYlY8r~?$`<0f4nZn?vTs%fZ z9~ek2hyIO`7X%sv=jgD#-H;Izch;PiS-amho0xZO5;YR@#P`!8y4)9XvL4^e@Py}= zL#V)!^+-w%^1+VycI>P77B30OD6OOMl);*{6-mFC7@sDiu%+UM0-FfN(-clR-S)`~ zIF~0sBrkckLaKsA!c>;>)njkoIMkX!@AfGZgU!O17qO#KPfS>Y-98H1KCH4u8I-Hh zRK^+&|09&RLAhqAG%zHpUD{<5lctLghNfSkkr=TSRuBSyoUd=qw(~xgLswYYtC~CB z4-A=X;cM0{=z_jgo4bg92gM2a!oa4MOJ3$5AHU)dD`yGuaUIH6n}r|+JjdQ+;xuQC zFeTkk94PK&#X|2_9=uJgOg6XmtNo*(i-V)kA0`}%Y_a#0I1kK|eoXChu5i%=cCAWb zcvQSvw{3UUKKSZua4+9&LqIzDHTaorCshfuXyC6M0+(6bv(TS8g67E|8RLR$LS!h> zQe`X>wWS`amij;4LJ60V?s@7ZGO*qefAn7X_1Nze36&%XB5LVRYBicHQ4e+MOJe$6 zl$(A$Y<>UrBNr=txLMct-DxE+MneV{sHdG4!3nxiE443Q^h=zc+%s1cplMw%Vl!kS zDjjjFHY38?zm?US3<3{kH=AIul^*ePqa?=JtT0Cvl!4&l?uV5N3dwoRG({R289Jwm zHvBD&STq$b5H{DjFqW67LEExbU=ts?X_s|yE|1zRilftfE7Yoo@ci5}dD)?@c{s0{U;3isz@>sWEfx%L=QdmV zV!DHmaMT|zF%zTaFI3SL#D-@~Z?PeVB3cII-7@s2z{Gv#ECO5~mB`TYqUC-z5(6K^ zLw+;3$Sqc!s}A^tnvAXNOx*8<@@socY%b|4T)P0vWNW^bLVB7z;(Bn;Ks^`!3|X+h z9Ib7adFP|d%r78p1dTa-rQcwDro6Q4^Ia-$|8cu>GWho8`qWzuMN87>)P_uY=%eGW zI&o!<2TS7~J`1Os++GS<+6t2Ttgg2%7lVO=qJ1Ac6cvhEb%pDFvIm3>%{m!pRCATC!)c?wD4Lm(K%oreSAz|H6ta}w zB0^Y9w>zb?P|(pSvZMW=P`t|1yWQY6*Os|mcYe=w0%>mO1jAUNZU>Ag;U z+^oOy`;!%O(fvR;RseKy+xNp=`Qzgc->a&=tCDD{m${AgJges;zlw>lOs}_MPxtu7 zRY-}1X`4WkY}kYX{oMI=vq?J;;=Z@Ny*d#+wCXWSwwVJ!=DXR9-!XRjwS-cLQ3!f} zarhmAHX^8L!K0+9E)I|n@_OPT- zVP6P>$)yIj>zj9T?Pm9I-0u_Ip+6+}9A`DBtA82*FckUkwk_tTqTu#2ChpdcgRt?2 zoH``)DyiP$bmy1GkCR8{cynxVs1`3j;{`m@--93ntmgy@mzT@weRWDZu1(c5aS7r> z*Pryb4uqaFuY9M5I>`#u&$`?SsHx`D%8vO?vU|Zw=zrXl_Ce28U)iup_B& zCC~{MO&RHFyNW~@;FyvYrJ|E|-i z#pW3r=oZ=?u8RXgPq6HGUcoZsJ4IxO@Ap+p6)D#J&|h}Kl9kO*t_VBVYh|hUt42}z z$(Ng6zO3|$ML?ZL?d@lyU%f}oc~Or>U)1H~V*?EA0wY@K2`;+AhlxB{qJC8c+AFI@ zjqgZ^#0LC}<=ISdB8vrZh0wl=s))QE3kaMf5aBJF^5g$~74+VS8M|Lu`3bNK_RxLP zkpX5QFs~2B?=aK|2+EaGX@R~v0-dB&54Q339LezMG=003h%#WUvL!Kuoq zQ5U8jW_rDqMx-1Ps#LvJMx=NU4Ov#6-YVyrejB}B@0KX5Zyz07LKcP|`11l&ns+Gn zX_52*Z-_<0;Bu z3~4o2M*Kc%Jxc%bc4G|Kf9AxkcWsZZI*`t=^Rt8|vO9BxG1=1efIRVO5QH+UsHz>*p{ycv#+9K}n^G!{m_O7{mCdD`sa=Ad}> z8L14eRMLkBYZKeznpXZfto@f4OVnddEx0*4Q>!&jVm2IbRA(AH4~|hq2)M&YALKM7 zwaa8lYD^3xf26alYTwM!rBfzbGywEymNrjS>tJ59;)y%(HkCEg1dD^1=pH0no$QP7 z%AW@z2;XvAh=4HXxhAm<-ryDSP(9D9{vsH4S7~%N2dkj*XCu`nB9sT__V-7FMHZ`X z`FZ!{6;#jLKKB8LQVZM>B&Q%@F$QKKeeGkOL?J^nc+F|nlmiiUOLoT0)@lT>H?!tg zcFu^&ZYq zmG<|Q)0$!N4!o$8+}Bl3P~%dH&@Ol6%2Fhp23blPsWCc?tLOw=52ki|ze=U24gEf{WE4z1KES>IPFx+tZ^Cz7^2^&^8U%e)QayqsE2(a2uniUySK@@D%eOxRF zGDJ#IKD|!209%IFV_1r=v6F{$lSkiw-Oxs`dxeQyjrgP*R5D$)qCmEZvhB=zTgi7y zO#cKZRcSUiP3w@x(uz@rfAmivvezuTWSDUV2NS71OLt#9S@zN0*iwcV%?B!deT3D! zx_HrwO?McxG>hDn6{E%apW#mxQ^LuRlkuxQh2#!2x7}3xu!nnvx~HFd3)N?yj^q3? z)5LVDF;M0-!GEbcB;8R<2cg9tU&c6vAfLtmpS)Z(YNXL%my!ZPLjQN z6^J(Uyf@^7KD1JvpJZ2(*=-_I_T;m~%isN*j5bmiX^!zSW^F3crLsIU*NQM4l?9lv zuGi*K#$t&fie+$VOo*S@pR>!&;}9F%(vc+ zx5!UX_`lu9{zL|1lz5$Nu_E)Q@hOS|8BGWRMgq{6jrC>Nt1E68~Nz4i$c$mofHgZ?GkpXPESky2`h6-T|mY zEG$87=LVYS9VyM3;_N+Fk(!epR(L4DQQ9tMc!*$7p<2pbV*an3{_S23vcy2 zFcJV3K`c-tsWOQHmlXbSH+A5GTzS2$;@zBn9a$zb?o3KWBiCmXy3@#{q;ZD^`J$?_ zodLzkkcY5q1CFw-Fl=rdxU@Rm$104eE=y7+i=MD|nL$HVSjU=o1u3dxR^$X>>>{UJ z2kCZmhGjdOSurI@~LsyCL+uABf2UldG!jz3=9%X0tD!mdif^+wsoc@no z=4o%-;~FxpxXXHE6gkodK3ejm`b&iY3cuP9}-U%qPjK)SsC;VDax_D=b4m^l(| zw;YiDnCZ;C&INHq79%&hR-1L~fea0oCLgolxsBDiPoQw)W?#GxOUzOs3>1{aqSi}j z{4RrU&vM<-D~li_#1yCQ4+e7ao6YR;o`EP zp^oNdt2r1@JO);#U*2y_juUeCi-6Y%j9bL#mo;D**MZ~2rx4m=p$(i9(iQy(GB3@CUKoAV(rNpSYAn4wtL+gzr-+nZPDAhX~7Z-XBjXH4R zH*>(@7a&1^d|GM)i=amCw0q{fKut!K&ZbrFd}L*;#`Lp-o2mf+1&E?|4~U4wIQ1*$ zkzz{*@Z;%94$roIT6$Qt7QmFRVieaYek3ZbiV#dX1jDIu9I3Txq?d>Lpzol<+E1%>AjS9@rN8TTcv+BQ&80N* zsH_;~-dt&*!u-40;I`D9_F*%88=Z$B4)XZh8iArWX~9PT&;26k?JY)p%P?pgKJ{fD zoZ4sN40$g=xr7EamRILk)y`wjR9$Uf3=VdPFDzbV&8`hf#VD$inazedmW~fWyS3T0=V*2eG;;f9KK}fhb0Jt zi5lLsuP67O)67fWoA4-cutvA~nY3zY@J5jlcvK0QA7xDxC2|#f7WaN}ag~`_lP@ZJnvrU`RY<0CF?|6 zFDNXRw|LD^+n1VE5yoJLt^vGqm`sR02L)D{Cb4_T<2tf(vU`s)Y+Dg9I#O_5SUEdz6v#Q_i0OT?l%fhI z_H3#7a{t42&XXU!HhzN)8s`iS=6~J# zMYZP20D;9S=o=(QxVySBID!nwl{-r_+pR{n*c~O5=B22XNpN!b+mKQK4NDY+5?=Du zzIrTTx&`)AvagYWSm2dCO9i;7O8n?njD#eEy0dpZ@c z^iP@gZn`T-gEQii)(1g+=n$&VLxv{x0q0r;60s3`Q7BZ7fI!jy(_=)mu9seX;q(-7 zoqFJ`%hHfp;?8Q`HWOQL^g#dwk;M0fK(^DY+W^=MHV@K|Exxv{V<<= zXRtOonc9O$3C`5O1ka@VT)_dfS#kZr{KT5>1Bjf}fwu+)FFaM{GN+{Pa0wGF3-GNc z2O6~Q6^$|d{!Y3$MFc;ME)-)s_51fGz`r-$wrm^^wwlRZa3Bvr4Usz;b13-%!|Me^ zc1mBsS7gd*43NP@`+ocG5Yl0CCWsCF1oY)7@a05VeY%8tm0HS5fExXpaD3dT(rpQm zO|u&ZJ-X=mzU2^dqmhu7S^@m1YiN_%9Z1udJpLmXm$TjbY|&VAIwY>vkc_5614iAl z6kd7Q#LoloKy_6V0%>WFJNz~!PgQf99r&(gn&R(9ud-GBi=#NqZdzyZmS;kO5>C$w z%*~C?S*v{e)c}f@Lxes2R1SwsdT#fk`_*QyF5Fq~?ZV0#2=2^!>}A)MSNvG6+l#8t z*VKQ-a+~G8c-}tBBu_K17N~CkzI&HN$YuVO$0itn)9M*b$v2RgaP_T;TZ^_6l!(dT zzI2t5;ox@7jQ`C15;Zeh!0Vxp8w;xp(R34FW9>nL9rAIpF3XXVMfwAY4)%#Ep`1e+R+w(|q?E8{&Wk&0=`Ifb(E2Aw&)S;8>GvIwY-NFwBwl5* z9GsqK&!JK(sz5HJ_ZTs~)tSyiAKraN8>Q9W@^#Ju9Q=Zo*)_f2c4)mJGNc=yC=)x- z5C{OWT(#Nqlfkuf*E%UYtHQu%G;SW0#=clPK6&X5gw0|bK09n!Cuwj5Ej9c+kS@Bu zo0Lxma<0?Bwws=liwcnEjG+)?*3FpMzcWxVlD8BHjo`uj%gjo-4AH*4#R+!(=GB-J zyM+d|Cy@%I&!tj4Hb3t0)0v%-0F^)cX;N!zB6089S&oj{ zl8>|4=cRw>Y7mY(7hSprn0|ZkGvv9wQozJwLZJ~}Xf>L4FZswH<9-ncnSaDU4Du6j z=gTN6^>Jy^m7I{ExFaFbHP7|f&GAW!9{&MZJe#oc;2;CckjP9d5 zEg+Hp+|r}iO$JDJ_#)#5`BU7YR(cZUWkQg0?}t`QPocWM(mpqAg=hk zj_2_-dokhFpc3;pFARl|f3Qs;xEz+EURe{OV%p+J0?zYzJZFzTfu@3fWv4vPZGvncs}mn zBeUJ;%x}mwb&FUzjVnd6v?faxF^`iotk&}9`mxD{{b<1rD1VN)-TWzvj;79#FCHol zG*JAu?IlFnY>0sjb>hcAA^ULUD$VelACBg#6U?) zGj^ZfyTOJuwY9$~UXQp4EN7e#W;F|vSDJ_Vf(Kp_O4p|e<{q7V+*-9v#P!IMa!pNuupEs%IhKtNvFX3@&d)eg$Cdr5 zlUKzm>b}(IzZAIDIgoPS{xkN6;?~@Uf!13!UjSX0oSlmu|p_$zw34iXMt>k8peJdO5rBPz$XQb zSJgBY%MHwnLsJE}UlWTkviOBcL=N0cO5cJ)2|DjUhX-4u_IU}*LtN&YUnF`r?*eWZ zjV+bzHQBwzJCw+tbF-}#(jZ{Fy^)IYR*hLB@!yl?N8*h%B*S__4drK*V;3Mh!c-^K z-a)_4q2o%5up^kWU)50<;Ogg8J3y=nJOdGopp=cy2<)@@Y$N9M&FMQs1UFeTD;1|O zfInW3Z8yZWpA>BWV$%e)L_6!32BSN~gM4gc)aG|Z6MQSHD_S2c?t)sw3@CN2McU9E zJw62H2=Tt)#Pgpy2vtR3?EdhZOBeFG#g;lzn5$j~ z*CW=(hv8eAfBns}zPVh4@(F64YGX%@Gs$HBgMMqn&y@BufV_DJ%5|=oEvHH^*HQEU zolMkKWps;p(2$pkVRikaeSrX8&J$TS7f<;Q) zx|=7Rw`kU;Y)F#xwy*y&NS-?+@dif4e$$*HxUK5I3rtdviB1Q?9tr72-NLlR_%R<> z_%*mMAeh97s?O)>6loke4MDUt;QRpDe#GP&OWvt-K)9|-Yq#8R2SZt9gB&M(6~op( zvqN_ty)iT**;XO&{Go9FC33!(wJwg6*kvhIZ{p7p;qMi$p_@=oai zx~+zmZ!NzMQRK;jMeb#65*p7u8KEDrgK26X0}^__Tz`Rdb7-Dp`NfZS&PU(8tl9i>in~AOkdO}ydB>K>ghh-u26#4$ z;Tz?B$WLN31;B0^B``#@Y*%#S$b?uVpRnd7F4M%mH&wFB&liYEG~`Ig;@(W{PxDK1 z`%A9OZB>tsbrTTL)d9)ppPF&w;wTh{NOh`A`U$uIz?SJ#tR3+PSU>)-mEp#nWdU?D zA#h2Q_M+RE>CBjIG+n-!)s71vD&2YNQWYiZ(6m1k0q8y@{=i&f9KG}8Rs3PQlYSS^ zK$}_{bmZr(tj!crU8-y0VPX74kZ)+f4L2&@Jg5PpK`Uds3m}`?(yTT$H)=ivS+M<| zN}|Nly1IT=z|j1#{OK}+eG9UP>1ER4GMuvF>9P%9Z6vw8bgWwQA|3=hrwRa_ecF67 zIxiszc=$JT_(qEkjc@3bn81F&#`^%=OaDNK;P0!^0zj%teL~M)Hv!bv^39S#yB8^= zyGMBx3iSKxnyO=Y`D`_kx}AR2PS6=bi1TPw*L0{@W+3HC9BM7ju+Q@I*Lb$q! z)EU%@MHCGR7{H(%P;xx8MoU?5L7Mk)x}(2Y&BztkMytdz-G{TCuMhbHpX+|#=hEXB z#{uNM0h}P-`JsXfYK%Bh2mQq}@5-)&K^d>j&gW+kdPe^4)b7G4$WKo$ua;M>@6#Qr zWNqC@1W(B02x#s6&H36$A(gK=FW8`RV9P+#gD3zpOR9 zVmSXfp>b35`0~`Q_EN1M;PFP{lso16^B}r0KwN2iU&|)0`T%nhnCg;D+WV{yK;I+s zpBuFv8f?SkCZ7tG(kDw-|z zmvg4d7vC*RNXb3T9JVXpIqWfi{A=odI=k^Hd-O&BYh3>)*T&Eb{=HP&y7f^n@Tt28|~il{r?9L|uukaPhQ`ls?gZww1dibTf>`wKtusTQ+TydCb#IT6fm)yK8o?9cA|&o_d?ZuamxL-Q%X)$X6uD+1(SiUPvF z?nSGg32;asyX{V-g!_~~ZX}L^yPoaGuD#A7gG(zMGg}}Uh`(Xk^;sIt_rtQHD5`B- zuZ@^;6AS5AOrE^da3zJk1gFAs8^S?gTnrR43^}!>h81t%(!oT)fTQ5}Wlno(_mcMaGwJx>s>OHFWC}CenwfsGl)ut=2h=koc7u?^tpP;{@(bhfNlskPjE$I(i5M zcDY_64|?D2_{AN)Z}#&F*Yn#0TuG>x$V6X284)~7+jv!-{cS|q%IDTV!BV?r zQ7(h`fYIelT3bwF?sP?N>+BWk@22$?Ew0rk0?&8P$@a{Z#<~7#Sb9J1VvQTrMqRuL=e|R7S9MtiRvhC<}rLpJWPvOCz zH3e9?-Bbv2IVS1oRoUrA75YeA1d_w`^})?`8BbjJfaSW-Z!Tk?bNuu(Md-EQJsI5A zZ=B6m0y+1p+b?#kz2YLYNM1*CbNvC2$j=i9p?f-Pm6qA1;0~YeEtrdkT(`0@w{z_v z(Q@}S0}&zpgn6pf08qT|T(0lK2DfCN(K)^OUZw%4y~1#m&-*-TWhXzEEY zP&sByRMX-KKHVe-Ov2@ad5a8FI61c2p&H8N-et?=46Jnm7swdE2zj}^>b_+fim%PF72ZLLB4aDtLsSi$lX@eGY0NUjQYv%)sCyvcO9-b@Ep33 zS;PQ8Nj13rRremwjGioABa|{EPSw^fmeS9x@ckiI(E^E>6I*4>7Po8iOLKi8)iy`qpB#mAUuTbv zp=k<AWhkAxqHsWN57R*9{eM+Ie}n9V&URU{SGe8Ac!`QTQfFQx zjvXgCP(m%k$9Z~r^+5d3VRwjei=S)wxO^5P<7V&=<_v!2<{e}HoZ+`5%mL2=W#9Ui z1&Qt1B`8#rR7Mfp{hqirn$apm07-(hyr_xDdDVE(P&uvGQfwF;aEHqFy^G7f{u`5l zqbbO#%1Ah4?d!=_!SeyKO8hTb4V;ylPbw;EJsLX9)5CVflYgN0oH5%WuYm|+ivPBn z60vPbb+f}e`c>^avmu(L-PYM=QTkPLfaTGdNMsV9j9C3n!doz?%u7ElEW1h&aUeG zN6OQl_tB+~bXa`-E=de-g{!&vwq(gV--DnmMM*itaT@@JCb0vkAOd^=fx2TBkN{QI z3v+p%s1AOc3=}Zo`aoa1Zj3Z>sIgoX%)y<}9iK*}^BhoMcRvZE8cY4Gub2v5SO1s@&+C~JAyU0?Ml@uEdQ{V=CZF!LNO;FV%T)j zALZ=51((cNdpdMn9IH4yyiP-KN>$2{SvUBs1-w_>-AuEotF%k*wNPCZAGuiT>U^Uh z;Cc*7<>BNB#eq&ONKw1ih83=SoE8=3(Zrv^Tzn%5(Vi%%tr#`+DcZOBg9;epn2m^N z0Fh-?HVzV$>pazt2QC5!)gblD8VOg8+!_|_3KttUDiv_L#JJt?C(hZSl)4~e^e5qq=>I!|P2R=1ByH*+v2=mQ4 zRyk03zW{~%5HX-Z6ko)XU=RYC#3n9dLjrsGrAp|4`*;4QowO_4v~f5%(wFZ#up1?= zxd~IheQg2Rs`GTv9Jq+p>|@`BUrfJ6g$xxK@oPXe4&+#4=#XY?K6RSiJ@hG9(jMU@ zqA~qU8av&A&!`GXF2n(X3e;zs7q%XB+PkPrXFbD4moNt$p+S6nGfA(N%QKmW#g5?p z_kjZ6vXni5VH>Cf!39t((g3&;zJg?7q?JT8$x6vqMzxLv!CIS(=8fDW?Q98Wzpjn} zL0`;6MPOz?fnH&aj(m;`ywdiFC@XdgN(f|8*KXj*_oxfV|Hj$x*Lap41SuYuNoU{& z+_A+!4Q~b1kZ|5QfWB|G%X5MebLFxDNCv>S+-GG}(JPAD_@LKS_t2fO~#*G@#c0wgV3G$WPk%faGl@KF2`~{S@PP9>uL`^-(qdQT~z2GW2{#ipQJTeGHxEr6B_{Op93gjw34Qgf?~%W z(9aDldF?-q;K2r&{xFT`>-Ofh*WY_V$=jf(MoAS=)uY7?6k|?AvCsOg2I2ufT?rI+ zI-TZhj;HPYuYgvN9B6dlGJ?n<$)nDhH;OCz_xN|j3&d1V5hho zT}(qJUJ%xAL`V5F-jFD%@I8`HZn0z#Q*M3P9Se`%meRN`9L=yy!0HVMAl}YW^Kaku z1kQGT-E$&V`uxMbVCIrMgEO#1nR6ZAkjZ`p#x|7h?`uIIU6?c8L=#W`Q#>wU@q0|m zFaQ;|O<7=cM><+<(6L5y1OnzwsjbcLPe@$K&X?HrX&BU&{H5a2e7BzgS>ZBMxs)&t zpgIkC1e)-0*O);a7MB8%(DgbjR(?%f@+Zrj=~p`^teS9fL>SZ`=tq6_ZAnv4ZoGjEnv5x4UTc(7 z5LB&iB$VRufS`^yY+SuSk+iPSGR4Y5_cM^PaVtNVxIwJ+siI)oqG@Xn+i$q|`%5Yt z_t+sgtEo#&o67w`cAe2%i%!n~b_?6d7&I-EG%y%+Ni*Y4I#xUkcwblNu-b#FEz;y7 z-E23UeGvAEc{+I|%=sI4#gredKpM+k=aKMbnr~$)VEW15yR2h5H!4od>VCGXA9fK4!GTKt-%fxWD&n_>uuOH`pXN(SPBOrcWdsE;ZY>f<2skDnP8t1foCSP2FWcN>MKkqLp zIH`AcUv9rT0B{;Jv~A$Q7wEOyjxww-dmFby5PfU>VM-2=xkn`;B$5N78W^a-8yPm1 zp-{vJlkefoW)n{Pqu;+#V8=hFy8gxzDAoeB*wUwpO+dSi1hj6` zM#{?hsIZ5G-*M2nL)HC<1iPPPi!_yLdk*h! z`L`8taKZqtzwTYn>pTDg*cJ@W662YZLOj8#<~{(dQJt%UVg=8EB3#JgJ!fZ3dCiOh z3wqjSmpL2Wlozcx{A~%-*LW22`@3EOuZR%S!o@mVW2K;aq*%U@=4(Uexeu=ZR=+fu zgcHBSEcrgF?JI-@SXLrX^4RLxH|Fs2+Svo0J1`9cRbo*HtO>PG<*qY<|&j6 zcEI?%LdtpUGN6K-0@U`ji5gC>xAYtk6~j$oFQR+wG@mMJtJ~*~3#^Om?M@Fg2>?(0 z^)}0U_s~V`4Av1ixyBdn%R+)xRMmje)`ewImc~LUIxyKT{)t#hyE`5S`XagXf#2Mb z-Yd&o>{qG_T_6C-X)4o?$P5j5MLwi}3W@gJS4C%PaF-L@n5H|A%d!4U^G)EJHV3PZ zr4884gNXtzLNqfUY+@9ZAKm=+&E*H~L9$Q@!#Ek}L|YXGuLHUGiTnX$UlK1RRX0ot z3+RBL-`A2&Kb_;N9biW<6_2l^@~TeufWVXI*95#uy$q5-Pz$Y~gsPZa6*yMUE~~Qv zDJrSZ&+EwhBr47Xh#@>=iUVP7kU88S<}0eh*7R!0?s5kxlg*VabwK3>qIqSQLI!rA zPym<7rQx9lHE8Ui*XfP~%1gk(4uMuhx|H;14G*gosyn`bJ_WCl&4{qq(nzW4=V+~;dhwn6vc@`TB5+0gGt~vjDu@)I)C@4e=^p{j zV7UFJ2KR6xAxZp~tr?b+xq_*{(?@pOc~AW`J%TwOn5k_9jK9qc{_7~&?;wi@9>-t0ur2FJhwrXFFVwmD@uS9x`y#T8BQs&Dy1YN)<}ReEsqeQi>eO>^W-bOC zALhb4_mD9cfo{P6*^t5d#3fv;{RphH(eEo3^Sm3L5b1=&w>zPkFOEPpRunjze7!Cj zn>tz|xJPpEEnlydvirJCI8U#Y2e7q9(14T$8s_@mNUlg?pK%IdK!tqdCCb&N#!16E z8^}P(hU#0>Ugu>AOL7LTDl6%-K|jI)CjWAie(y5xEZP!mn+e4I&Ng7=q##(3I3?kTZVod+LIuo_q# zcHCSV&x!V<5D)RDJN&$nBkL}k5bFTYI&|C*)biMJSQ3Y>6amx+8mskh9%}kHhHNc! z7b-@spk!>`gCkoFBqP~Sf=168NHNEY5ZyX90arEC&+ z!l1LTTX{4oXPG^JJN};1S48CQD>2LBLl*Bw{XLCQz2&SbmcK_8o(|kg%0a#9v0#NVP(?~;(?9BI^L9CXbpEu~~WuMvtoL%3KR76(Y4}%$% z)ASm32cf$Fy~E@~=%(5@IJxA>Cn6xNT>{omKIXJoh+m5UbwF_iCSlBym;~H$xWN${ z-wq)RC;+l=!(zQ4ACrp4F?8o~rUufg{NsRYl~0ByuuMh;PepvGWU^2^N~dK(+F!ca zYtBOG4!R!(_LdP?p9E5o;ssbg_Zs%Z*vgqi|H#NBO*-LQ%iI;jUmxdvPZAkEh56VR zzZfEVegdEh5$o)F3;OMPBZuvl3UmOIM3m|WTEA|r+pP~lZ&TR;P-K$G;1-86rn`c4 zo1uI!`-!F{TB#jPGI=TzM?F=$!X@zMm(8DlYsyqT^K!6QiHFGzr>q$7DIRrcqb|-u z6V*~TpXO3kxJP{L{qqS>SR2$JBsthfG5QqMw6G|^)b#^S^ zM93dWve$WW31=N01Yjx&zv?kuPP1&&LbxXnk++h>YNU6)v2l4F`OOe=* zaI8h?tfr`(jt6YDPBrLgEdYHL*P3qJ_GK#vaQ|Y9gH=%q+!7s$2$`RsSW9Ag7Q+hD z9l)~qY?>26jYHVjys9_$#zv2(<{g?TO&RV3$|%g#2_-j8r$a(sLp7O;I!WTeq5(*| z>DtuyP?*NBkoT?GlT1-^X)| z&*r7<`SYB;SCk4vJ9w`wmAMkrQn5haPy*=BD}L9~f-->i=+`q-OI=ymhkEZF6kOgTQvO}s}hAs1D)NV)fEUyE@LTp@g#bSUM)8mM0aJ;2wm zpfp>fzaWvEF=K!izmz!sG!dc4{i{z=1vDZEK%H*}s3Y=cN&%HvWIKU}3}%Q!qBgY6 z5~oZXX2|FWK|n-o5p!p7CoKh)k9@g8tRPEf`8z?g=JlaLc znOsFHE@mU%%cU+bnWy-qacgRVdn$c*PR9>v=89<1?kgw|Fgb=sWyt z85m?j?ZO^E_udJVB34dR?vadt;0LROmzsC0L>eha2Z?@0_aIL;)idu%e~j0He|N(n ztR$vP@EuH0&bQ2-;zPU;^o9eL*0+vVvg{l|BD1KNRd7jB`LGdDYXx#tG^iuJ$X$3f z*+`LHi-hGFTIOdTU*J-h&n&UpLEjpnsSPI4IKqYnT6ok0fbNptA@~h)Kx_4zld!uq-yO`)%#XmOJ6dO7^!`N5aR>-8!8`-gzRG# zlo;`EQD(z6)ms)4Lna$$&!q;;G~oVsSl~y8>gf%drP=-WRfRo3GlZSJX`P4}kZ2D1 zVrO`F1}aE(CCy7p>Q{qb5%7Xsoe-Ttj}M74BBj;$t#F_=t(;{2lO|ASX7nXPXD)v? zMAD^Jo}d}fTrS0 zOZ#t~T=_rLYa1R+)7XlMOo^hwh%!W$#y*xonx?UwQxef4yR6AO)*@oYXzXQ6Bw4F* zieyQ$WQ!KXAzB=ilhirYdq2JX1@BMu`Is@E?>x_QKlgoK_jO%v8MT2KrLrG+B=4`f zpZ-d6eeAo${qW%x=H7WOQB|ZY(_QSK_{G?<*9Tui!G0il@wFMi$)7-N5CnBXr6iWI zjg=yV^8G}}kHwS{U^zD{@4u{v&akn|W(kwaJ9L057Wb}_oaP-|kfp%_hKu7s8b@?k zXIseI7(`3yVyVU{LXwI$4t*&!ix}U8&66dkKYbM!G{kPj&Gni1G&Z+z$2QSHk6<{e zNrwUCBanW}3@I8Sb`eiXtzF_xeG1?_0k+Y{<3UeLB6-wY&NL(_PL?1=Y=2^C?%Ti> z&#^mRzH`r+&y-~m?FXkgciDBo$I3Bp69^am{!fOKS7WIClnu<0N5`^_1SOu zNuV7@a>O$R0NEPtARC*LXwQO*fqA4V<8wINKe8ivlEugz2FC#;s$nl1-Za*sR@pk4 zSEF9+G>8dJvEN@#%K%uf?zD%a;cM6IyO4vt4V;$-EDe9n+458Cr>lU5TU`&;`EOO3 zcEteb0`da2mY-&tHUF4sU7?X1a8I-*<} zs0`(2x4n`&ory;p#oLg2#3sVFi~8tZvOBXD6nw<*HQHHMhc4Vibaa*&RsbV95 zW3p=pQr<3>M&`P0|qV~upqWO{`O#=zb&zDexMebPV zh(Kf-_o*gK5LAoC{8xZk_7LP$t2fbtVIaEqGbs<4{&W6n{Lg#VzB{#Gy0CQj-sDDe zw2ZE&muBpzz1+e`2R`fw_Xh;lWy8^F5chAa~*B`t9!ri7gOW)V0o`E>6AE>`a)Uoxyn5Xi)T1YPc)TQA5 zY5wAsT?BxyV75QtOl^yMLrD;n0?uGU^II(TLYN1q#~j4ZQa|r%z%^tR_tHl?rP{CB zfS9Q$R>=HVihB8ekT}gfUj~L*gc#|bm)Kon@`GjUITg%%k2NP7=hGuEIs#p9@IK`c zVo9maE6&S6Km|;NK70X4(st^MPAsOPL!WbB!N09G$$Pl8(z*2q}R!k>A_Wt?^{trNMprU^$S&mK2YIQH+G8_EX5-vceMKMDyrg44q-dY zYTZwIWanzCf7&P5{wW6j$J{SDWGAQRwmV#+l+LM{6ILzc?2ENsFn`7AZVZ4L=BsVp zI`0REx;XZF5mVW_TP-SduW}C0?oU++#{6s|fhV+mP`Gl3Zef5{^_Wc^$jF11%<1q#Jd5X`^++y-;?42IW#YbaLEZ{zjrb_XDxwWpT&@DH6xC4U>uT;xjA=&sVlRBC6MrPU1TNTSJ7Tu;FDChhE4gmeY zFcelS^hO96WKrcrx9mB1dW_I}$5RN?NcNGicD^YoDR8Zw76X=Db1f38j*}gnYZ%=y zhvV`S+wJ}gl?*a4le4dcAL6fHwj1Wluwp0{0Ys?B2U@Y;y3XnXt zMLGMYCmZ`i;ah0?e|tJ*0jouPJXZ`r1_&l26avQ>j_gDx4lPLZP{5??!>5msQx5eA z*aBtr94_KzBbr!1l^1OCpYU!kQt(-W}$cRi`v38X3?qbhYljAf%^9$n4AE1MbE(I*Pu*Y63Gx#*?+|& zSWYQY^H|#%OMn>x2uf4;recnEWg1nDH2qA2!yWHQ(sl?Y)hl9%ykcZSj=XzZcmCl> zwt2peWSd-3#ZyM6{xxYKS(_6Oz6ITAg3GL0aJ&i{_prWkp`)C|Ac>_cS=+#h8KjMpXri?pHGwRyN5r zo#d-Kb@$zmCjmUN>$Y>Rnlu7CZrJL83z2d%9nm6Xl{_pFR%!3SC)#RqF}?)p+P)63 z+seT@G^$A_L<|Wg)Q|8`Ljh1q1e|lQ&AK*1Jfc2fpydMASrfKClhkHBp&Mm7t3#Rj zqR8Itr(aH+Ygdh^*oxD$HuWJ2{-@hb^pb1JTc_k!kOU-B`Ri6JkcXN`QNiMRJ1A?s zW7;s7;laR_eH$L+ad@uu>IvQnq9Z;#lF=A@kAqB!tn|*Q`L^mB>WRSYA?Go*@J?bd z6nP?EF>Ul=N&&L^nU-h7paOMNBwqXDR+3)c#%)G`w2e>|#ETfPk?|R8BE(hXiIrHH zKZ1&h7DrUlp(*9x-_bK?ZTsbbp6@x79XQxU8FO25 zuFNt}GJDw|M-pvb?b;B%<9D51owMmARl5tlt2MdA0puRk3G&k>Gi>X38ZDi z_9pTfA9JJXha4p~8aNZS8X@^6gv_jNv}IzTiQR^KwOpdQ6ytkXwFT#v|A%J|e`@kP zu>pNb$HU4A@f@ts>wsAj(7_^llfFdpLZYsVNdGigg2yVgHv4iwLOJ5yc1g&B?jOC1 zi4yFIkGF`}6)~0X@m@)2oN91`WbK&1Ybmg&EX{oQ{mO(UPmpW;n<}E3r7hWc^32S> zJQoPV?K(RXqNJ5h&{8a4g?_UQ{M}H<-6oyShyuTk=<2*)v^R}gF8}*iRnj+yh}QRB zj&S1xYds&gDxJ~<8rk~DuPo@Eci&R?!{XWoA+%jU#eroXE%T!@cN#szi1{SJ+bf59 z?8!j-{ZCl&Z39|2YhAPvs+RQaDcL#u4(Rn;Uu&%LiQZ(UR4cagW)U%^kI z>uv5|_ip_Q=~z!nJ+|rEdCp{)3d)+E3g0KPCaDU-DUHW0hb?Bn|4^Zn7lo5@(WmX@ zZO>(ZR1(sGRccnTkgE8$$zFMhqsv@oFH2?|>{7Jw<9Bi{Tm?;H&W&JzUcxyw!n2_K zecyiUigvajmThE4|DTN!*+4n*h)NSf6%DBXw=Dp)Gxd!6^VMcR{f5>Pxa%ix&*1^uylvNPbEV-CQA%Ut#z53xfy!D`5ojywm*dqi4uza$&h$U=Ms8{^tH6yB zlX>By%Es&9Tf#3NO6A<4euj$6CO9kRq7_-2D51)4-Oz_#VJ!duz5Fn38!vtqir=*Y QKGPVwl_Ra%;&9x50ir%7?*IS* literal 0 HcmV?d00001 diff --git a/public/login.html b/public/login.html index e69de29bb..eb05d012f 100644 --- a/public/login.html +++ b/public/login.html @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + SillyTavern + + + +
+
+
+
+
+

+ + Welcome to SillyTavern +

+

Select a User

+
+
+ + +
+
+
+
+
+
+
+ + + diff --git a/public/scripts/loader.js b/public/scripts/loader.js index 179a0437b..91e7df196 100644 --- a/public/scripts/loader.js +++ b/public/scripts/loader.js @@ -1,7 +1,5 @@ const ELEMENT_ID = 'loader'; -import { populateUserList } from './userManagement.js' - export function showLoader() { const container = $('
').attr('id', ELEMENT_ID); const loader = $('
').attr('id', 'load-spinner').addClass('fa-solid fa-gear fa-spin fa-3x'); @@ -10,7 +8,6 @@ export function showLoader() { } 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 () { //uncomment this as part of user selection enabling @@ -35,8 +32,4 @@ export async function hideLoader() { //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..ef34b6747 --- /dev/null +++ b/public/scripts/login.js @@ -0,0 +1,142 @@ +async function getUserList() { + const response = await fetch('/api/users/list'); + const userListObj = await response.json(); + console.log(userListObj); + return userListObj; +} + +async function sendRecoveryPart1(handle) { + const response = await fetch('/api/users/recover-step1', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ handle }), + }); + + if (!response.ok) { + const errorData = await response.json(); + return displayError(errorData.error || 'An error occurred'); + } + + showRecoveryBlock(); +} + +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', + }, + 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); +} + +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(''); +} + +function displayError(message) { + $('#errorMessage').text(message); +} + +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', + }, + 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)); + } +} + +function redirectToHome() { + window.location.href = '/'; +} + +function showRecoveryBlock() { + $('#passwordEntryBlock').hide(); + $('#passwordRecoveryBlock').show(); + displayError(''); +} + +function onCancelRecoveryClick() { + $('#passwordRecoveryBlock').hide(); + $('#passwordEntryBlock').show(); + displayError(''); +} + +(async function () { + const userList = await getUserList(); + 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); + } + document.body.style.display = ''; + $('#cancelRecovery').on('click', onCancelRecoveryClick); +})(); diff --git a/public/scripts/userManagement.js b/public/scripts/userManagement.js index eff0b02b3..e3cd9d34f 100644 --- a/public/scripts/userManagement.js +++ b/public/scripts/userManagement.js @@ -1,10 +1,3 @@ -async function getUserList() { - const response = await fetch('/api/users/list'); - const userListObj = await response.json(); // Assuming the response is in JSON format - console.log(userListObj) - return userListObj; -} - async function registerNewUser() { let handle = String($("#newUserHandle").val()); let name = String($("#newUserName").val()); @@ -111,17 +104,6 @@ export async function populateUserList() { ` const userSelectHTML = ` -
-

Select User

- This is merely a test.
Click a user, and then click Login to proceed.
-
-
- -
- +
+ + + +
+
@@ -5277,17 +5292,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/script.js b/public/script.js index e81adefef..dd697b331 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'; //exporting functions and vars for mods export { @@ -6097,7 +6098,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', ' '); @@ -6151,6 +6152,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); diff --git a/public/scripts/templates/admin.html b/public/scripts/templates/admin.html new file mode 100644 index 000000000..4db762b76 --- /dev/null +++ b/public/scripts/templates/admin.html @@ -0,0 +1,77 @@ +
+ +
+
+
+
+ avatar +
+
+
+
+ + +

+ @userhandle +
+
+ + Role: + + + + Status: + Status + + + Created: + Date + +
+
+
+ + + +
+
+
+ + + +
+
diff --git a/public/scripts/user.js b/public/scripts/user.js new file mode 100644 index 000000000..b51a463c3 --- /dev/null +++ b/public/scripts/user.js @@ -0,0 +1,236 @@ +import { callPopup, getRequestHeaders, renderTemplate } from '../script.js'; + +/** + * @type {import('../../src/users.js').User} Logged in user + */ +export let currentUser = null; + +/** + * Enable or disable user account controls in the UI. + * @param {boolean} isEnabled User account controls enabled + * @returns {Promise} + */ +export async function setUserControls(isEnabled) { + if (!isEnabled) { + $('#account_controls').hide(); + return; + } + + $('#account_controls').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(isAdmin()); + } catch (error) { + console.error('Error getting current user:', error); + } +} + +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); + } +} + +/** + * 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); + } +} + +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)); + template.find('.usersList').append(userBlock); + } + } + + const template = $(renderTemplate('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('.userCreateForm').on('submit', function (event) { + if (!(event.target instanceof HTMLFormElement)) { + return; + } + + event.preventDefault(); + createUser(event.target, () => { + template.find('.manageUsersButton').trigger('click'); + renderUsers(); + }); + }); + + callPopup(template, 'text', '', { okButton: 'Close', wide: true, large: true, 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(); +} + +jQuery(() => { + $('#logout_button').on('click', () => { + logout(); + }); + $('#admin_button').on('click', () => { + openAdminPanel(); + }); +}); diff --git a/public/scripts/userManagement.js b/public/scripts/userManagement.js deleted file mode 100644 index e3cd9d34f..000000000 --- a/public/scripts/userManagement.js +++ /dev/null @@ -1,161 +0,0 @@ -async function registerNewUser() { - let handle = String($("#newUserHandle").val()); - let name = String($("#newUserName").val()); - let password = String($("#newUserPassword").val()); - let passwordConfirm = String($("#newUserPasswordConfirm").val()); - - if (handle.length < 4) { - alert('Username must be at least 4 characters long'); - return; - } - - if (password.length < 8) { - alert('Password must be at least 8 characters long'); - return; - } - - if (password !== passwordConfirm) { - alert("Passwords don't match!") - return - } - - const newUser = { - handle: handle, - name: name || 'Anonymous', - password: password, - enabled: true - }; - - try { - const response = await $.ajax({ - url: '/api/users/create', - type: 'POST', - contentType: 'application/json', - data: JSON.stringify(newUser), - }); - - console.log(response); - if (response.handle) { - console.log('saw user created successfully') - alert('New user created!') - $("#userSelectBlock").empty() - populateUserList() - $("#userListBlock").show() - $("#registerNewUserBlock").hide() - $("#registerNewUserBlock input").val('') - - } - } catch (error) { - console.error('Error creating new user:', error); - alert(error.responseText) - } -} - -async function loginUser() { - const password = $("#userPassword").val(); - const handle = $('.userSelect.selected').data('foruser'); - const userInfo = { - handle: handle, - password: password, - }; - - try { - const response = await $.ajax({ - url: '/api/users/login', - type: 'POST', - contentType: 'application/json', - data: JSON.stringify(userInfo), - }); - - console.log(response); - if (response.handle) { - console.log('successfully logged in'); - alert(`logged in as ${handle}!`); - $('#loader').animate({ opacity: 0 }, 300, function () { - // Insert user handle/password verification code here - // .finally: - $('#loader').remove(); - }); - } - } catch (error) { - console.error('Error logging in:', error); - alert(error.responseText); - } -} - -export async function populateUserList() { - const userList = await getUserList(); - - const registerNewUserButtonHTML = `` - - const newUserRegisterationHTML = ` -
- Register New SillyTavern User -
Username:
-
Display Name:
-
Password:
-
Password confirm:
- This will create a new subfolder in the /data/ directory. -
- - -
-
- ` - - const userSelectHTML = ` - - -
- `; - - // Add login screen - $('#loader').append(userSelectHTML); - - const parentDiv = $('#userList'); - - userList.forEach(user => { - const userDiv = $('
') - .attr('id', `userSelect-${user.handle}`) - .attr('data-foruser', user.name) - .addClass('userSelect menu_button flex-container flexFlowCol'); - - const avatarImg = $('') - .addClass('avatar') - .attr('src', user.avatar); - - userDiv.append(avatarImg); - - const userName = $('').text(user.name); - userDiv.append(userName); - - parentDiv.append(userDiv); - }); - - parentDiv.append(registerNewUserButtonHTML) - - $(".userSelect").off('click').on("click", function () { - let selectedUserName = $(this).data('foruser') - $('.userSelect').removeClass('avatar-container selected') - $(this).addClass('avatar-container selected') - console.log(selectedUserName) - $("#passwordHeaderText").text(`Enter password for ${selectedUserName}`) - $("#passwordEntryBlock").show() - }); - - $("#registerNewUserButton").off('click').on('click', function () { - $("#userListBlock").hide() - $("#registerNewUserBlock").show() - }) - - $("#newUserRegisterFinalizeButton").off('click').on('click', registerNewUser) - - $("#newUserRegisterCancelButton").off('click').on('click', function () { - $("#userListBlock").show() - $("#registerNewUserBlock").hide() - }) - - $("#loginButton").off('click').on('click', loginUser) -} diff --git a/src/endpoints/settings.js b/src/endpoints/settings.js index ce5baf6cc..117b51f90 100644 --- a/src/endpoints/settings.js +++ b/src/endpoints/settings.js @@ -8,7 +8,8 @@ const { jsonParser } = require('../express-common'); const { getAllUserHandles, getUserDirectories } = require('../users'); const SETTINGS_FILE = 'settings.json'; -const enableExtensions = getConfigValue('enableExtensions', true); +const ENABLE_EXTENSIONS = getConfigValue('enableExtensions', true); +const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); function readAndParseFromDirectory(directoryPath, fileExtension = '.json') { const files = fs @@ -163,7 +164,8 @@ router.post('/get', jsonParser, (request, response) => { quickReplyPresets, instruct, context, - enable_extensions: enableExtensions, + enable_extensions: ENABLE_EXTENSIONS, + enable_accounts: ENABLE_ACCOUNTS, }); }); diff --git a/src/users.js b/src/users.js index 9c8ee7e2d..022d6f904 100644 --- a/src/users.js +++ b/src/users.js @@ -604,7 +604,7 @@ const publicEndpoints = express.Router(); publicEndpoints.get('/list', async (_request, response) => { /** @type {User[]} */ - const users = await storage.values(); + 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) @@ -612,7 +612,6 @@ publicEndpoints.get('/list', async (_request, response) => { handle: user.handle, name: user.name, avatar: getUserAvatar(user.handle), - admin: user.admin, password: !!user.password, })); @@ -672,7 +671,7 @@ publicEndpoints.post('/recover-step1', jsonParser, async (request, response) => return response.status(403).json({ error: 'User is disabled' }); } - const mfaCode = Math.floor(Math.random() * 1000000).toString().padStart(6, '0'); + 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(); @@ -706,20 +705,30 @@ publicEndpoints.post('/recover-step2', jsonParser, async (request, response) => return response.status(401).json({ error: 'Incorrect code' }); } - const newPassword = request.body.newPassword || ''; - const salt = getPasswordSalt(); - user.password = getPasswordHash(newPassword, salt); - user.salt = salt; - await storage.setItem(toKey(user.handle), user); + 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); + } + return response.sendStatus(204); }); const authenticatedEndpoints = express.Router(); authenticatedEndpoints.post('/logout', async (request, response) => { - request.session?.destroy(() => { - return response.sendStatus(204); - }); + if (!request.session) { + console.error('Session not available'); + return response.sendStatus(500); + } + + request.session.handle = null; + return response.sendStatus(204); }); authenticatedEndpoints.get('/me', async (request, response) => { @@ -772,6 +781,25 @@ authenticatedEndpoints.post('/change-password', jsonParser, async (request, resp const adminEndpoints = express.Router(); +adminEndpoints.post('/get', requireAdminMiddleware, jsonParser, async (request, response) => { + /** @type {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); +}); + adminEndpoints.post('/disable', requireAdminMiddleware, jsonParser, async (request, response) => { if (!request.body.handle) { console.log('Disable user failed: Missing required fields'); From 411a8ef8a7889ded3f6866936d67dcad353691a0 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Tue, 9 Apr 2024 21:58:16 +0300 Subject: [PATCH 17/46] Enable CSRF for public endpoints. Split users module. Add rate limiter. --- package-lock.json | 22 +++ package.json | 2 + public/login.html | 4 +- public/scripts/login.js | 133 +++++++++++---- server.js | 77 +++++---- src/endpoints/stats.js | 1 + src/endpoints/users-admin.js | 123 +++++++++++++ src/endpoints/users-private.js | 83 +++++++++ src/endpoints/users-public.js | 187 ++++++++++++++++++++ src/express-common.js | 23 ++- src/middleware/whitelist.js | 16 +- src/users.js | 303 ++------------------------------- 12 files changed, 596 insertions(+), 378 deletions(-) create mode 100644 src/endpoints/users-admin.js create mode 100644 src/endpoints/users-private.js create mode 100644 src/endpoints/users-public.js diff --git a/package-lock.json b/package-lock.json index 4f9bfd49c..2b7c6c0d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "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", @@ -57,6 +58,7 @@ "sillytavern": "server.js" }, "devDependencies": { + "@types/jquery": "^3.5.29", "eslint": "^8.55.0", "jquery": "^3.6.4" } @@ -731,6 +733,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", @@ -761,6 +772,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, @@ -3288,6 +3305,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", diff --git a/package.json b/package.json index 313de3b7a..ff6de4e59 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "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", @@ -81,6 +82,7 @@ }, "main": "server.js", "devDependencies": { + "@types/jquery": "^3.5.29", "eslint": "^8.55.0", "jquery": "^3.6.4" } diff --git a/public/login.html b/public/login.html index eb05d012f..b1e7088d8 100644 --- a/public/login.html +++ b/public/login.html @@ -26,8 +26,8 @@ SillyTavern - -
+ +
diff --git a/public/scripts/login.js b/public/scripts/login.js index ef34b6747..184673829 100644 --- a/public/scripts/login.js +++ b/public/scripts/login.js @@ -1,15 +1,46 @@ +/** + * CRSF token for requests. + */ +let csrfToken = ''; + +/** + * 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'); + const response = await fetch('/api/users/list', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + }, + }); 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 }), }); @@ -22,6 +53,13 @@ async function sendRecoveryPart1(handle) { 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, @@ -33,6 +71,7 @@ async function sendRecoveryPart2(handle, code, newPassword) { method: 'POST', headers: { 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, }, body: JSON.stringify(recoveryData), }); @@ -46,6 +85,50 @@ async function sendRecoveryPart2(handle, code, newPassword) { 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) { @@ -72,52 +155,33 @@ async function onUserSelected(user) { displayError(''); } +/** + * Displays an error message to the user. + * @param {string} message Error message + */ function displayError(message) { $('#errorMessage').text(message); } -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', - }, - 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)); - } -} - +/** + * 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(); @@ -125,6 +189,7 @@ function onCancelRecoveryClick() { } (async function () { + csrfToken = await getCsrfToken(); const userList = await getUserList(); console.log(userList); for (const user of userList) { @@ -137,6 +202,6 @@ function onCancelRecoveryClick() { userBlock.on('click', () => onUserSelected(user)); $('#userList').append(userBlock); } - document.body.style.display = ''; + document.getElementById('shadow_popup').style.opacity = ''; $('#cancelRecovery').on('click', onCancelRecoveryClick); })(); diff --git a/server.js b/server.js index 2a29f950d..58ec857ef 100644 --- a/server.js +++ b/server.js @@ -192,36 +192,6 @@ app.use(cookieSession({ app.use(userModule.setUserDataMiddleware); -// 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') }); -}); -app.use(express.static(process.cwd() + '/public', {})); - -app.use('/api/users', userModule.publicEndpoints); - -app.use(userModule.requireLoginMiddleware); - // CSRF Protection // if (!cliArguments.disableCsrf) { const COOKIES_SECRET = userModule.getCookieSecret(); @@ -255,12 +225,51 @@ if (!cliArguments.disableCsrf) { }); } -// User management -app.use('/', userModule.router); -app.use('/api/users', userModule.authenticatedEndpoints); -app.use('/api/users', userModule.adminEndpoints); +// 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', {})); + +// Public API +app.use('/api/users', require('./src/endpoints/users-public').router); + +// 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')); + +// 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); diff --git a/src/endpoints/stats.js b/src/endpoints/stats.js index b6195c8cc..4d20c8723 100644 --- a/src/endpoints/stats.js +++ b/src/endpoints/stats.js @@ -462,6 +462,7 @@ router.post('/update', jsonParser, function (request, response) { module.exports = { router, + recreateStats, init, onExit, }; diff --git a/src/endpoints/users-admin.js b/src/endpoints/users-admin.js new file mode 100644 index 000000000..d594b595e --- /dev/null +++ b/src/endpoints/users-admin.js @@ -0,0 +1,123 @@ +const storage = require('node-persist'); +const express = require('express'); +const slugify = require('slugify').default; +const uuid = require('uuid'); +const { jsonParser } = require('../express-common'); +const { checkForNewContent } = require('./content-manager'); +const { + KEY_PREFIX, + toKey, + requireAdminMiddleware, + getUserAvatar, + getAllUserHandles, + getPasswordSalt, + getPasswordHash, + getUserDirectories, + ensurePublicDirectoriesExist, +} = require('../users'); + +const router = express.Router(); + +router.post('/get', requireAdminMiddleware, jsonParser, async (request, response) => { + /** @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); +}); + +router.post('/disable', requireAdminMiddleware, jsonParser, async (request, response) => { + 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); +}); + +router.post('/enable', requireAdminMiddleware, jsonParser, async (request, response) => { + 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); +}); + +router.post('/create', requireAdminMiddleware, jsonParser, async (request, response) => { + 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 = slugify(request.body.handle, { lower: true, trim: true }); + + 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 = { + uuid: uuid.v4(), + 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 }); +}); + +module.exports = { + router, +}; diff --git a/src/endpoints/users-private.js b/src/endpoints/users-private.js new file mode 100644 index 000000000..17fe99ad2 --- /dev/null +++ b/src/endpoints/users-private.js @@ -0,0 +1,83 @@ +const storage = require('node-persist'); +const express = require('express'); +const { jsonParser } = require('../express-common'); +const { getUserAvatar, toKey, getPasswordHash, getPasswordSalt } = require('../users'); + +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(401); + } + + const user = request.user.profile; + const viewModel = { + handle: user.handle, + name: user.name, + avatar: getUserAvatar(user.handle), + admin: user.admin, + password: !!user.password, + }; + + 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 || !request.body.oldPassword || !request.body.newPassword) { + console.log('Change password 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('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 (user.password && user.password !== getPasswordHash(request.body.oldPassword, user.salt)) { + console.log('Change password failed: Incorrect password'); + return response.status(401).json({ error: 'Incorrect password' }); + } + + const salt = getPasswordSalt(); + user.password = getPasswordHash(request.body.newPassword, salt); + user.salt = salt; + await storage.setItem(toKey(request.body.handle), user); + return response.sendStatus(204); + } catch (error) { + console.error(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..dbd2659bf --- /dev/null +++ b/src/endpoints/users-public.js @@ -0,0 +1,187 @@ +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 } = require('../util'); +const { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } = require('../users'); + +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 { + /** @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(401).json({ error: 'User not found' }); + } + + 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(401).json({ error: 'Incorrect password' }); + } + + 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(401).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/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/middleware/whitelist.js b/src/middleware/whitelist.js index 87d5ac5a5..757b72667 100644 --- a/src/middleware/whitelist.js +++ b/src/middleware/whitelist.js @@ -1,8 +1,8 @@ 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'); @@ -19,20 +19,6 @@ 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} listen If listen mode is enabled via config or command line diff --git a/src/users.js b/src/users.js index 022d6f904..d64655d0a 100644 --- a/src/users.js +++ b/src/users.js @@ -7,21 +7,17 @@ const os = require('os'); // Express and other dependencies const storage = require('node-persist'); const express = require('express'); -const uuid = require('uuid'); const mime = require('mime-types'); -const slugify = require('slugify').default; -// Local imports -const { jsonParser } = require('./express-common'); const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, DEFAULT_AVATAR } = require('./constants'); -const { getConfigValue, color, delay, setConfigValue, Cache } = require('./util'); +const { getConfigValue, color, delay, setConfigValue } = require('./util'); const { readSecret, writeSecret } = require('./endpoints/secrets'); -const { checkForNewContent } = require('./endpoints/content-manager'); const KEY_PREFIX = 'user:'; const DATA_ROOT = getConfigValue('dataRoot', './data'); const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); -const MFA_CACHE = new Cache(5 * 60 * 1000); +const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64'); + /** * Cache for user directories. * @type {Map} @@ -381,7 +377,7 @@ function getPasswordHash(password, salt) { */ function getCsrfSecret(request) { if (!request || !request.user) { - throw new Error('Request object is required to get the CSRF secret.'); + return ANON_CSRF_SECRET; } let csrfSecret = readSecret(request.user.directories, STORAGE_KEYS.csrfSecret); @@ -600,301 +596,24 @@ router.use('/user/images/*', createRouteHandler(req => req.user.directories.user router.use('/user/files/*', createRouteHandler(req => req.user.directories.files)); router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.user.directories.extensions)); -const publicEndpoints = express.Router(); - -publicEndpoints.get('/list', async (_request, response) => { - /** @type {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); -}); - -publicEndpoints.post('/login', jsonParser, async (request, response) => { - if (!request.body.handle) { - console.log('Login failed: Missing required fields'); - return response.status(400).json({ error: 'Missing required fields' }); - } - - /** @type {User} */ - const user = await storage.getItem(toKey(request.body.handle)); - - if (!user) { - console.log('Login failed: User not found'); - return response.status(401).json({ error: 'User not found' }); - } - - 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(401).json({ error: 'Incorrect password' }); - } - - if (!request.session) { - console.error('Session not available'); - return response.sendStatus(500); - } - - request.session.handle = user.handle; - console.log('Login successful:', user.handle, request.session); - return response.json({ handle: user.handle }); -}); - -publicEndpoints.post('/recover-step1', jsonParser, async (request, response) => { - if (!request.body.handle) { - console.log('Recover step 1 failed: Missing required fields'); - return response.status(400).json({ error: 'Missing required fields' }); - } - - /** @type {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); -}); - -publicEndpoints.post('/recover-step2', jsonParser, async (request, response) => { - 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 {User} */ - const user = await storage.getItem(toKey(request.body.handle)); - - 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) { - console.log('Recover step 2 failed: Incorrect code'); - return response.status(401).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); - } - - return response.sendStatus(204); -}); - -const authenticatedEndpoints = express.Router(); - -authenticatedEndpoints.post('/logout', async (request, response) => { - if (!request.session) { - console.error('Session not available'); - return response.sendStatus(500); - } - - request.session.handle = null; - return response.sendStatus(204); -}); - -authenticatedEndpoints.get('/me', async (request, response) => { - if (!request.user) { - return response.sendStatus(401); - } - - const user = request.user.profile; - const viewModel = { - handle: user.handle, - name: user.name, - avatar: getUserAvatar(user.handle), - admin: user.admin, - password: !!user.password, - }; - - return response.json(viewModel); -}); - -authenticatedEndpoints.post('/change-password', jsonParser, async (request, response) => { - if (!request.body.handle || !request.body.oldPassword || !request.body.newPassword) { - console.log('Change password failed: Missing required fields'); - return response.status(400).json({ error: 'Missing required fields' }); - } - - /** @type {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 (user.password && user.password !== getPasswordHash(request.body.oldPassword, user.salt)) { - console.log('Change password failed: Incorrect password'); - return response.status(401).json({ error: 'Incorrect password' }); - } - - const salt = getPasswordSalt(); - user.password = getPasswordHash(request.body.newPassword, salt); - user.salt = salt; - await storage.setItem(toKey(request.body.handle), user); - return response.sendStatus(204); -}); - -const adminEndpoints = express.Router(); - -adminEndpoints.post('/get', requireAdminMiddleware, jsonParser, async (request, response) => { - /** @type {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); -}); - -adminEndpoints.post('/disable', requireAdminMiddleware, jsonParser, async (request, response) => { - 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 {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); -}); - -adminEndpoints.post('/enable', requireAdminMiddleware, jsonParser, async (request, response) => { - if (!request.body.handle) { - console.log('Enable user failed: Missing required fields'); - return response.status(400).json({ error: 'Missing required fields' }); - } - - /** @type {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); -}); - -adminEndpoints.post('/create', requireAdminMiddleware, jsonParser, async (request, response) => { - 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 = slugify(request.body.handle, { lower: true, trim: true }); - - 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 = { - uuid: uuid.v4(), - 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); - const directories = await ensurePublicDirectoriesExist(); - await checkForNewContent(directories); - return response.json({ handle: newUser.handle }); -}); - module.exports = { + KEY_PREFIX, + toKey, initUserStorage, ensurePublicDirectoriesExist, getAllUserHandles, getUserDirectories, setUserDataMiddleware, requireLoginMiddleware, + requireAdminMiddleware, migrateUserData, + getPasswordSalt, + getPasswordHash, getCsrfSecret, getCookieSecret, getCookieSessionName, - router, - publicEndpoints, - authenticatedEndpoints, - adminEndpoints, + getUserAvatar, shouldRedirectToLogin, tryAutoLogin, + router, }; From 31cc6e51b5261f4dc7a79e7dd457be236fa8af6e Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Tue, 9 Apr 2024 22:43:47 +0300 Subject: [PATCH 18/46] Add user backups download --- package-lock.json | 807 +++++++++++++++++++++++++++- package.json | 1 + public/scripts/templates/admin.html | 27 +- public/scripts/user.js | 94 ++++ src/endpoints/users-admin.js | 231 +++++--- src/endpoints/users-private.js | 33 +- src/users.js | 40 +- 7 files changed, 1137 insertions(+), 96 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2b7c6c0d6..d1f8b11cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@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", @@ -228,6 +229,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", @@ -647,6 +737,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" @@ -892,6 +991,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, @@ -905,6 +1219,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" @@ -918,11 +1237,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" }, @@ -1212,6 +1541,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", @@ -1393,9 +1813,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", @@ -1581,6 +2088,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" @@ -1828,6 +2340,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" }, @@ -1903,6 +2423,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, @@ -2026,6 +2551,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", @@ -2206,6 +2746,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, @@ -2458,6 +3003,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", @@ -2474,7 +3030,6 @@ }, "node_modules/isexe": { "version": "2.0.0", - "dev": true, "license": "ISC" }, "node_modules/isomorphic-fetch": { @@ -2485,6 +3040,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", @@ -2605,6 +3177,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, @@ -2669,6 +3252,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", @@ -2757,6 +3348,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" @@ -2842,6 +3441,14 @@ "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", @@ -3099,12 +3706,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" @@ -3288,6 +3909,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", @@ -3369,6 +3995,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" @@ -3529,7 +4182,6 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -3540,7 +4192,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3634,6 +4285,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", @@ -3653,6 +4316,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", @@ -3663,6 +4340,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", @@ -3689,6 +4378,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, @@ -3892,7 +4591,6 @@ }, "node_modules/which": { "version": "2.0.2", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -3919,6 +4617,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" @@ -4047,6 +4762,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 ff6de4e59..0071f3337 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "@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", diff --git a/public/scripts/templates/admin.html b/public/scripts/templates/admin.html index 4db762b76..91bdc0b1e 100644 --- a/public/scripts/templates/admin.html +++ b/public/scripts/templates/admin.html @@ -37,9 +37,24 @@
- - - + + + + + +
@@ -56,15 +71,15 @@
Display Name: - +
Password: - +
Confirm Password: - +
This will create a new subfolder in the /data/ directory with the user's handle as the folder name. diff --git a/public/scripts/user.js b/public/scripts/user.js index b51a463c3..31f6285f5 100644 --- a/public/scripts/user.js +++ b/public/scripts/user.js @@ -116,6 +116,57 @@ async function disableUser(handle, callback) { } } +/** + * 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 @@ -168,6 +219,43 @@ async function createUser(form, callback) { } } +/** + * 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); + } +} + async function openAdminPanel() { async function renderUsers() { const users = await getUsers(); @@ -184,6 +272,12 @@ async function openAdminPanel() { 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('.userBackupButton').on('click', function () { + $(this).addClass('disabled').off('click'); + backupUserData(user.handle, renderUsers); + }); template.find('.usersList').append(userBlock); } } diff --git a/src/endpoints/users-admin.js b/src/endpoints/users-admin.js index d594b595e..0310d3b1d 100644 --- a/src/endpoints/users-admin.js +++ b/src/endpoints/users-admin.js @@ -19,103 +19,176 @@ const { const router = express.Router(); router.post('/get', requireAdminMiddleware, jsonParser, async (request, response) => { - /** @type {import('../users').User[]} */ - const users = await storage.values(x => x.key.startsWith(KEY_PREFIX)); + 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, - })); + 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); + return response.json(viewModels); + } catch (error) { + console.error('User list failed:', error); + return response.sendStatus(500); + } }); router.post('/disable', requireAdminMiddleware, jsonParser, async (request, response) => { - if (!request.body.handle) { - console.log('Disable user failed: Missing required fields'); - return response.status(400).json({ error: 'Missing required fields' }); + 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); } - - 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); }); router.post('/enable', requireAdminMiddleware, jsonParser, async (request, response) => { - if (!request.body.handle) { - console.log('Enable user failed: Missing required fields'); - return response.status(400).json({ error: 'Missing required fields' }); + 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); } +}); - /** @type {import('../users').User} */ - const user = await storage.getItem(toKey(request.body.handle)); +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' }); + } - if (!user) { - console.log('Enable user failed: User not found'); - return response.status(404).json({ error: 'User not found' }); + /** @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); } +}); - user.enabled = true; - await storage.setItem(toKey(request.body.handle), user); - return response.sendStatus(204); +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) => { - if (!request.body.handle || !request.body.name) { - console.log('Create user failed: Missing required fields'); - return response.status(400).json({ error: 'Missing required fields' }); + 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 = slugify(request.body.handle, { lower: true, trim: true }); + + 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 = { + uuid: uuid.v4(), + 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); } - - const handles = await getAllUserHandles(); - const handle = slugify(request.body.handle, { lower: true, trim: true }); - - 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 = { - uuid: uuid.v4(), - 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 }); }); module.exports = { diff --git a/src/endpoints/users-private.js b/src/endpoints/users-private.js index 17fe99ad2..0b95eb94c 100644 --- a/src/endpoints/users-private.js +++ b/src/endpoints/users-private.js @@ -1,7 +1,7 @@ const storage = require('node-persist'); const express = require('express'); const { jsonParser } = require('../express-common'); -const { getUserAvatar, toKey, getPasswordHash, getPasswordSalt } = require('../users'); +const { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive } = require('../users'); const router = express.Router(); @@ -44,11 +44,16 @@ router.get('/me', async (request, response) => { router.post('/change-password', jsonParser, async (request, response) => { try { - if (!request.body.handle || !request.body.oldPassword || !request.body.newPassword) { + 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)); @@ -62,7 +67,8 @@ router.post('/change-password', jsonParser, async (request, response) => { return response.status(403).json({ error: 'User is disabled' }); } - if (user.password && user.password !== getPasswordHash(request.body.oldPassword, user.salt)) { + const isAdminChange = request.user.profile.admin && request.body.handle !== request.user.profile.handle; + if (!isAdminChange && user.password && user.password !== getPasswordHash(request.body.oldPassword, user.salt)) { console.log('Change password failed: Incorrect password'); return response.status(401).json({ error: 'Incorrect password' }); } @@ -78,6 +84,27 @@ router.post('/change-password', jsonParser, async (request, response) => { } }); +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); + } +}); + module.exports = { router, }; diff --git a/src/users.js b/src/users.js index d64655d0a..0a516e9cf 100644 --- a/src/users.js +++ b/src/users.js @@ -8,9 +8,10 @@ const os = require('os'); 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 } = require('./constants'); -const { getConfigValue, color, delay, setConfigValue } = require('./util'); +const { getConfigValue, color, delay, setConfigValue, generateTimestamp } = require('./util'); const { readSecret, writeSecret } = require('./endpoints/secrets'); const KEY_PREFIX = 'user:'; @@ -584,6 +585,42 @@ function requireAdminMiddleware(request, response, next) { 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(); +} + /** * Express router for serving files from the user's directories. */ @@ -614,6 +651,7 @@ module.exports = { getCookieSessionName, getUserAvatar, shouldRedirectToLogin, + createBackupArchive, tryAutoLogin, router, }; From 189d09683428ec6f66fb7f15d08f93682cdf3aed Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Wed, 10 Apr 2024 00:01:03 +0300 Subject: [PATCH 19/46] Admin change password flow --- public/css/accounts.css | 5 ++ public/scripts/popup.js | 2 +- public/scripts/templates/admin.html | 77 ++++++++++++-------- public/scripts/templates/changePassword.html | 14 ++++ public/scripts/user.js | 57 ++++++++++++++- public/style.css | 1 + src/endpoints/users-admin.js | 28 +++++++ src/endpoints/users-private.js | 15 ++-- 8 files changed, 159 insertions(+), 40 deletions(-) create mode 100644 public/css/accounts.css create mode 100644 public/scripts/templates/changePassword.html 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/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/templates/admin.html b/public/scripts/templates/admin.html index 91bdc0b1e..987f7c043 100644 --- a/public/scripts/templates/admin.html +++ b/public/scripts/templates/admin.html @@ -1,10 +1,10 @@
@@ -19,41 +19,51 @@

- @userhandle +  
- Role: + Role: - Status: - Status + Status: +   - Created: - Date + Created: +  
-
-
diff --git a/public/scripts/templates/changePassword.html b/public/scripts/templates/changePassword.html new file mode 100644 index 000000000..bbebf088f --- /dev/null +++ b/public/scripts/templates/changePassword.html @@ -0,0 +1,14 @@ +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/public/scripts/user.js b/public/scripts/user.js index 31f6285f5..deb80eb50 100644 --- a/public/scripts/user.js +++ b/public/scripts/user.js @@ -1,4 +1,5 @@ -import { callPopup, getRequestHeaders, renderTemplate } from '../script.js'; +import { getRequestHeaders, renderTemplate } from '../script.js'; +import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js'; /** * @type {import('../../src/users.js').User} Logged in user @@ -256,6 +257,57 @@ async function backupUserData(handle, callback) { } } +/** + * 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 = $(renderTemplate('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); + } +} + async function openAdminPanel() { async function renderUsers() { const users = await getUsers(); @@ -274,6 +326,7 @@ async function openAdminPanel() { 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('.userBackupButton').on('click', function () { $(this).addClass('disabled').off('click'); backupUserData(user.handle, renderUsers); @@ -303,7 +356,7 @@ async function openAdminPanel() { }); }); - callPopup(template, 'text', '', { okButton: 'Close', wide: true, large: true, allowVerticalScrolling: true, allowHorizontalScrolling: false }); + callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: true, large: true, allowVerticalScrolling: true, allowHorizontalScrolling: false }); renderUsers(); } diff --git a/public/style.css b/public/style.css index d9818d537..8953009ee 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%; diff --git a/src/endpoints/users-admin.js b/src/endpoints/users-admin.js index 0310d3b1d..e3e45afe2 100644 --- a/src/endpoints/users-admin.js +++ b/src/endpoints/users-admin.js @@ -1,3 +1,4 @@ +const fsPromises = require('fs').promises; const storage = require('node-persist'); const express = require('express'); const slugify = require('slugify').default; @@ -191,6 +192,33 @@ router.post('/create', requireAdminMiddleware, jsonParser, async (request, respo } }); +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' }); + } + + 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); + } +}); + module.exports = { router, }; diff --git a/src/endpoints/users-private.js b/src/endpoints/users-private.js index 0b95eb94c..0ed3a7d36 100644 --- a/src/endpoints/users-private.js +++ b/src/endpoints/users-private.js @@ -67,15 +67,20 @@ router.post('/change-password', jsonParser, async (request, response) => { return response.status(403).json({ error: 'User is disabled' }); } - const isAdminChange = request.user.profile.admin && request.body.handle !== request.user.profile.handle; - if (!isAdminChange && user.password && user.password !== getPasswordHash(request.body.oldPassword, user.salt)) { + 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(401).json({ error: 'Incorrect password' }); } - const salt = getPasswordSalt(); - user.password = getPasswordHash(request.body.newPassword, salt); - user.salt = salt; + 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) { From 4f3780979edf11d3edfea58985a1881f88ce9201 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Wed, 10 Apr 2024 01:01:32 +0300 Subject: [PATCH 20/46] Admin delete user flow --- public/scripts/templates/admin.html | 2 + public/scripts/templates/deleteUser.html | 24 ++++++++++++ public/scripts/user.js | 50 ++++++++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 public/scripts/templates/deleteUser.html diff --git a/public/scripts/templates/admin.html b/public/scripts/templates/admin.html index 987f7c043..06d8f54a5 100644 --- a/public/scripts/templates/admin.html +++ b/public/scripts/templates/admin.html @@ -77,10 +77,12 @@
User Handle: + *
Display Name: + *
diff --git a/public/scripts/templates/deleteUser.html b/public/scripts/templates/deleteUser.html new file mode 100644 index 000000000..35f4cd1fc --- /dev/null +++ b/public/scripts/templates/deleteUser.html @@ -0,0 +1,24 @@ +
+

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

+
+ Deleting: + +
+ +
+
+ Warning: + This action is irreversible. +
+
+ + +
+
diff --git a/public/scripts/user.js b/public/scripts/user.js index deb80eb50..3f7d565cb 100644 --- a/public/scripts/user.js +++ b/public/scripts/user.js @@ -308,6 +308,55 @@ async function changePassword(handle, 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 = $(renderTemplate('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); + } +} + async function openAdminPanel() { async function renderUsers() { const users = await getUsers(); @@ -327,6 +376,7 @@ async function openAdminPanel() { 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('.userBackupButton').on('click', function () { $(this).addClass('disabled').off('click'); backupUserData(user.handle, renderUsers); From accebd00f5825e1268e25f7f2c8b95af388f50b0 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Wed, 10 Apr 2024 01:29:35 +0300 Subject: [PATCH 21/46] Stricter handle cleanup --- public/scripts/templates/deleteUser.html | 8 +++++--- src/constants.js | 2 +- src/endpoints/users-admin.js | 7 ++++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/public/scripts/templates/deleteUser.html b/public/scripts/templates/deleteUser.html index 35f4cd1fc..6932f3b99 100644 --- a/public/scripts/templates/deleteUser.html +++ b/public/scripts/templates/deleteUser.html @@ -16,9 +16,11 @@ This action is irreversible.
-
diff --git a/src/constants.js b/src/constants.js index 2c5bd5e7d..929e7e7cd 100644 --- a/src/constants.js +++ b/src/constants.js @@ -50,7 +50,7 @@ const DEFAULT_USER = Object.freeze({ uuid: '00000000-0000-0000-0000-000000000000', handle: 'user0', name: 'User', - created: 0, + created: Date.now(), password: '', admin: true, enabled: true, diff --git a/src/endpoints/users-admin.js b/src/endpoints/users-admin.js index e3e45afe2..764f5263e 100644 --- a/src/endpoints/users-admin.js +++ b/src/endpoints/users-admin.js @@ -157,7 +157,12 @@ router.post('/create', requireAdminMiddleware, jsonParser, async (request, respo } const handles = await getAllUserHandles(); - const handle = slugify(request.body.handle, { lower: true, trim: true }); + const handle = slugify(request.body.handle, { lower: true, trim: true, remove: /[^a-z0-9-]/g }); + + 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'); From 8f1d2e01632b543e2ae8ccdc4841de4987a44a76 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Wed, 10 Apr 2024 01:35:59 +0300 Subject: [PATCH 22/46] Generic popup as a notarget for panel closing --- public/script.js | 1 + 1 file changed, 1 insertion(+) diff --git a/public/script.js b/public/script.js index db6a51041..27a9942dc 100644 --- a/public/script.js +++ b/public/script.js @@ -10175,6 +10175,7 @@ jQuery(async function () { '#character_cross', '#avatar-and-name-block', '#shadow_popup', + '.shadow_popup', '#world_popup', '.ui-widget', '.text_pole', From 09b44075ed3bfe4cd0482c64ff3d59b6f73986c9 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Wed, 10 Apr 2024 02:09:38 +0300 Subject: [PATCH 23/46] User profile view --- public/scripts/templates/admin.html | 3 - public/scripts/templates/userProfile.html | 80 +++++++++++++++++++++++ public/scripts/user.js | 28 +++++++- src/endpoints/users-private.js | 1 + src/users.js | 1 + 5 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 public/scripts/templates/userProfile.html diff --git a/public/scripts/templates/admin.html b/public/scripts/templates/admin.html index 06d8f54a5..dec8b3bf1 100644 --- a/public/scripts/templates/admin.html +++ b/public/scripts/templates/admin.html @@ -72,9 +72,6 @@