Compare commits
10 Commits
4ca9863f38
...
51ad27fb86
| Author | SHA1 | Date | |
|---|---|---|---|
| 51ad27fb86 | |||
| 982dfec022 | |||
| 95ca4315bd | |||
| 97392a4ca0 | |||
| e5ae782705 | |||
| 9d61bbc3ed | |||
| 3eb3861596 | |||
| c325c6d8e9 | |||
| 91c40280ed | |||
| b2fa6a0afb |
+18
-3
@@ -58,7 +58,7 @@ ssl:
|
||||
# -- SECURITY CONFIGURATION --
|
||||
# Toggle whitelist mode
|
||||
whitelistMode: true
|
||||
# Whitelist will also verify IP in X-Forwarded-For / X-Real-IP headers
|
||||
# When enabled, whitelist will also verify IP in headers enabled in `forwardedHeaders` section.
|
||||
enableForwardedWhitelist: true
|
||||
# Whitelist of allowed IP addresses
|
||||
whitelist:
|
||||
@@ -189,9 +189,24 @@ logging:
|
||||
minLogLevel: 0
|
||||
# -- RATE LIMITING CONFIGURATION --
|
||||
rateLimiting:
|
||||
# Use X-Real-IP header instead of socket IP for rate limiting
|
||||
# Only enable this if you are using a properly configured reverse proxy (like Nginx/traefik/Caddy)
|
||||
# Use any of the enabled headers in the `forwardedHeaders` section to identify the client IP for rate limiting.
|
||||
# If disabled, only the socket IP will be used, which may not work correctly if you are behind a reverse proxy.
|
||||
preferRealIpHeader: false
|
||||
# Set the maximum number of allowed failed basic authentication attempts before rate limiting is applied. Set to 0 to disable rate limiting for basic auth.
|
||||
basicAuthMaxAttempts: 5
|
||||
# Set the maximum number of allowed failed account login attempts before rate limiting is applied. Set to 0 to disable rate limiting for account logins.
|
||||
accountsLoginMaxAttempts: 5
|
||||
# Set the maximum number of allowed failed account recovery attempts before rate limiting is applied. Set to 0 to disable rate limiting for account recovery.
|
||||
accountsRecoverMaxAttempts: 5
|
||||
# Set to true to enable support for real IPs in certain request headers for features like IP whitelisting, rate limiting and access logging.
|
||||
# Only change if you are sure that you use a correctly configured reverse proxy, otherwise this may lead to IP spoofing.
|
||||
forwardedHeaders:
|
||||
# X-Real-IP header (common with Nginx and Caddy)
|
||||
xRealIp: true
|
||||
# X-Forwarded-For header (common with many proxies, but may contain multiple IPs - only the first one will be used)
|
||||
xForwardedFor: true
|
||||
# CF-Connecting-IP header (used by Cloudflare Tunnels)
|
||||
cfConnectingIp: false
|
||||
|
||||
## BACKUP CONFIGURATION
|
||||
backups:
|
||||
|
||||
Vendored
+4
@@ -41,6 +41,10 @@ declare global {
|
||||
* Authenticated user handle.
|
||||
*/
|
||||
handle: string | null;
|
||||
/**
|
||||
* Account version tag: shake256 derivative of password hash and salt.
|
||||
*/
|
||||
version: string | null;
|
||||
/**
|
||||
* Last time the session was extended.
|
||||
*/
|
||||
|
||||
Generated
+70
-64
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sillytavern",
|
||||
"version": "1.17.0",
|
||||
"version": "1.18.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sillytavern",
|
||||
"version": "1.17.0",
|
||||
"version": "1.18.0",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "^4.4.4",
|
||||
@@ -45,7 +45,7 @@
|
||||
"bowser": "^2.12.1",
|
||||
"bytes": "^3.1.2",
|
||||
"chalk": "^5.6.0",
|
||||
"chevrotain": "^11.1.1",
|
||||
"chevrotain": "^11.2.0",
|
||||
"command-exists": "^1.2.9",
|
||||
"compression": "^1.8.1",
|
||||
"cookie-parser": "^1.4.6",
|
||||
@@ -54,7 +54,7 @@
|
||||
"crc": "^4.3.2",
|
||||
"csrf-sync": "^4.2.1",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"dompurify": "^3.2.6",
|
||||
"dompurify": "^3.4.2",
|
||||
"droll": "^0.2.1",
|
||||
"env-paths": "^3.0.0",
|
||||
"express": "^4.21.0",
|
||||
@@ -76,7 +76,7 @@
|
||||
"isomorphic-git": "^1.36.3",
|
||||
"js-sha256": "^0.11.1",
|
||||
"localforage": "^1.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "^4.18.1",
|
||||
"mime-types": "^3.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"morphdom": "^2.7.7",
|
||||
@@ -110,7 +110,7 @@
|
||||
"sillytavern": "src/server-global.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chevrotain/types": "^11.0.3",
|
||||
"@chevrotain/types": "^11.2.0",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/command-exists": "^1.2.3",
|
||||
@@ -124,7 +124,7 @@
|
||||
"@types/jquery-cropper": "^1.0.4",
|
||||
"@types/jquery.transit": "^0.9.33",
|
||||
"@types/jqueryui": "^1.12.24",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^18.19.84",
|
||||
@@ -179,42 +179,42 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/cst-dts-gen": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.1.tgz",
|
||||
"integrity": "sha512-fRHyv6/f542qQqiRGalrfJl/evD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw==",
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.2.0.tgz",
|
||||
"integrity": "sha512-ssJFvn/UXhQQeICw3SR/fZPmYVj+JM2mP+Lx7bZ51cOeHaMWOKp3AUMuyM3QR82aFFXTfcAp67P5GpPjGmbZWQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/gast": "11.1.1",
|
||||
"@chevrotain/types": "11.1.1",
|
||||
"@chevrotain/gast": "11.2.0",
|
||||
"@chevrotain/types": "11.2.0",
|
||||
"lodash-es": "4.17.23"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/gast": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.1.tgz",
|
||||
"integrity": "sha512-Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+dfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg==",
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.2.0.tgz",
|
||||
"integrity": "sha512-c+KoD6eSI1xjAZZoNUW+V0l13UEn+a4ShmUrjIKs1BeEWCji0Kwhmqn5FSx1K4BhWL7IQKlV7wLR4r8lLArORQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/types": "11.1.1",
|
||||
"@chevrotain/types": "11.2.0",
|
||||
"lodash-es": "4.17.23"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/regexp-to-ast": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.1.tgz",
|
||||
"integrity": "sha512-ctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+82C2uylg/TEwFRgwLmbhlln4qkmDyteg==",
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.2.0.tgz",
|
||||
"integrity": "sha512-lG73pBFqbXODTbXhdZwv0oyUaI+3Irm+uOv5/W79lI3g5hasYaJnVJOm3H2NkhA0Ef4XLBU4Scr7TJDJwgFkAw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/types": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.1.tgz",
|
||||
"integrity": "sha512-wb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw==",
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.2.0.tgz",
|
||||
"integrity": "sha512-vBMSj/lz/LqolbGQEHB0tlpW5BnljHVtp+kzjQfQU+5BtGMTuZCPVgaAjtKvQYXnHb/8i/02Kii00y0tsuwfsw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/utils": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.1.tgz",
|
||||
"integrity": "sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ==",
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.2.0.tgz",
|
||||
"integrity": "sha512-+7whECg4yNWHottjvr2To2BRxL4XJVjIyyv5J4+bJ0iMOVU8j/8n1qPDLZS/90W/BObDR8VNL46lFbzY/Hosmw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@es-joy/jsdoccomment": {
|
||||
@@ -2011,9 +2011,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz",
|
||||
"integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
|
||||
"version": "4.17.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
|
||||
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2791,9 +2791,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -3013,14 +3013,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
|
||||
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/b4a": {
|
||||
@@ -3075,9 +3084,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/basic-ftp": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
|
||||
"integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==",
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
|
||||
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -3182,9 +3191,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3494,16 +3503,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.1.tgz",
|
||||
"integrity": "sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ==",
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.2.0.tgz",
|
||||
"integrity": "sha512-mHCHTxM51nCklUw9RzRVc0DLjAh/SAUPM4k/zMInlTIo25ldWXOZoPt7XEIk/LwoT4lFVmJcu9g5MHtx371x3A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/cst-dts-gen": "11.1.1",
|
||||
"@chevrotain/gast": "11.1.1",
|
||||
"@chevrotain/regexp-to-ast": "11.1.1",
|
||||
"@chevrotain/types": "11.1.1",
|
||||
"@chevrotain/utils": "11.1.1",
|
||||
"@chevrotain/cst-dts-gen": "11.2.0",
|
||||
"@chevrotain/gast": "11.2.0",
|
||||
"@chevrotain/regexp-to-ast": "11.2.0",
|
||||
"@chevrotain/types": "11.2.0",
|
||||
"@chevrotain/utils": "11.2.0",
|
||||
"lodash-es": "4.17.23"
|
||||
}
|
||||
},
|
||||
@@ -4313,13 +4322,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
|
||||
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@@ -5199,9 +5205,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -6610,9 +6616,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
@@ -7922,9 +7928,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/readdir-glob/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
|
||||
+6
-6
@@ -36,7 +36,7 @@
|
||||
"bowser": "^2.12.1",
|
||||
"bytes": "^3.1.2",
|
||||
"chalk": "^5.6.0",
|
||||
"chevrotain": "^11.1.1",
|
||||
"chevrotain": "^11.2.0",
|
||||
"command-exists": "^1.2.9",
|
||||
"compression": "^1.8.1",
|
||||
"cookie-parser": "^1.4.6",
|
||||
@@ -45,7 +45,7 @@
|
||||
"crc": "^4.3.2",
|
||||
"csrf-sync": "^4.2.1",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"dompurify": "^3.2.6",
|
||||
"dompurify": "^3.4.2",
|
||||
"droll": "^0.2.1",
|
||||
"env-paths": "^3.0.0",
|
||||
"express": "^4.21.0",
|
||||
@@ -67,7 +67,7 @@
|
||||
"isomorphic-git": "^1.36.3",
|
||||
"js-sha256": "^0.11.1",
|
||||
"localforage": "^1.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "^4.18.1",
|
||||
"mime-types": "^3.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"morphdom": "^2.7.7",
|
||||
@@ -115,7 +115,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/SillyTavern/SillyTavern.git"
|
||||
},
|
||||
"version": "1.17.0",
|
||||
"version": "1.18.0",
|
||||
"scripts": {
|
||||
"init": "node src/server-init.js",
|
||||
"start": "node server.js",
|
||||
@@ -139,7 +139,7 @@
|
||||
},
|
||||
"main": "server.js",
|
||||
"devDependencies": {
|
||||
"@chevrotain/types": "^11.0.3",
|
||||
"@chevrotain/types": "^11.2.0",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/command-exists": "^1.2.3",
|
||||
@@ -153,7 +153,7 @@
|
||||
"@types/jquery-cropper": "^1.0.4",
|
||||
"@types/jquery.transit": "^0.9.33",
|
||||
"@types/jqueryui": "^1.12.24",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^18.19.84",
|
||||
|
||||
+20
-4
@@ -3929,7 +3929,7 @@ export function createRawPrompt(prompt, api, instructOverride, quietToLoud, syst
|
||||
* @prop {number} [responseLength] Maximum response length. If unset, the global default value is used.
|
||||
* @prop {boolean} [trimNames] Whether to allow trimming "{{user}}:" and "{{char}}:" from the response.
|
||||
* @prop {string} [prefill] An optional prefill for the prompt.
|
||||
* @prop {object} [jsonSchema] JSON schema to use for the structured generation. Usually requires a special instruction.
|
||||
* @prop {JsonSchema} [jsonSchema] JSON schema to use for the structured generation. Usually requires a special instruction.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -4041,7 +4041,7 @@ export async function generateRawData({ prompt = '', api = null, instructOverrid
|
||||
}
|
||||
|
||||
if (jsonSchema) {
|
||||
return extractJsonFromData(data, { mainApi: api });
|
||||
return extractJsonFromData(data, { mainApi: api, returnInvalidJson: jsonSchema.returnInvalid });
|
||||
}
|
||||
|
||||
return data;
|
||||
@@ -4204,6 +4204,7 @@ function removeLastMessage() {
|
||||
* @property {object} value JSON schema value.
|
||||
* @property {string} [description] Description of the schema.
|
||||
* @property {boolean} [strict] If true, the schema will be used in strict mode, meaning that only the fields defined in the schema will be allowed.
|
||||
* @property {boolean} [returnInvalid] If true, a string that can't be parsed as a JSON will be returned as is, instead of an empty object.
|
||||
*
|
||||
* @typedef {object} GenerateOptions
|
||||
* @property {boolean} [automatic_trigger] If the generation was triggered automatically (e.g. group auto mode).
|
||||
@@ -5419,7 +5420,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
|
||||
|
||||
if (jsonSchema) {
|
||||
unblockGeneration(type);
|
||||
return extractJsonFromData(data);
|
||||
return extractJsonFromData(data, { returnInvalidJson: jsonSchema.returnInvalid ?? false });
|
||||
}
|
||||
|
||||
//const getData = await response.json();
|
||||
@@ -6242,9 +6243,13 @@ export function extractMessageFromData(data, activeApi = null) {
|
||||
/**
|
||||
* Extracts JSON from the response data.
|
||||
* @param {object} data Response data
|
||||
* @param {object} [options] Extraction options
|
||||
* @param {string} [options.mainApi] Main API to use
|
||||
* @param {string} [options.chatCompletionSource] Chat completion source
|
||||
* @param {boolean} [options.returnInvalidJson=false] Whether to return the raw JSON string even if it fails to parse
|
||||
* @returns {string} Extracted JSON string from the response data
|
||||
*/
|
||||
export function extractJsonFromData(data, { mainApi = null, chatCompletionSource = null } = {}) {
|
||||
export function extractJsonFromData(data, { mainApi = null, chatCompletionSource = null, returnInvalidJson = false } = {}) {
|
||||
mainApi = mainApi ?? main_api;
|
||||
chatCompletionSource = chatCompletionSource ?? oai_settings.chat_completion_source;
|
||||
|
||||
@@ -6267,6 +6272,9 @@ export function extractJsonFromData(data, { mainApi = null, chatCompletionSource
|
||||
break;
|
||||
case chat_completion_sources.PERPLEXITY:
|
||||
result = tryParse(removeReasoningFromString(text));
|
||||
if (!result && returnInvalidJson) {
|
||||
return text;
|
||||
}
|
||||
break;
|
||||
case chat_completion_sources.VERTEXAI:
|
||||
case chat_completion_sources.MAKERSUITE:
|
||||
@@ -6287,6 +6295,9 @@ export function extractJsonFromData(data, { mainApi = null, chatCompletionSource
|
||||
case chat_completion_sources.ZAI:
|
||||
default:
|
||||
result = tryParse(text);
|
||||
if (!result && returnInvalidJson) {
|
||||
return text;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} break;
|
||||
@@ -7957,6 +7968,11 @@ export async function getSettings(initLoaderHandle = null) {
|
||||
Object.assign(extension_settings, (settings.extension_settings ?? {}));
|
||||
$('#third_party_extension_button').addClass('disabled');
|
||||
$('#extensions_details').addClass('disabled');
|
||||
$('#extensions_connect').addClass('disabled');
|
||||
$('#extensions_notify_updates').attr('disabled', 'disabled');
|
||||
$('#extensions_autoconnect').attr('disabled', 'disabled');
|
||||
$('#extensions_url').attr('disabled', 'disabled');
|
||||
$('#extensions_api_key').attr('disabled', 'disabled');
|
||||
}
|
||||
|
||||
firstRun = !!settings.firstRun;
|
||||
|
||||
@@ -51,6 +51,7 @@ import EventSourceStream from './sse-stream.js';
|
||||
* @property {string} [reverse_proxy] - Optional reverse proxy URL
|
||||
* @property {string} [proxy_password] - Optional proxy password
|
||||
* @property {string} [custom_prompt_post_processing] - Optional custom prompt post-processing
|
||||
* @property {import('../script.js').JsonSchema} [json_schema] - Optional JSON schema for structured generation
|
||||
*/
|
||||
|
||||
/** @typedef {Record<string, any> & ChatCompletionPayloadBase} ChatCompletionPayload */
|
||||
|
||||
+237
-109
@@ -1,9 +1,9 @@
|
||||
import { DOMPurify, Popper } from '../lib.js';
|
||||
import { Popper } from '../lib.js';
|
||||
|
||||
import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js';
|
||||
import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js';
|
||||
import { renderTemplate, renderTemplateAsync } from './templates.js';
|
||||
import { delay, deleteValueByPath, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js';
|
||||
import { delay, deleteValueByPath, equalsIgnoreCaseAndAccents, escapeHtml, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js';
|
||||
import { getContext } from './st-context.js';
|
||||
import { isAdmin } from './user.js';
|
||||
import { addLocaleData, getCurrentLocale, t } from './i18n.js';
|
||||
@@ -279,6 +279,18 @@ export async function doExtrasFetch(endpoint, args = {}) {
|
||||
return await fetch(endpoint, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a CSS selector for an extension based on its name, allowing omission of a common prefix.
|
||||
* @param {string} name Name of the extension, with or without the "third-party" prefix
|
||||
* @param {object} [options] Optional parameters
|
||||
* @param {string} [options.prefix] Optional prefix to ignore when generating the selector (e.g. "third-party")
|
||||
* @returns {string} CSS selector for the extension, with the prefix removed if it was present and specified in options
|
||||
*/
|
||||
function getNameSelector(name, { prefix = 'third-party' } = {}) {
|
||||
const nameWithoutPrefix = prefix && name.startsWith(prefix) ? name.slice(prefix.length) : name;
|
||||
return CSS.escape(nameWithoutPrefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers extensions from the API.
|
||||
* @returns {Promise<{name: string, type: string}[]>}
|
||||
@@ -356,7 +368,7 @@ function onToggleAllExtensions(extensionsToToggle, toggleContainer) {
|
||||
}
|
||||
|
||||
toggleContainer
|
||||
.find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`)
|
||||
.find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`)
|
||||
.prop('checked', enable)
|
||||
.toggleClass('toggle_enable', !enable)
|
||||
.toggleClass('toggle_disable', enable)
|
||||
@@ -865,7 +877,7 @@ function addExtensionLocale(name, manifest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates HTML string for displaying an extension in the UI.
|
||||
* Generates an element for displaying an extension in the UI.
|
||||
*
|
||||
* @param {string} name - The name of the extension.
|
||||
* @param {object} manifest - The manifest of the extension.
|
||||
@@ -873,98 +885,180 @@ function addExtensionLocale(name, manifest) {
|
||||
* @param {boolean} isDisabled - Whether the extension is disabled or not.
|
||||
* @param {boolean} isExternal - Whether the extension is external or not.
|
||||
* @param {string} checkboxClass - The class for the checkbox HTML element.
|
||||
* @return {string} - The HTML string that represents the extension.
|
||||
* @return {HTMLElement} - The element that represents the extension.
|
||||
*/
|
||||
function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) {
|
||||
function generateExtensionElement(name, manifest, isActive, isDisabled, isExternal, checkboxClass) {
|
||||
function getExtensionIcon() {
|
||||
const type = getExtensionType(name);
|
||||
const icon = document.createElement('i');
|
||||
icon.classList.add('fa-sm', 'fa-fw', 'fa-solid');
|
||||
switch (type) {
|
||||
case 'global':
|
||||
return '<i class="fa-sm fa-fw fa-solid fa-server" data-i18n="[title]ext_type_global" title="This is a global extension, available for all users."></i>';
|
||||
icon.classList.add('fa-server');
|
||||
icon.title = t`This is a global extension, available for all users.`;
|
||||
break;
|
||||
case 'local':
|
||||
return '<i class="fa-sm fa-fw fa-solid fa-user" data-i18n="[title]ext_type_local" title="This is a local extension, available only for you."></i>';
|
||||
icon.classList.add('fa-user');
|
||||
icon.title = t`This is a local extension, available only for you.`;
|
||||
break;
|
||||
case 'system':
|
||||
return '<i class="fa-sm fa-fw fa-solid fa-cog" data-i18n="[title]ext_type_system" title="This is a built-in extension. It cannot be deleted and updates with the app."></i>';
|
||||
icon.classList.add('fa-cog');
|
||||
icon.title = t`This is a built-in extension. It cannot be deleted and updates with the app.`;
|
||||
break;
|
||||
default:
|
||||
return '<i class="fa-sm fa-fw fa-solid fa-question" title="Unknown extension type."></i>';
|
||||
icon.classList.add('fa-question');
|
||||
icon.title = t`Unknown extension type.`;
|
||||
break;
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
const isUserAdmin = isAdmin();
|
||||
const extensionIcon = getExtensionIcon();
|
||||
const displayName = manifest.display_name;
|
||||
const displayVersion = manifest.version || '';
|
||||
const externalId = name.replace('third-party', '');
|
||||
let originHtml = '';
|
||||
if (isExternal) {
|
||||
originHtml = '<a>';
|
||||
|
||||
// Root block
|
||||
const block = document.createElement('div');
|
||||
block.classList.add('extension_block');
|
||||
block.dataset.name = externalId;
|
||||
|
||||
// Toggle
|
||||
const toggleDiv = document.createElement('div');
|
||||
toggleDiv.classList.add('extension_toggle');
|
||||
const toggle = document.createElement('input');
|
||||
toggle.type = 'checkbox';
|
||||
toggle.dataset.name = name;
|
||||
if (isActive || isDisabled) {
|
||||
toggle.title = t`Click to toggle`;
|
||||
toggle.classList.add(isActive ? 'toggle_disable' : 'toggle_enable');
|
||||
if (checkboxClass) toggle.classList.add(checkboxClass);
|
||||
toggle.checked = isActive;
|
||||
} else {
|
||||
toggle.title = t`Cannot enable extension`;
|
||||
toggle.classList.add('extension_missing');
|
||||
if (checkboxClass) toggle.classList.add(checkboxClass);
|
||||
toggle.disabled = true;
|
||||
}
|
||||
toggleDiv.appendChild(toggle);
|
||||
block.appendChild(toggleDiv);
|
||||
|
||||
let toggleElement = isActive || isDisabled ?
|
||||
'<input type="checkbox" title="' + t`Click to toggle` + `" data-name="${name}" class="${isActive ? 'toggle_disable' : 'toggle_enable'} ${checkboxClass}" ${isActive ? 'checked' : ''}>` :
|
||||
`<input type="checkbox" title="Cannot enable extension" data-name="${name}" class="extension_missing ${checkboxClass}" disabled>`;
|
||||
// Icon
|
||||
const iconDiv = document.createElement('div');
|
||||
iconDiv.classList.add('extension_icon');
|
||||
iconDiv.appendChild(getExtensionIcon());
|
||||
block.appendChild(iconDiv);
|
||||
|
||||
let deleteButton = isExternal ? `<button class="btn_delete menu_button" data-name="${externalId}" data-i18n="[title]Delete" title="Delete"><i class="fa-fw fa-solid fa-trash-can"></i></button>` : '';
|
||||
let updateButton = isExternal ? `<button class="btn_update menu_button displayNone" data-name="${externalId}" title="Update available"><i class="fa-solid fa-download fa-fw"></i></button>` : '';
|
||||
let moveButton = isExternal && isUserAdmin ? `<button class="btn_move menu_button" data-name="${externalId}" data-i18n="[title]Move" title="Move"><i class="fa-solid fa-folder-tree fa-fw"></i></button>` : '';
|
||||
let branchButton = isExternal && isUserAdmin ? `<button class="btn_branch menu_button" data-name="${externalId}" data-i18n="[title]Switch branch" title="Switch branch"><i class="fa-solid fa-code-branch fa-fw"></i></button>` : '';
|
||||
let cleanButton = isExternal && hasExtensionHook(externalId, 'clean') ? `<button class="btn_clean menu_button" data-name="${externalId}" data-i18n="[title]Clean extension data" title="Clean extension data"><i class="fa-fw fa-solid fa-broom"></i></button>` : '';
|
||||
let modulesInfo = '';
|
||||
// Text block
|
||||
const textBlock = document.createElement('div');
|
||||
textBlock.classList.add('flexGrow', 'extension_text_block');
|
||||
|
||||
const statusSpan = document.createElement('span');
|
||||
statusSpan.className = isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.classList.add('extension_name');
|
||||
nameSpan.textContent = displayName;
|
||||
|
||||
const authorSpan = document.createElement('span');
|
||||
authorSpan.classList.add('extension_author');
|
||||
|
||||
const versionSpan = document.createElement('span');
|
||||
versionSpan.classList.add('extension_version');
|
||||
versionSpan.textContent = displayVersion;
|
||||
|
||||
statusSpan.append(nameSpan, authorSpan, versionSpan);
|
||||
|
||||
if (isActive && Array.isArray(manifest.optional)) {
|
||||
const optional = new Set(manifest.optional);
|
||||
modules.forEach(x => optional.delete(x));
|
||||
if (optional.size > 0) {
|
||||
const optionalString = DOMPurify.sanitize([...optional].join(', '));
|
||||
modulesInfo = '<div class="extension_modules">' + t`Optional modules:` + ` <span class="optional">${optionalString}</span></div>`;
|
||||
const modulesDiv = document.createElement('div');
|
||||
modulesDiv.classList.add('extension_modules');
|
||||
const optionalSpan = document.createElement('span');
|
||||
optionalSpan.classList.add('optional');
|
||||
optionalSpan.textContent = [...optional].join(', ');
|
||||
modulesDiv.append(t`Optional modules:`, ' ', optionalSpan);
|
||||
statusSpan.appendChild(modulesDiv);
|
||||
}
|
||||
} else if (!isDisabled) { // Neither active nor disabled
|
||||
} else if (!isDisabled) {
|
||||
// Neither active nor disabled
|
||||
const requirements = new Set(manifest.requires);
|
||||
modules.forEach(x => requirements.delete(x));
|
||||
if (requirements.size > 0) {
|
||||
const requirementsString = DOMPurify.sanitize([...requirements].join(', '));
|
||||
modulesInfo = `<div class="extension_modules">Missing modules: <span class="failure">${requirementsString}</span></div>`;
|
||||
const modulesDiv = document.createElement('div');
|
||||
modulesDiv.classList.add('extension_modules');
|
||||
const failureSpan = document.createElement('span');
|
||||
failureSpan.classList.add('failure');
|
||||
failureSpan.textContent = [...requirements].join(', ');
|
||||
modulesDiv.append(t`Missing modules:`, ' ', failureSpan);
|
||||
statusSpan.appendChild(modulesDiv);
|
||||
}
|
||||
}
|
||||
|
||||
// if external, wrap the name in a link to the repo
|
||||
if (isExternal) {
|
||||
const originLink = document.createElement('a');
|
||||
originLink.appendChild(statusSpan);
|
||||
textBlock.appendChild(originLink);
|
||||
} else {
|
||||
textBlock.appendChild(statusSpan);
|
||||
}
|
||||
|
||||
let extensionHtml = `
|
||||
<div class="extension_block" data-name="${externalId}">
|
||||
<div class="extension_toggle">
|
||||
${toggleElement}
|
||||
</div>
|
||||
<div class="extension_icon">
|
||||
${extensionIcon}
|
||||
</div>
|
||||
<div class="flexGrow extension_text_block">
|
||||
${originHtml}
|
||||
<span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}">
|
||||
<span class="extension_name">${DOMPurify.sanitize(displayName)}</span>
|
||||
<span class="extension_author"></span>
|
||||
<span class="extension_version">${DOMPurify.sanitize(displayVersion)}</span>
|
||||
${modulesInfo}
|
||||
</span>
|
||||
${isExternal ? '</a>' : ''}
|
||||
</div>
|
||||
block.appendChild(textBlock);
|
||||
|
||||
<div class="extension_actions flex-container alignItemsCenter">
|
||||
${updateButton}
|
||||
${cleanButton}
|
||||
${branchButton}
|
||||
${moveButton}
|
||||
${deleteButton}
|
||||
</div>
|
||||
</div>`;
|
||||
// Actions
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.classList.add('extension_actions', 'flex-container', 'alignItemsCenter');
|
||||
|
||||
return extensionHtml;
|
||||
/**
|
||||
* Helper function to create an action button for an extension.
|
||||
* @param {string} cls Class name
|
||||
* @param {string} dataName Name of the extension
|
||||
* @param {string} title Title of the button
|
||||
* @param {string} iconClasses Classes for the icon
|
||||
* @returns {HTMLButtonElement} The created button element
|
||||
*/
|
||||
function makeActionButton(cls, dataName, title, iconClasses) {
|
||||
const btn = document.createElement('button');
|
||||
btn.classList.add(cls, 'menu_button');
|
||||
btn.dataset.name = dataName;
|
||||
btn.title = title;
|
||||
const icon = document.createElement('i');
|
||||
icon.classList.add(...iconClasses.split(' '));
|
||||
btn.appendChild(icon);
|
||||
return btn;
|
||||
}
|
||||
|
||||
if (isExternal) {
|
||||
const updateBtn = makeActionButton('btn_update', externalId, t`Update available`, 'fa-solid fa-download fa-fw');
|
||||
updateBtn.classList.add('displayNone');
|
||||
actionsDiv.appendChild(updateBtn);
|
||||
}
|
||||
|
||||
if (isExternal && hasExtensionHook(externalId, 'clean')) {
|
||||
actionsDiv.appendChild(makeActionButton('btn_clean', externalId, t`Clean extension data`, 'fa-fw fa-solid fa-broom'));
|
||||
}
|
||||
|
||||
if (isExternal && isUserAdmin) {
|
||||
actionsDiv.appendChild(makeActionButton('btn_branch', externalId, t`Switch branch`, 'fa-solid fa-code-branch fa-fw'));
|
||||
actionsDiv.appendChild(makeActionButton('btn_move', externalId, t`Move`, 'fa-solid fa-folder-tree fa-fw'));
|
||||
}
|
||||
|
||||
if (isExternal) {
|
||||
actionsDiv.appendChild(makeActionButton('btn_delete', externalId, t`Delete`, 'fa-fw fa-solid fa-trash-can'));
|
||||
}
|
||||
|
||||
block.appendChild(actionsDiv);
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets extension data and generates the corresponding HTML for displaying the extension.
|
||||
* Gets extension data and generates the corresponding element for displaying the extension.
|
||||
*
|
||||
* @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest.
|
||||
* @return {object} - An object with 'isExternal' indicating whether the extension is external, and 'extensionHtml' for the extension's HTML string.
|
||||
* @return {{isExternal: boolean, extensionElement: HTMLElement}} - An object with 'isExternal' indicating whether the extension is external, and 'extensionElement' for the extension's HTML element.
|
||||
*/
|
||||
function getExtensionData(extension) {
|
||||
const name = extension[0];
|
||||
@@ -974,33 +1068,43 @@ function getExtensionData(extension) {
|
||||
const isExternal = name.startsWith('third-party');
|
||||
|
||||
const checkboxClass = isDisabled ? 'checkbox_disabled' : '';
|
||||
const extensionElement = generateExtensionElement(name, manifest, isActive, isDisabled, isExternal, checkboxClass);
|
||||
|
||||
const extensionHtml = generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass);
|
||||
|
||||
return { isExternal, extensionHtml };
|
||||
return { isExternal, extensionElement };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the module information to be displayed.
|
||||
*
|
||||
* @return {string} - The HTML string for the module information.
|
||||
* @return {HTMLElement} - The element containing the module information.
|
||||
*/
|
||||
function getModuleInformation() {
|
||||
let moduleInfo = modules.length ? `<p>${DOMPurify.sanitize(modules.join(', '))}</p>` : '<p class="failure">' + t`Not connected to the API!` + '</p>';
|
||||
return `
|
||||
<h3>` + t`Modules provided by your Extras API:` + `</h3>
|
||||
${moduleInfo}
|
||||
`;
|
||||
const container = document.createElement('div');
|
||||
|
||||
const heading = document.createElement('h3');
|
||||
heading.textContent = t`Modules provided by your Extras API:`;
|
||||
container.appendChild(heading);
|
||||
|
||||
const moduleInfo = document.createElement('p');
|
||||
if (modules.length) {
|
||||
moduleInfo.textContent = modules.join(', ');
|
||||
} else {
|
||||
moduleInfo.classList.add('failure');
|
||||
moduleInfo.textContent = t`Not connected to the API!`;
|
||||
}
|
||||
container.appendChild(moduleInfo);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates HTML for the extension load errors.
|
||||
* @returns {string} HTML string containing the errors that occurred while loading extensions.
|
||||
* Generates HTMLElement for the extension load errors.
|
||||
* @returns {HTMLElement} - The element containing the extension load errors.
|
||||
*/
|
||||
function getExtensionLoadErrorsHtml() {
|
||||
function getExtensionLoadErrors() {
|
||||
if (extensionLoadErrors.size === 0) {
|
||||
return '';
|
||||
return document.createElement('div');
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
@@ -1012,7 +1116,7 @@ function getExtensionLoadErrorsHtml() {
|
||||
container.appendChild(errorElement);
|
||||
}
|
||||
|
||||
return container.outerHTML;
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1029,22 +1133,35 @@ async function showExtensionsDetails() {
|
||||
initialScrollTop = oldPopup.content.scrollTop;
|
||||
await oldPopup.completeCancelled();
|
||||
}
|
||||
const htmlErrors = getExtensionLoadErrorsHtml();
|
||||
const htmlDefault = $('<div class="marginBot10"><h3>' + t`Built-in Extensions:` + '</h3></div>');
|
||||
const errors = getExtensionLoadErrors();
|
||||
|
||||
const htmlExternal = $(`<div class="marginBot10">
|
||||
<div class="flex-container alignitemscenter spaceBetween flexnowrap marginBot10">
|
||||
<h3 class="margin0">${t`Installed Extensions:`}</h3>
|
||||
<div class="flex-container third_party_toolbar"></div>
|
||||
</div>
|
||||
</div>`);
|
||||
const defaultContainer = document.createElement('div');
|
||||
defaultContainer.classList.add('marginBot10');
|
||||
const defaultHeading = document.createElement('h3');
|
||||
defaultHeading.textContent = t`Built-in Extensions:`;
|
||||
defaultContainer.appendChild(defaultHeading);
|
||||
|
||||
const htmlLoading = $(`<div class="flex-container alignItemsCenter justifyCenter marginTop10 marginBot5">
|
||||
<i class="fa-solid fa-spinner fa-spin"></i>
|
||||
<span>` + t`Loading third-party extensions... Please wait...` + `</span>
|
||||
</div>`);
|
||||
const externalContainer = document.createElement('div');
|
||||
externalContainer.classList.add('marginBot10');
|
||||
const externalHeader = document.createElement('div');
|
||||
externalHeader.classList.add('flex-container', 'alignitemscenter', 'spaceBetween', 'flexnowrap', 'marginBot10');
|
||||
const externalHeading = document.createElement('h3');
|
||||
externalHeading.classList.add('margin0');
|
||||
externalHeading.textContent = t`Installed Extensions:`;
|
||||
const thirdPartyToolbar = document.createElement('div');
|
||||
thirdPartyToolbar.classList.add('flex-container', 'third_party_toolbar');
|
||||
externalHeader.append(externalHeading, thirdPartyToolbar);
|
||||
externalContainer.appendChild(externalHeader);
|
||||
|
||||
htmlExternal.append(htmlLoading);
|
||||
const loadingEl = document.createElement('div');
|
||||
loadingEl.classList.add('flex-container', 'alignItemsCenter', 'justifyCenter', 'marginTop10', 'marginBot5');
|
||||
const loadingIcon = document.createElement('i');
|
||||
loadingIcon.classList.add('fa-solid', 'fa-spinner', 'fa-spin');
|
||||
const loadingSpan = document.createElement('span');
|
||||
loadingSpan.textContent = t`Loading third-party extensions... Please wait...`;
|
||||
loadingEl.append(loadingIcon, loadingSpan);
|
||||
|
||||
externalContainer.appendChild(loadingEl);
|
||||
|
||||
const sortOrderKey = 'extensions_sortByName';
|
||||
const sortByName = accountStorage.getItem(sortOrderKey) === 'true';
|
||||
@@ -1053,16 +1170,16 @@ async function showExtensionsDetails() {
|
||||
let extensionsToToggle = [];
|
||||
|
||||
extensions.forEach(value => {
|
||||
const { isExternal, extensionHtml } = value;
|
||||
const container = isExternal ? htmlExternal : htmlDefault;
|
||||
container.append(extensionHtml);
|
||||
const { isExternal, extensionElement } = value;
|
||||
const container = isExternal ? externalContainer : defaultContainer;
|
||||
container.appendChild(extensionElement);
|
||||
});
|
||||
|
||||
const html = $('<div></div>')
|
||||
const extensionsMenu = $('<div></div>')
|
||||
.addClass('extensions_info')
|
||||
.append(htmlErrors)
|
||||
.append(htmlDefault)
|
||||
.append(htmlExternal)
|
||||
.append(errors)
|
||||
.append(defaultContainer)
|
||||
.append(externalContainer)
|
||||
.append(getModuleInformation());
|
||||
|
||||
{
|
||||
@@ -1088,23 +1205,24 @@ async function showExtensionsDetails() {
|
||||
const toggleAllExtensionsButton = document.createElement('div');
|
||||
toggleAllExtensionsButton.classList.add('menu_button', 'menu_button_icon');
|
||||
toggleAllExtensionsButton.title = t`Bulk toggle third-party extensions.`;
|
||||
toggleAllExtensionsButton.innerHTML = `
|
||||
<span>${t`Toggle extensions`}</span>
|
||||
<div class="fa-solid fa-circle-info opacity50p"></div>
|
||||
`;
|
||||
const toggleAllLabel = document.createElement('span');
|
||||
toggleAllLabel.textContent = t`Toggle extensions`;
|
||||
const toggleAllIcon = document.createElement('div');
|
||||
toggleAllIcon.classList.add('fa-solid', 'fa-circle-info', 'opacity50p');
|
||||
toggleAllExtensionsButton.append(toggleAllLabel, toggleAllIcon);
|
||||
|
||||
const restoreBulkToggledExtensionsButton = document.createElement('div');
|
||||
restoreBulkToggledExtensionsButton.classList.add('menu_button', 'menu_button_icon', 'fa-solid', 'fa-arrow-right-rotate', 'displayNone');
|
||||
restoreBulkToggledExtensionsButton.title = t`Restore toggled extensions.\n\nIt does not restore extensions toggled individually.`;
|
||||
|
||||
toggleAllExtensionsButton.addEventListener('click', () => {
|
||||
extensionsToToggle = onToggleAllExtensions(extensionsToToggle, htmlExternal);
|
||||
extensionsToToggle = onToggleAllExtensions(extensionsToToggle, $(externalContainer));
|
||||
|
||||
for (const extension of extensionsToToggle) {
|
||||
const { name } = extension;
|
||||
|
||||
htmlExternal
|
||||
.find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`)
|
||||
$(externalContainer)
|
||||
.find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`)
|
||||
.off('click')
|
||||
.one('click', () => {
|
||||
extensionsToToggle = extensionsToToggle.filter(ext => ext.name !== name);
|
||||
@@ -1121,8 +1239,8 @@ async function showExtensionsDetails() {
|
||||
const { name } = extension;
|
||||
const isDisabled = extension_settings.disabledExtensions.includes(name);
|
||||
|
||||
htmlExternal
|
||||
.find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`)
|
||||
$(externalContainer)
|
||||
.find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`)
|
||||
.prop('checked', !isDisabled)
|
||||
.toggleClass('toggle_enable', isDisabled)
|
||||
.toggleClass('toggle_disable', !isDisabled)
|
||||
@@ -1146,13 +1264,13 @@ async function showExtensionsDetails() {
|
||||
});
|
||||
|
||||
toolbar.append(updateAllButton, updateEnabledOnlyButton, flexExpander, sortOrderButton);
|
||||
htmlExternal.find('.third_party_toolbar').append(restoreBulkToggledExtensionsButton, toggleAllExtensionsButton);
|
||||
html.prepend(toolbar);
|
||||
thirdPartyToolbar.append(restoreBulkToggledExtensionsButton, toggleAllExtensionsButton);
|
||||
extensionsMenu.prepend(toolbar);
|
||||
}
|
||||
|
||||
let waitingForSave = false;
|
||||
|
||||
const popup = new Popup(html, POPUP_TYPE.TEXT, '', {
|
||||
const popup = new Popup(extensionsMenu, POPUP_TYPE.TEXT, '', {
|
||||
okButton: t`Close`,
|
||||
wide: true,
|
||||
large: true,
|
||||
@@ -1194,7 +1312,7 @@ async function showExtensionsDetails() {
|
||||
});
|
||||
popupPromise = popup.show();
|
||||
popup.content.scrollTop = initialScrollTop;
|
||||
checkForUpdatesManual(sortFn, abortController.signal).finally(() => htmlLoading.remove());
|
||||
checkForUpdatesManual(sortFn, abortController.signal).finally(() => loadingEl.remove());
|
||||
} catch (error) {
|
||||
toastr.error(t`Error loading extensions. See browser console for details.`);
|
||||
console.error(error);
|
||||
@@ -1297,7 +1415,7 @@ async function onDeleteClick() {
|
||||
/** @type {import('./popup.js').CustomPopupInput[]} */
|
||||
const customInputs = hasCleanHook ? [{ id: 'extension_delete_cleanup', label: t`Also clean up extension data`, defaultState: false }] : null;
|
||||
|
||||
const popup = new Popup(t`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', { customInputs });
|
||||
const popup = new Popup(t`Are you sure you want to delete ${escapeHtml(extensionName)}?`, POPUP_TYPE.CONFIRM, '', { customInputs });
|
||||
const confirmation = await popup.show();
|
||||
if (confirmation === POPUP_RESULT.AFFIRMATIVE) {
|
||||
const shouldClean = hasCleanHook && Boolean(popup.inputResults?.get('extension_delete_cleanup'));
|
||||
@@ -1312,7 +1430,7 @@ async function onDeleteClick() {
|
||||
async function onCleanClick() {
|
||||
const extensionName = $(this).data('name');
|
||||
|
||||
const confirmation = await Popup.show.confirm(t`Clean extension data`, t`Are you sure you want to clean up data for ${extensionName}? This action cannot be undone.`);
|
||||
const confirmation = await Popup.show.confirm(t`Clean extension data`, t`Are you sure you want to clean up data for ${escapeHtml(extensionName)}? This action cannot be undone.`);
|
||||
if (!confirmation) {
|
||||
return;
|
||||
}
|
||||
@@ -1388,8 +1506,8 @@ async function onMoveClick() {
|
||||
|
||||
const confirmationHeader = t`Move extension`;
|
||||
const confirmationText = source == 'global'
|
||||
? t`Are you sure you want to move ${extensionName} to your local extensions? This will make it available only for you.`
|
||||
: t`Are you sure you want to move ${extensionName} to the global extensions? This will make it available for all users.`;
|
||||
? t`Are you sure you want to move ${escapeHtml(extensionName)} to your local extensions? This will make it available only for you.`
|
||||
: t`Are you sure you want to move ${escapeHtml(extensionName)} to the global extensions? This will make it available for all users.`;
|
||||
|
||||
const confirmation = await Popup.show.confirm(confirmationHeader, confirmationText);
|
||||
|
||||
@@ -1493,6 +1611,9 @@ async function getExtensionVersion(extensionName, abortSignal) {
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
@@ -1730,7 +1851,11 @@ async function checkForUpdatesManual(sortFn, abortSignal) {
|
||||
const promise = enqueueVersionCheck(async () => {
|
||||
try {
|
||||
const data = await getExtensionVersion(externalId, abortSignal);
|
||||
const extensionBlock = document.querySelector(`.extension_block[data-name="${externalId}"]`);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const selector = getNameSelector(externalId, { prefix: '' });
|
||||
const extensionBlock = document.querySelector(`.extension_block[data-name="${selector}"]`);
|
||||
if (extensionBlock && data) {
|
||||
if (data.isUpToDate === false) {
|
||||
const buttonElement = extensionBlock.querySelector('.btn_update');
|
||||
@@ -1825,6 +1950,9 @@ async function checkForExtensionUpdates(force) {
|
||||
const promise = enqueueVersionCheck(async () => {
|
||||
try {
|
||||
const data = await getExtensionVersion(id.replace('third-party', ''));
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
if (!data.isUpToDate) {
|
||||
updatesAvailable.push(manifest.display_name);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import { DOMPurify } from '../../../lib.js';
|
||||
import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js';
|
||||
import { deleteExtension, EMPTY_AUTHOR, extensionNames, getAuthorFromUrl, getContext, installExtension, renderExtensionTemplateAsync, isOfficialExtension } from '../../extensions.js';
|
||||
import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';
|
||||
import { executeSlashCommandsWithOptions } from '../../slash-commands.js';
|
||||
import { accountStorage } from '../../util/AccountStorage.js';
|
||||
import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js';
|
||||
import { escapeHtml, flashHighlight, getStringHash, isValidUrl } from '../../utils.js';
|
||||
import { t, translate } from '../../i18n.js';
|
||||
import { SlashCommandParser } from '/scripts/slash-commands/SlashCommandParser.js';
|
||||
export { MODULE_NAME };
|
||||
|
||||
const MODULE_NAME = 'assets';
|
||||
@@ -60,205 +60,262 @@ const KNOWN_TYPES = {
|
||||
'blip': t`Blip sounds`,
|
||||
};
|
||||
|
||||
async function downloadAssetsList(url) {
|
||||
updateCurrentAssets().then(async function () {
|
||||
fetch(url, { cache: 'no-cache' })
|
||||
.then(response => response.json())
|
||||
.then(async function (json) {
|
||||
availableAssets = {};
|
||||
$('#assets_menu').empty();
|
||||
/**
|
||||
* Creates the download/delete button element for a single asset, with all interaction handlers attached.
|
||||
* @param {object} asset The asset data object, containing at least id, name, description and url fields
|
||||
* @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
|
||||
* @param {number} index Index of the asset in the list of available assets of the same type, used to create a unique element ID
|
||||
* @returns {JQuery} The button element
|
||||
*/
|
||||
function createAssetButton(asset, assetType, index) {
|
||||
const elemId = `assets_install_${assetType}_${index}`;
|
||||
const element = $('<div />', { id: elemId, class: 'asset-download-button right_menu_button' });
|
||||
const label = $('<i class="fa-fw fa-solid fa-download fa-lg"></i>');
|
||||
element.append(label);
|
||||
|
||||
console.debug(DEBUG_PREFIX, 'Received assets dictionary', json);
|
||||
console.debug(DEBUG_PREFIX, 'Checking asset', asset.id, asset.url);
|
||||
|
||||
for (const i of json) {
|
||||
//console.log(DEBUG_PREFIX,i)
|
||||
if (availableAssets[i.type] === undefined)
|
||||
availableAssets[i.type] = [];
|
||||
const assetInstall = async function () {
|
||||
element.off('click');
|
||||
label.removeClass('fa-download');
|
||||
this.classList.add('asset-download-button-loading');
|
||||
const result = await installAsset(asset.url, assetType, asset.id);
|
||||
if (!result) {
|
||||
this.classList.remove('asset-download-button-loading');
|
||||
label.addClass('fa-download');
|
||||
label.removeClass('fa-spinner');
|
||||
label.removeClass('fa-spin');
|
||||
element.on('click', assetInstall);
|
||||
return;
|
||||
}
|
||||
label.addClass('fa-check');
|
||||
this.classList.remove('asset-download-button-loading');
|
||||
element.on('click', assetDelete);
|
||||
element.on('mouseenter', function () {
|
||||
label.removeClass('fa-check');
|
||||
label.addClass('fa-trash');
|
||||
label.addClass('redOverlayGlow');
|
||||
}).on('mouseleave', function () {
|
||||
label.addClass('fa-check');
|
||||
label.removeClass('fa-trash');
|
||||
label.removeClass('redOverlayGlow');
|
||||
});
|
||||
};
|
||||
|
||||
availableAssets[i.type].push(i);
|
||||
}
|
||||
const assetDelete = async function () {
|
||||
if (assetType === 'character') {
|
||||
toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported');
|
||||
await SlashCommandParser.commands.go.callback(null, asset.id);
|
||||
return;
|
||||
}
|
||||
element.off('click');
|
||||
await deleteAsset(assetType, asset.id);
|
||||
label.removeClass('fa-check');
|
||||
label.removeClass('redOverlayGlow');
|
||||
label.removeClass('fa-trash');
|
||||
label.addClass('fa-download');
|
||||
element.off('mouseenter').off('mouseleave');
|
||||
element.on('click', assetInstall);
|
||||
};
|
||||
|
||||
console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets);
|
||||
// First extensions, then everything else
|
||||
const assetTypes = Object.keys(availableAssets).sort((a, b) => (a === 'extension') ? -1 : (b === 'extension') ? 1 : 0);
|
||||
if (isAssetInstalled(assetType, asset.id)) {
|
||||
console.debug(DEBUG_PREFIX, 'installed, checked');
|
||||
label.toggleClass('fa-download');
|
||||
label.toggleClass('fa-check');
|
||||
element.on('click', assetDelete);
|
||||
element.on('mouseenter', function () {
|
||||
label.removeClass('fa-check');
|
||||
label.addClass('fa-trash');
|
||||
label.addClass('redOverlayGlow');
|
||||
}).on('mouseleave', function () {
|
||||
label.addClass('fa-check');
|
||||
label.removeClass('fa-trash');
|
||||
label.removeClass('redOverlayGlow');
|
||||
});
|
||||
} else {
|
||||
console.debug(DEBUG_PREFIX, 'not installed, unchecked');
|
||||
element.prop('checked', false);
|
||||
element.on('click', assetInstall);
|
||||
}
|
||||
|
||||
$('#assets_type_select').empty();
|
||||
$('#assets_search').val('');
|
||||
$('#assets_type_select').append($('<option />', { value: '', text: t`All` }));
|
||||
|
||||
for (const type of assetTypes) {
|
||||
const text = translate(KNOWN_TYPES[type] || type);
|
||||
const option = $('<option />', { value: type, text: text });
|
||||
$('#assets_type_select').append(option);
|
||||
}
|
||||
|
||||
if (assetTypes.includes('extension')) {
|
||||
$('#assets_type_select').val('extension');
|
||||
}
|
||||
|
||||
$('#assets_type_select').off('change').on('change', filterAssets);
|
||||
$('#assets_search').off('input').on('input', filterAssets);
|
||||
|
||||
for (const assetType of assetTypes) {
|
||||
let assetTypeMenu = $('<div />', { id: 'assets_audio_ambient_div', class: 'assets-list-div' });
|
||||
assetTypeMenu.attr('data-type', assetType);
|
||||
assetTypeMenu.append(`<h3>${KNOWN_TYPES[assetType] || assetType}</h3>`).hide();
|
||||
|
||||
if (assetType == 'extension') {
|
||||
assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation'));
|
||||
}
|
||||
|
||||
for (const asset of availableAssets[assetType].sort((a, b) => a?.name && b?.name && a.name.localeCompare(b.name))) {
|
||||
const i = availableAssets[assetType].indexOf(asset);
|
||||
const elemId = `assets_install_${assetType}_${i}`;
|
||||
let element = $('<div />', { id: elemId, class: 'asset-download-button right_menu_button' });
|
||||
const label = $('<i class="fa-fw fa-solid fa-download fa-lg"></i>');
|
||||
element.append(label);
|
||||
|
||||
//if (DEBUG_TONY_SAMA_FORK_MODE)
|
||||
// asset["url"] = asset["url"].replace("https://github.com/SillyTavern/","https://github.com/Tony-sama/"); // DBG
|
||||
|
||||
console.debug(DEBUG_PREFIX, 'Checking asset', asset.id, asset.url);
|
||||
|
||||
const assetInstall = async function () {
|
||||
element.off('click');
|
||||
label.removeClass('fa-download');
|
||||
this.classList.add('asset-download-button-loading');
|
||||
const result = await installAsset(asset.url, assetType, asset.id);
|
||||
if (!result) {
|
||||
this.classList.remove('asset-download-button-loading');
|
||||
label.addClass('fa-download');
|
||||
label.removeClass('fa-spinner');
|
||||
label.removeClass('fa-spin');
|
||||
element.on('click', assetInstall);
|
||||
return;
|
||||
}
|
||||
label.addClass('fa-check');
|
||||
this.classList.remove('asset-download-button-loading');
|
||||
element.on('click', assetDelete);
|
||||
element.on('mouseenter', function () {
|
||||
label.removeClass('fa-check');
|
||||
label.addClass('fa-trash');
|
||||
label.addClass('redOverlayGlow');
|
||||
}).on('mouseleave', function () {
|
||||
label.addClass('fa-check');
|
||||
label.removeClass('fa-trash');
|
||||
label.removeClass('redOverlayGlow');
|
||||
});
|
||||
};
|
||||
|
||||
const assetDelete = async function () {
|
||||
if (assetType === 'character') {
|
||||
toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported');
|
||||
await executeSlashCommandsWithOptions(`/go ${asset.id}`);
|
||||
return;
|
||||
}
|
||||
element.off('click');
|
||||
await deleteAsset(assetType, asset.id);
|
||||
label.removeClass('fa-check');
|
||||
label.removeClass('redOverlayGlow');
|
||||
label.removeClass('fa-trash');
|
||||
label.addClass('fa-download');
|
||||
element.off('mouseenter').off('mouseleave');
|
||||
element.on('click', assetInstall);
|
||||
};
|
||||
|
||||
if (isAssetInstalled(assetType, asset.id)) {
|
||||
console.debug(DEBUG_PREFIX, 'installed, checked');
|
||||
label.toggleClass('fa-download');
|
||||
label.toggleClass('fa-check');
|
||||
element.on('click', assetDelete);
|
||||
element.on('mouseenter', function () {
|
||||
label.removeClass('fa-check');
|
||||
label.addClass('fa-trash');
|
||||
label.addClass('redOverlayGlow');
|
||||
}).on('mouseleave', function () {
|
||||
label.addClass('fa-check');
|
||||
label.removeClass('fa-trash');
|
||||
label.removeClass('redOverlayGlow');
|
||||
});
|
||||
} else {
|
||||
console.debug(DEBUG_PREFIX, 'not installed, unchecked');
|
||||
element.prop('checked', false);
|
||||
element.on('click', assetInstall);
|
||||
}
|
||||
|
||||
console.debug(DEBUG_PREFIX, 'Created element for ', asset.id);
|
||||
|
||||
const displayName = DOMPurify.sanitize(asset.name || asset.id);
|
||||
const description = DOMPurify.sanitize(asset.description || '');
|
||||
const url = isValidUrl(asset.url) ? asset.url : '';
|
||||
const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : t`Preview in browser`;
|
||||
const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple';
|
||||
const toolTag = assetType === 'extension' && asset.tool;
|
||||
const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR;
|
||||
|
||||
const assetBlock = $('<i></i>')
|
||||
.append(element)
|
||||
.append(`<div class="flex-container flexFlowColumn flexNoGap wide100p overflowHidden">
|
||||
<span class="asset-name flex-container alignitemscenter">
|
||||
<b>${displayName}</b>
|
||||
<a class="asset_preview" href="${url}" target="_blank" title="${title}">
|
||||
<i class="fa-solid fa-sm ${previewIcon}"></i>
|
||||
</a>` +
|
||||
(toolTag ? '<span class="tag" title="' + t`Adds a function tool` + '"><i class="fa-solid fa-sm fa-wrench"></i> ' +
|
||||
t`Tool` + '</span>' : '') +
|
||||
'<span class="expander"></span>' +
|
||||
(author.name ? `<a href="${author.url}" target="_blank" class="asset-author-info"><i class="fa-solid fa-at fa-xs"></i><span>${author.name}</span></a>` : '') +
|
||||
`</span>
|
||||
<small class="asset-description">
|
||||
${description}
|
||||
</small>
|
||||
</div>`);
|
||||
|
||||
assetBlock.find('.tag').on('click', function (e) {
|
||||
const a = document.createElement('a');
|
||||
a.href = 'https://docs.sillytavern.app/for-contributors/function-calling/';
|
||||
a.target = '_blank';
|
||||
a.click();
|
||||
});
|
||||
|
||||
if (assetType === 'character') {
|
||||
if (asset.highlight) {
|
||||
assetBlock.find('.asset-name').append('<i class="fa-solid fa-sm fa-trophy"></i>');
|
||||
}
|
||||
assetBlock.find('.asset-name').prepend(`<div class="avatar"><img src="${asset.url}" alt="${displayName}"></div>`);
|
||||
}
|
||||
|
||||
assetBlock.addClass('asset-block');
|
||||
|
||||
if (assetType === 'extension') {
|
||||
const extensionBlockList = isOfficialExtension(asset.url)
|
||||
? assetTypeMenu.find('.assets-list-extensions-official .assets-list-extensions')
|
||||
: assetTypeMenu.find('.assets-list-extensions-community .assets-list-extensions');
|
||||
extensionBlockList.append(assetBlock);
|
||||
} else {
|
||||
assetTypeMenu.append(assetBlock);
|
||||
}
|
||||
}
|
||||
assetTypeMenu.appendTo('#assets_menu');
|
||||
assetTypeMenu.on('click', 'a.asset_preview', previewAsset);
|
||||
}
|
||||
|
||||
filterAssets();
|
||||
$('#assets_filters').show();
|
||||
$('#assets_menu').show();
|
||||
})
|
||||
.catch((error) => {
|
||||
// Info hint if the user maybe... likely accidently was trying to install an extension and we wanna help guide them? uwu :3
|
||||
const installButton = $('#third_party_extension_button');
|
||||
flashHighlight(installButton, 10_000);
|
||||
toastr.info('Click the flashing button at the top right corner of the menu.', 'Trying to install a custom extension?', { timeOut: 10_000 });
|
||||
|
||||
// Error logged after, to appear on top
|
||||
console.error(error);
|
||||
toastr.error('Problem with assets URL', DEBUG_PREFIX + 'Cannot get assets list');
|
||||
$('#assets-connect-button').addClass('fa-plug-circle-exclamation');
|
||||
$('#assets-connect-button').addClass('redOverlayGlow');
|
||||
});
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the full visual block element for a single asset.
|
||||
* @param {object} asset The asset data object, containing at least id, name, description and url fields
|
||||
* @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
|
||||
* @param {JQuery} element The button element from createAssetButton
|
||||
* @returns {JQuery} The asset block element
|
||||
*/
|
||||
function createAssetBlock(asset, assetType, element) {
|
||||
console.debug(DEBUG_PREFIX, 'Created element for ', asset.id);
|
||||
|
||||
const displayName = DOMPurify.sanitize(asset.name || asset.id);
|
||||
const description = DOMPurify.sanitize(asset.description || '');
|
||||
const url = isValidUrl(asset.url) ? asset.url : '';
|
||||
const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : t`Preview in browser`;
|
||||
const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple';
|
||||
const toolTag = assetType === 'extension' && asset.tool;
|
||||
const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR;
|
||||
|
||||
const nameSpan = $('<span>', { class: 'asset-name flex-container alignitemscenter' })
|
||||
.append($('<b>').text(displayName))
|
||||
.append($('<a>', { class: 'asset_preview', href: url, target: '_blank', title: title })
|
||||
.append($('<i>', { class: `fa-solid fa-sm ${previewIcon}` })));
|
||||
|
||||
if (toolTag) {
|
||||
const tagSpan = $('<span>', { class: 'tag', title: t`Adds a function tool` })
|
||||
.append($('<i>', { class: 'fa-solid fa-sm fa-wrench' }))
|
||||
.append(document.createTextNode(` ${t`Tool`}`));
|
||||
nameSpan.append(tagSpan);
|
||||
}
|
||||
|
||||
nameSpan.append($('<span>', { class: 'expander' }));
|
||||
|
||||
if (author.name) {
|
||||
nameSpan.append($('<a>', { href: author.url, target: '_blank', class: 'asset-author-info' })
|
||||
.append($('<i>', { class: 'fa-solid fa-at fa-xs' }))
|
||||
.append($('<span>').text(author.name)));
|
||||
}
|
||||
|
||||
const infoDiv = $('<div>', { class: 'flex-container flexFlowColumn flexNoGap wide100p overflowHidden' })
|
||||
.append(nameSpan)
|
||||
.append($('<small>', { class: 'asset-description' }).text(description));
|
||||
|
||||
const assetBlock = $('<i></i>').append(element).append(infoDiv);
|
||||
|
||||
assetBlock.find('.tag').on('click', function (e) {
|
||||
const a = document.createElement('a');
|
||||
a.href = 'https://docs.sillytavern.app/for-contributors/function-calling/';
|
||||
a.target = '_blank';
|
||||
a.click();
|
||||
});
|
||||
|
||||
if (assetType === 'character') {
|
||||
if (asset.highlight) {
|
||||
nameSpan.append($('<i>', { class: 'fa-solid fa-sm fa-trophy' }));
|
||||
}
|
||||
nameSpan.prepend($('<div>', { class: 'avatar' }).append($('<img>', { src: asset.url, alt: displayName })));
|
||||
}
|
||||
|
||||
assetBlock.addClass('asset-block');
|
||||
return assetBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and appends the menu section for a single asset type.
|
||||
* @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function buildAssetTypeSection(assetType) {
|
||||
const assetTypeMenu = $('<div />', { id: `assets_${assetType}_div`, class: 'assets-list-div' });
|
||||
assetTypeMenu.attr('data-type', assetType);
|
||||
assetTypeMenu.append($('<h3>').text(KNOWN_TYPES[assetType] || assetType)).hide();
|
||||
|
||||
if (assetType == 'extension') {
|
||||
assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation'));
|
||||
}
|
||||
|
||||
for (const asset of availableAssets[assetType].sort((a, b) => a?.name && b?.name && a.name.localeCompare(b.name))) {
|
||||
const i = availableAssets[assetType].indexOf(asset);
|
||||
const element = createAssetButton(asset, assetType, i);
|
||||
const assetBlock = createAssetBlock(asset, assetType, element);
|
||||
|
||||
if (assetType === 'extension') {
|
||||
const extensionBlockList = isOfficialExtension(asset.url)
|
||||
? assetTypeMenu.find('.assets-list-extensions-official .assets-list-extensions')
|
||||
: assetTypeMenu.find('.assets-list-extensions-community .assets-list-extensions');
|
||||
extensionBlockList.append(assetBlock);
|
||||
} else {
|
||||
assetTypeMenu.append(assetBlock);
|
||||
}
|
||||
}
|
||||
|
||||
assetTypeMenu.appendTo('#assets_menu');
|
||||
assetTypeMenu.on('click', 'a.asset_preview', previewAsset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the fetched assets JSON and renders the full assets menu.
|
||||
* @param {object[]} json Array of asset objects, each containing at least id, name, description, url and type fields
|
||||
*/
|
||||
async function populateAssetsMenu(json) {
|
||||
availableAssets = {};
|
||||
$('#assets_menu').empty();
|
||||
|
||||
console.debug(DEBUG_PREFIX, 'Received assets dictionary', json);
|
||||
|
||||
for (const i of json) {
|
||||
if (availableAssets[i.type] === undefined)
|
||||
availableAssets[i.type] = [];
|
||||
availableAssets[i.type].push(i);
|
||||
}
|
||||
|
||||
console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets);
|
||||
// First extensions, then everything else
|
||||
const assetTypes = Object.keys(availableAssets).sort((a, b) => (a === 'extension') ? -1 : (b === 'extension') ? 1 : 0);
|
||||
|
||||
$('#assets_type_select').empty();
|
||||
$('#assets_search').val('');
|
||||
$('#assets_type_select').append($('<option />', { value: '', text: t`All` }));
|
||||
|
||||
for (const type of assetTypes) {
|
||||
const text = translate(KNOWN_TYPES[type] || type);
|
||||
const option = $('<option />', { value: type, text: text });
|
||||
$('#assets_type_select').append(option);
|
||||
}
|
||||
|
||||
if (assetTypes.includes('extension')) {
|
||||
$('#assets_type_select').val('extension');
|
||||
}
|
||||
|
||||
$('#assets_type_select').off('change').on('change', filterAssets);
|
||||
$('#assets_search').off('input').on('input', filterAssets);
|
||||
|
||||
for (const assetType of assetTypes) {
|
||||
await buildAssetTypeSection(assetType);
|
||||
}
|
||||
|
||||
filterAssets();
|
||||
$('#assets_filters').show();
|
||||
$('#assets_menu').show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the assets list from the given URL and populates the menu. Shows error message if something goes wrong.
|
||||
* @param {URL} url URL to fetch from
|
||||
*/
|
||||
async function downloadAssetsList(url) {
|
||||
await updateCurrentAssets();
|
||||
try {
|
||||
const response = await fetch(url, { cache: 'no-cache' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Cannot download the assets list.');
|
||||
}
|
||||
const json = await response.json();
|
||||
if (!Array.isArray(json)) {
|
||||
throw new Error('Assets list is not an array');
|
||||
}
|
||||
await populateAssetsMenu(json);
|
||||
} catch (error) {
|
||||
// Info hint if the user maybe... likely accidentally was trying to install an extension and we wanna help guide them? uwu :3
|
||||
const installButton = $('#third_party_extension_button');
|
||||
flashHighlight(installButton, 10_000);
|
||||
toastr.info('Click the flashing button at the top right corner of the menu.', 'Trying to install a custom extension?', { timeOut: 10_000 });
|
||||
|
||||
// Error logged after, to appear on top
|
||||
console.error(error);
|
||||
toastr.error('Problem with assets URL', 'Cannot get assets list');
|
||||
$('#assets-connect-button').addClass('fa-plug-circle-exclamation');
|
||||
$('#assets-connect-button').addClass('redOverlayGlow');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Previews the asset by opening its URL. If it's an audio asset, it plays a preview sound. Otherwise, it opens the URL in a new tab.
|
||||
* @param {JQuery.Event} e Click event
|
||||
*/
|
||||
function previewAsset(e) {
|
||||
const href = $(this).attr('href');
|
||||
const audioExtensions = ['.mp3', '.ogg', '.wav'];
|
||||
@@ -281,6 +338,15 @@ function previewAsset(e) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the asset is already installed.
|
||||
* For extensions, it checks if the extension name is in the list of installed extensions.
|
||||
* For characters, it checks if any character has the same avatar URL.
|
||||
* For other asset types, it checks if any installed asset of the same type has a URL that includes the filename.
|
||||
* @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
|
||||
* @param {string} filename Name or ID of the asset
|
||||
* @returns {boolean} True if the asset is installed, false otherwise
|
||||
*/
|
||||
function isAssetInstalled(assetType, filename) {
|
||||
let assetList = currentAssets[assetType];
|
||||
|
||||
@@ -302,6 +368,13 @@ function isAssetInstalled(assetType, filename) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the asset by sending a request to the server to download it. If it's an extension, it uses the existing installExtension function.
|
||||
* @param {string} url URL of the asset to download
|
||||
* @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
|
||||
* @param {string} filename Name or ID of the asset
|
||||
* @returns {Promise<boolean>} True if the asset was successfully installed, false otherwise
|
||||
*/
|
||||
async function installAsset(url, assetType, filename) {
|
||||
console.debug(DEBUG_PREFIX, 'Downloading ', url);
|
||||
const category = assetType;
|
||||
@@ -326,7 +399,8 @@ async function installAsset(url, assetType, filename) {
|
||||
console.debug(DEBUG_PREFIX, 'Importing character ', filename);
|
||||
const blob = await result.blob();
|
||||
const file = new File([blob], filename, { type: blob.type });
|
||||
await processDroppedFiles([file]);
|
||||
const fileNameMap = new Map([[file, filename]]);
|
||||
await processDroppedFiles([file], fileNameMap);
|
||||
console.debug(DEBUG_PREFIX, 'Character downloaded.');
|
||||
}
|
||||
return true;
|
||||
@@ -338,6 +412,12 @@ async function installAsset(url, assetType, filename) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the asset by sending a request to the server to delete it. If it's an extension, it uses the existing deleteExtension function.
|
||||
* @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
|
||||
* @param {string} filename Name or ID of the asset
|
||||
* @returns {Promise<boolean>} True if the asset was successfully deleted, false otherwise
|
||||
*/
|
||||
async function deleteAsset(assetType, filename) {
|
||||
console.debug(DEBUG_PREFIX, 'Deleting ', assetType, filename);
|
||||
const category = assetType;
|
||||
@@ -346,6 +426,7 @@ async function deleteAsset(assetType, filename) {
|
||||
console.debug(DEBUG_PREFIX, 'Deleting extension ', filename);
|
||||
await deleteExtension(filename);
|
||||
console.debug(DEBUG_PREFIX, 'Extension deleted.');
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = { category, filename };
|
||||
@@ -357,19 +438,37 @@ async function deleteAsset(assetType, filename) {
|
||||
});
|
||||
if (result.ok) {
|
||||
console.debug(DEBUG_PREFIX, 'Deletion success.');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the character browser popup, which shows all available characters and allows downloading them.
|
||||
* @param {boolean} forceDefault If true, it uses the default ASSETS_JSON_URL instead of the one from the input field.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function openCharacterBrowser(forceDefault) {
|
||||
const url = forceDefault ? ASSETS_JSON_URL : String($('#assets-json-url-field').val());
|
||||
if (!isValidUrl(url)) {
|
||||
toastr.error('Please enter a valid URL');
|
||||
return;
|
||||
}
|
||||
const fetchResult = await fetch(url, { cache: 'no-cache' });
|
||||
if (!fetchResult.ok) {
|
||||
toastr.error('Cannot download the assets list.');
|
||||
return;
|
||||
}
|
||||
const json = await fetchResult.json();
|
||||
const characters = json.filter(x => x.type === 'character');
|
||||
|
||||
if (!Array.isArray(json)) {
|
||||
toastr.error('Assets list is not an array');
|
||||
return;
|
||||
}
|
||||
const characters = json.filter(x => x && x.type === 'character');
|
||||
if (!characters.length) {
|
||||
toastr.error('No characters found in the assets list', 'Character browser');
|
||||
return;
|
||||
@@ -395,7 +494,10 @@ async function openCharacterBrowser(forceDefault) {
|
||||
}
|
||||
});
|
||||
|
||||
checkMark.toggle(isInstalled);
|
||||
checkMark.toggle(isInstalled).on('click', async () => {
|
||||
toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported');
|
||||
await SlashCommandParser.commands.go.callback(null, character.id);
|
||||
});
|
||||
|
||||
listElement.append(characterElement);
|
||||
}
|
||||
@@ -449,11 +551,16 @@ export async function init() {
|
||||
|
||||
const connectButton = windowHtml.find('#assets-connect-button');
|
||||
connectButton.on('click', async function () {
|
||||
const url = DOMPurify.sanitize(String(assetsJsonUrl.val()));
|
||||
const rememberKey = `Assets_SkipConfirm_${getStringHash(url)}`;
|
||||
const urlString = String(assetsJsonUrl.val()).trim();
|
||||
if (!isValidUrl(urlString)) {
|
||||
toastr.error('Please enter a valid URL');
|
||||
return;
|
||||
}
|
||||
const url = new URL(urlString);
|
||||
const rememberKey = `Assets_SkipConfirm_${getStringHash(url.href)}`;
|
||||
const skipConfirm = accountStorage.getItem(rememberKey) === 'true';
|
||||
|
||||
const confirmation = skipConfirm || await Popup.show.confirm(t`Loading Asset List`, '<span>' + t`Are you sure you want to connect to the following url?` + `</span><var>${url}</var>`, {
|
||||
const confirmation = skipConfirm || await Popup.show.confirm(t`Loading Asset List`, '<span>' + t`Are you sure you want to connect to the following url?` + `</span><var>${escapeHtml(url.href)}</var>`, {
|
||||
customInputs: [{ id: 'assets-remember', label: 'Don\'t ask again for this URL' }],
|
||||
onClose: popup => {
|
||||
if (popup.result) {
|
||||
@@ -472,7 +579,7 @@ export async function init() {
|
||||
connectButton.addClass('fa-plug-circle-check');
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
toastr.error(`Cannot get assets list from ${url}`);
|
||||
toastr.error(`Cannot get assets list from ${url.href}`);
|
||||
connectButton.removeClass('fa-plug-circle-check');
|
||||
connectButton.addClass('fa-plug-circle-exclamation');
|
||||
connectButton.removeClass('redOverlayGlow');
|
||||
|
||||
@@ -792,6 +792,8 @@ async function setSpriteSlashCommand({ type }, searchTerm) {
|
||||
function setFallBackExpressionSlashCommand(args, expressionName) {
|
||||
expressionName = expressionName.trim().toLowerCase();
|
||||
|
||||
if (!expressionName) return extension_settings?.expressions?.fallback_expression || '';
|
||||
|
||||
const select = /** @type {HTMLSelectElement} */(document.getElementById('expression_fallback'));
|
||||
const fallbackExpressions = Array
|
||||
.from(select?.options || [])
|
||||
@@ -2341,13 +2343,13 @@ export async function init() {
|
||||
returns: 'The currently set expression label after setting it.',
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'expression-set-fallback',
|
||||
name: 'expression-fallback',
|
||||
callback: setFallBackExpressionSlashCommand,
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'expression label to set',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
isRequired: false,
|
||||
enumProvider: () => [
|
||||
new SlashCommandEnumValue('#none', 'Sets the fallback expression to no image'),
|
||||
new SlashCommandEnumValue('#emoji', 'Sets the fallback expression to emojis'),
|
||||
@@ -2355,7 +2357,25 @@ export async function init() {
|
||||
],
|
||||
}),
|
||||
],
|
||||
helpString: 'Force sets the expression fallback for all characters.',
|
||||
helpString: `
|
||||
<div>
|
||||
Gets the currently selected expression fallback for all characters.<br />
|
||||
If a valid expression label is sent, it will be set as the new fallback.
|
||||
</div>
|
||||
<div>
|
||||
<strong>Example:</strong>
|
||||
<ul>
|
||||
<li>
|
||||
<pre><code>/expression-fallback | /echo</code></pre>
|
||||
<small>Returns the currently selected fallback.</small>
|
||||
</li>
|
||||
<li>
|
||||
<pre><code>/expression-fallback admiration</code></pre>
|
||||
<small>Sets a new expression as fallback.</small>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
returns: 'The currently set expression label after setting it.',
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
|
||||
@@ -65,14 +65,20 @@ async function checkIfRepoIsUpToDate(extensionPath) {
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
// Feature flag guard: don't allow calling any of the endpoints if extensions are disabled
|
||||
router.use((_, response, next) => {
|
||||
/**
|
||||
* Feature flag guard: don't allow calling any of the endpoints if extensions are disabled
|
||||
* @type {import('express').RequestHandler}
|
||||
*/
|
||||
export const extensionsEnabledFeatureGuard = (_, response, next) => {
|
||||
const enabled = !!getConfigValue('extensions.enabled', true, 'boolean');
|
||||
if (!enabled) {
|
||||
return response.status(400).send('Bad Request: Extensions are disabled.');
|
||||
response.sendStatus(404);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
router.use(extensionsEnabledFeatureGuard);
|
||||
|
||||
/**
|
||||
* HTTP POST handler function to clone a git repository from a provided URL, read the extension manifest,
|
||||
@@ -119,6 +125,7 @@ router.post('/install', async (request, response) => {
|
||||
}
|
||||
|
||||
const extensionPath = path.join(basePath, extensionNameSanitized);
|
||||
const folderName = path.basename(extensionPath);
|
||||
|
||||
if (fs.existsSync(extensionPath)) {
|
||||
return response.status(409).send(`Directory already exists at ${extensionPath}`);
|
||||
@@ -131,10 +138,17 @@ router.post('/install', async (request, response) => {
|
||||
await git.clone(parsedUrl.href, extensionPath, cloneOptions);
|
||||
console.info(`Extension has been cloned to ${extensionPath} from ${parsedUrl.href} at ${branch || '(default)'} branch`);
|
||||
|
||||
const { version, author, display_name } = await getManifest(extensionPath);
|
||||
const folderName = path.basename(extensionPath);
|
||||
|
||||
return response.send({ version, author, display_name, extensionPath, folderName });
|
||||
try {
|
||||
const manifest = await getManifest(extensionPath);
|
||||
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
||||
throw new Error('Manifest is not a valid JSON object.');
|
||||
}
|
||||
const { version, author, display_name } = manifest;
|
||||
return response.send({ version, author, display_name, extensionPath, folderName });
|
||||
} catch (manifestError) {
|
||||
await fs.promises.rm(extensionPath, { recursive: true, force: true });
|
||||
throw manifestError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Importing extension failed', error);
|
||||
return response.status(500).send('Internal Server Error. Check the server logs for more details.');
|
||||
|
||||
@@ -1093,7 +1093,7 @@ router.post('/remote/textgenerationwebui/encode', async function (request, respo
|
||||
switch (request.body.api_type) {
|
||||
case TEXTGEN_TYPES.TABBY:
|
||||
url += '/v1/token/encode';
|
||||
args.body = JSON.stringify({ 'text': text, 'add_bos_token': false });
|
||||
args.body = JSON.stringify({ 'text': text, 'add_bos_token': false, 'encode_special_tokens': false });
|
||||
break;
|
||||
case TEXTGEN_TYPES.KOBOLDCPP:
|
||||
url += '/api/extra/tokencount';
|
||||
|
||||
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
|
||||
import storage from 'node-persist';
|
||||
import express from 'express';
|
||||
|
||||
import { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist, toAvatarKey } from '../users.js';
|
||||
import { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist, toAvatarKey, getAccountVersion } from '../users.js';
|
||||
import { SETTINGS_FILE } from '../constants.js';
|
||||
import { checkForNewContent, CONTENT_TYPES } from './content-manager.js';
|
||||
import { color, Cache, getConfigValue } from '../util.js';
|
||||
@@ -23,6 +23,7 @@ router.post('/logout', async (request, response) => {
|
||||
|
||||
request.session.handle = null;
|
||||
request.session.csrfToken = null;
|
||||
request.session.version = null;
|
||||
request.session = null;
|
||||
return response.sendStatus(204);
|
||||
} catch (error) {
|
||||
@@ -129,6 +130,12 @@ router.post('/change-password', async (request, response) => {
|
||||
}
|
||||
|
||||
await storage.setItem(toKey(request.body.handle), user);
|
||||
|
||||
// Update session version to keep the current session valid after password change
|
||||
if (request.session && request.session.handle === user.handle) {
|
||||
request.session.version = getAccountVersion(user);
|
||||
}
|
||||
|
||||
return response.sendStatus(204);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -3,23 +3,25 @@ import crypto from 'node:crypto';
|
||||
import storage from 'node-persist';
|
||||
import express from 'express';
|
||||
import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
|
||||
import { getIpFromRequest, getRealIpFromHeader } from '../express-common.js';
|
||||
import { getIpAddress, retryAfter } from '../express-common.js';
|
||||
import { color, Cache, getConfigValue } from '../util.js';
|
||||
import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js';
|
||||
import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt, getAccountVersion } from '../users.js';
|
||||
|
||||
const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false, 'boolean');
|
||||
const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false, 'boolean');
|
||||
const LOGIN_POINTS = getConfigValue('rateLimiting.accountsLoginMaxAttempts', 5, 'number');
|
||||
const RECOVER_POINTS = getConfigValue('rateLimiting.accountsRecoverMaxAttempts', 5, 'number');
|
||||
const MFA_CACHE = new Cache(5 * 60 * 1000);
|
||||
|
||||
const getIpAddress = (request) => PREFER_REAL_IP_HEADER ? getRealIpFromHeader(request) : getIpFromRequest(request);
|
||||
const generateRecoveryCode = () => Array.from({ length: 6 }, () => crypto.randomInt(0, 10)).join('');
|
||||
|
||||
export const router = express.Router();
|
||||
const loginLimiter = new RateLimiterMemory({
|
||||
points: 5,
|
||||
points: LOGIN_POINTS > 0 ? LOGIN_POINTS : Number.MAX_SAFE_INTEGER,
|
||||
duration: 60,
|
||||
});
|
||||
const recoverLimiter = new RateLimiterMemory({
|
||||
points: 5,
|
||||
points: RECOVER_POINTS > 0 ? RECOVER_POINTS : Number.MAX_SAFE_INTEGER,
|
||||
duration: 300,
|
||||
});
|
||||
|
||||
@@ -63,7 +65,7 @@ router.post('/login', async (request, response) => {
|
||||
return response.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const ip = getIpAddress(request);
|
||||
const ip = getIpAddress(request, PREFER_REAL_IP_HEADER);
|
||||
await loginLimiter.consume(ip);
|
||||
|
||||
/** @type {import('../users.js').User} */
|
||||
@@ -91,12 +93,13 @@ router.post('/login', async (request, response) => {
|
||||
|
||||
await loginLimiter.delete(ip);
|
||||
request.session.handle = user.handle;
|
||||
request.session.version = getAccountVersion(user);
|
||||
console.info('Login successful:', user.handle, 'from', ip, 'at', new Date().toLocaleString());
|
||||
return response.json({ handle: user.handle });
|
||||
} catch (error) {
|
||||
if (error instanceof RateLimiterRes) {
|
||||
console.error('Login failed: Rate limited from', getIpAddress(request));
|
||||
return response.status(429).send({ error: 'Too many attempts. Try again later or recover your password.' });
|
||||
console.error('Login failed: Rate limited from', getIpAddress(request, PREFER_REAL_IP_HEADER));
|
||||
return retryAfter(response, error).status(429).send({ error: 'Too many attempts. Try again later or recover your password.' });
|
||||
}
|
||||
|
||||
console.error('Login failed:', error);
|
||||
@@ -111,7 +114,7 @@ router.post('/recover-step1', async (request, response) => {
|
||||
return response.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const ip = getIpAddress(request);
|
||||
const ip = getIpAddress(request, PREFER_REAL_IP_HEADER);
|
||||
await recoverLimiter.consume(ip);
|
||||
|
||||
/** @type {import('../users.js').User} */
|
||||
@@ -127,7 +130,7 @@ router.post('/recover-step1', async (request, response) => {
|
||||
return response.status(403).json({ error: 'User is disabled' });
|
||||
}
|
||||
|
||||
const mfaCode = String(crypto.randomInt(1000, 9999));
|
||||
const mfaCode = generateRecoveryCode();
|
||||
console.log();
|
||||
console.log(color.blue(`${user.name}, your password recovery code is: `) + color.magenta(mfaCode));
|
||||
console.log();
|
||||
@@ -135,8 +138,8 @@ router.post('/recover-step1', async (request, response) => {
|
||||
return response.sendStatus(204);
|
||||
} catch (error) {
|
||||
if (error instanceof RateLimiterRes) {
|
||||
console.error('Recover step 1 failed: Rate limited from', getIpAddress(request));
|
||||
return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
|
||||
console.error('Recover step 1 failed: Rate limited from', getIpAddress(request, PREFER_REAL_IP_HEADER));
|
||||
return retryAfter(response, error).status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
|
||||
}
|
||||
|
||||
console.error('Recover step 1 failed:', error);
|
||||
@@ -153,7 +156,12 @@ router.post('/recover-step2', async (request, response) => {
|
||||
|
||||
/** @type {import('../users.js').User} */
|
||||
const user = await storage.getItem(toKey(request.body.handle));
|
||||
const ip = getIpAddress(request);
|
||||
const ip = getIpAddress(request, PREFER_REAL_IP_HEADER);
|
||||
const rateLimit = await recoverLimiter.get(ip);
|
||||
|
||||
if (rateLimit !== null && rateLimit.consumedPoints > recoverLimiter.points) {
|
||||
throw rateLimit;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
console.error('Recover step 2 failed: User', request.body.handle, 'not found');
|
||||
@@ -184,13 +192,17 @@ router.post('/recover-step2', async (request, response) => {
|
||||
await storage.setItem(toKey(user.handle), user);
|
||||
}
|
||||
|
||||
if (request.session && request.session.handle === user.handle) {
|
||||
request.session.version = getAccountVersion(user);
|
||||
}
|
||||
|
||||
await recoverLimiter.delete(ip);
|
||||
MFA_CACHE.remove(user.handle);
|
||||
return response.sendStatus(204);
|
||||
} catch (error) {
|
||||
if (error instanceof RateLimiterRes) {
|
||||
console.error('Recover step 2 failed: Rate limited from', getIpAddress(request));
|
||||
return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
|
||||
console.error('Recover step 2 failed: Rate limited from', getIpAddress(request, PREFER_REAL_IP_HEADER));
|
||||
return retryAfter(response, error).status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
|
||||
}
|
||||
|
||||
console.error('Recover step 2 failed:', error);
|
||||
|
||||
+53
-7
@@ -1,5 +1,7 @@
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import ipMatching from 'ip-matching';
|
||||
import { RateLimiterRes } from 'rate-limiter-flexible';
|
||||
import { getConfigValue } from './util.js';
|
||||
|
||||
const noopMiddleware = (_req, _res, next) => next();
|
||||
/** @deprecated Do not use. A global middleware is provided at the application level. */
|
||||
@@ -29,17 +31,46 @@ export function getIpFromRequest(req) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the IP address of the client when behind reverse proxy using x-real-ip header, falls back to socket remote address.
|
||||
* This function should be used when the application is running behind a reverse proxy (e.g., Nginx, traefik, Caddy...).
|
||||
* @param {import('express').Request} req Request object
|
||||
* @returns {string} IP address of the client
|
||||
* Get the client IP address from the request headers.
|
||||
* @param {import('express').Request} req Express request object
|
||||
* @returns {string|undefined} The client IP address
|
||||
*/
|
||||
export function getRealIpFromHeader(req) {
|
||||
if (req.headers['x-real-ip']) {
|
||||
export function getRealOrForwardedIp(req) {
|
||||
const xRealIpEnabled = !!getConfigValue('forwardedHeaders.xRealIp', true, 'boolean');
|
||||
const cfConnectingIpEnabled = !!getConfigValue('forwardedHeaders.cfConnectingIp', false, 'boolean');
|
||||
const xForwardedForEnabled = !!getConfigValue('forwardedHeaders.xForwardedFor', true, 'boolean');
|
||||
|
||||
// Check if X-Real-IP is available
|
||||
if (req.headers['x-real-ip'] && xRealIpEnabled) {
|
||||
return req.headers['x-real-ip'].toString();
|
||||
}
|
||||
|
||||
return getIpFromRequest(req);
|
||||
// Check for CF-Connecting-IP (Cloudflare) if available
|
||||
if (req.headers['cf-connecting-ip'] && cfConnectingIpEnabled) {
|
||||
return req.headers['cf-connecting-ip'].toString();
|
||||
}
|
||||
|
||||
// Check for X-Forwarded-For and parse if available
|
||||
if (req.headers['x-forwarded-for'] && xForwardedForEnabled) {
|
||||
const ipList = req.headers['x-forwarded-for'].toString().split(',').map(ip => ip.trim());
|
||||
return ipList[0];
|
||||
}
|
||||
|
||||
// If none of the headers are available, return undefined
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the IP address of the client, optionally including the real/forwarded IP from headers.
|
||||
* Most common use cases: key for rate limiter, logging, etc. where you want to have the real client IP if behind a reverse proxy.
|
||||
* @param {import('express').Request} request Request object
|
||||
* @param {boolean} includeHeaderIp Whether to include the real/forwarded IP from headers
|
||||
* @returns {string} IP address of the client (will include "forwarded" info if includeHeaderIp is true and headers are present)
|
||||
*/
|
||||
export function getIpAddress(request, includeHeaderIp) {
|
||||
const socketIp = getIpFromRequest(request);
|
||||
const forwardedIp = includeHeaderIp && getRealOrForwardedIp(request);
|
||||
return forwardedIp ? `${socketIp} (forwarded: ${forwardedIp})` : socketIp;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,3 +110,18 @@ export function filterValidIpPatterns(entries, formatLog) {
|
||||
|
||||
return validEntries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Retry-After header on the response based on the rate limit information.
|
||||
* @param {import('express').Response} response Express response object
|
||||
* @param {RateLimiterRes} rateLimit The rate limit information from rate-limiter-flexible
|
||||
* @returns {import('express').Response} The response object with the Retry-After header set if applicable
|
||||
*/
|
||||
export function retryAfter(response, rateLimit) {
|
||||
if (response.headersSent || !(rateLimit instanceof RateLimiterRes)) {
|
||||
return response;
|
||||
}
|
||||
const retryAfter = Math.ceil(rateLimit.msBeforeNext / 1000);
|
||||
response.set('Retry-After', retryAfter.toString());
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { getRealIpFromHeader } from '../express-common.js';
|
||||
import { getIpAddress } from '../express-common.js';
|
||||
import { color, getConfigValue } from '../util.js';
|
||||
|
||||
const enableAccessLog = getConfigValue('logging.enableAccessLog', true, 'boolean');
|
||||
@@ -32,7 +32,7 @@ export function migrateAccessLog() {
|
||||
*/
|
||||
export default function accessLoggerMiddleware() {
|
||||
return function (req, res, next) {
|
||||
const clientIp = getRealIpFromHeader(req);
|
||||
const clientIp = getIpAddress(req, true);
|
||||
const userAgent = req.headers['user-agent'];
|
||||
|
||||
if (!knownIPs.has(clientIp)) {
|
||||
|
||||
+58
-28
@@ -5,53 +5,83 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import path from 'node:path';
|
||||
import storage from 'node-persist';
|
||||
import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
|
||||
import { getAllUserHandles, toKey, getPasswordHash } from '../users.js';
|
||||
import { getConfigValue, safeReadFileSync } from '../util.js';
|
||||
import { getIpAddress, retryAfter } from '../express-common.js';
|
||||
|
||||
const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean');
|
||||
const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false, 'boolean');
|
||||
const PER_USER_BASIC_AUTH = !!getConfigValue('perUserBasicAuth', false, 'boolean');
|
||||
const ENABLE_ACCOUNTS = !!getConfigValue('enableUserAccounts', false, 'boolean');
|
||||
const PREFER_REAL_IP_HEADER = !!getConfigValue('rateLimiting.preferRealIpHeader', false, 'boolean');
|
||||
const BASIC_AUTH_ATTEMPTS = getConfigValue('rateLimiting.basicAuthMaxAttempts', 5, 'number');
|
||||
|
||||
const basicAuthLimiter = new RateLimiterMemory({
|
||||
points: BASIC_AUTH_ATTEMPTS > 0 ? BASIC_AUTH_ATTEMPTS : Number.MAX_SAFE_INTEGER,
|
||||
duration: 60,
|
||||
});
|
||||
|
||||
const basicAuthMiddleware = async function (request, response, callback) {
|
||||
const unauthorizedWebpage = safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'unauthorized.html')) ?? '';
|
||||
const unauthorizedResponse = (res) => {
|
||||
const unauthorizedWebpage = safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'unauthorized.html')) ?? '';
|
||||
res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');
|
||||
return res.status(401).send(unauthorizedWebpage);
|
||||
};
|
||||
|
||||
const basicAuthUserName = getConfigValue('basicAuthUser.username');
|
||||
const basicAuthUserPassword = getConfigValue('basicAuthUser.password');
|
||||
const authHeader = request.headers.authorization;
|
||||
try {
|
||||
const ip = getIpAddress(request, PREFER_REAL_IP_HEADER);
|
||||
|
||||
if (!authHeader) {
|
||||
return unauthorizedResponse(response);
|
||||
}
|
||||
const basicAuthUserName = getConfigValue('basicAuthUser.username');
|
||||
const basicAuthUserPassword = getConfigValue('basicAuthUser.password');
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
const [scheme, credentials] = authHeader.split(' ');
|
||||
if (!authHeader) {
|
||||
return unauthorizedResponse(response);
|
||||
}
|
||||
|
||||
if (scheme !== 'Basic' || !credentials) {
|
||||
return unauthorizedResponse(response);
|
||||
}
|
||||
const [scheme, credentials] = authHeader.split(' ');
|
||||
|
||||
const usePerUserAuth = PER_USER_BASIC_AUTH && ENABLE_ACCOUNTS;
|
||||
const [username, ...passwordParts] = Buffer.from(credentials, 'base64')
|
||||
.toString('utf8')
|
||||
.split(':');
|
||||
const password = passwordParts.join(':');
|
||||
if (scheme !== 'Basic' || !credentials) {
|
||||
return unauthorizedResponse(response);
|
||||
}
|
||||
|
||||
if (!usePerUserAuth && username === basicAuthUserName && password === basicAuthUserPassword) {
|
||||
return callback();
|
||||
} else if (usePerUserAuth) {
|
||||
const userHandles = await getAllUserHandles();
|
||||
for (const userHandle of userHandles) {
|
||||
if (username === userHandle) {
|
||||
const user = await storage.getItem(toKey(userHandle));
|
||||
if (user && user.enabled && (user.password && user.password === getPasswordHash(password, user.salt))) {
|
||||
return callback();
|
||||
const rateLimit = await basicAuthLimiter.get(ip);
|
||||
|
||||
if (rateLimit !== null && rateLimit.consumedPoints > basicAuthLimiter.points) {
|
||||
throw rateLimit;
|
||||
}
|
||||
|
||||
const usePerUserAuth = PER_USER_BASIC_AUTH && ENABLE_ACCOUNTS;
|
||||
const [username, ...passwordParts] = Buffer.from(credentials, 'base64')
|
||||
.toString('utf8')
|
||||
.split(':');
|
||||
const password = passwordParts.join(':');
|
||||
|
||||
if (!usePerUserAuth && username === basicAuthUserName && password === basicAuthUserPassword) {
|
||||
await basicAuthLimiter.delete(ip);
|
||||
return callback();
|
||||
} else if (usePerUserAuth) {
|
||||
const userHandles = await getAllUserHandles();
|
||||
for (const userHandle of userHandles) {
|
||||
if (username === userHandle) {
|
||||
const user = await storage.getItem(toKey(userHandle));
|
||||
if (user && user.enabled && (user.password && user.password === getPasswordHash(password, user.salt))) {
|
||||
await basicAuthLimiter.delete(ip);
|
||||
return callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await basicAuthLimiter.consume(ip);
|
||||
return unauthorizedResponse(response);
|
||||
} catch (error) {
|
||||
if (error instanceof RateLimiterRes) {
|
||||
console.error('Basic auth failed: Rate limited from', getIpAddress(request, PREFER_REAL_IP_HEADER), request.method, request.originalUrl);
|
||||
return retryAfter(response, error).sendStatus(429);
|
||||
}
|
||||
console.error('Basic auth error:', error);
|
||||
return response.sendStatus(500);
|
||||
}
|
||||
return unauthorizedResponse(response);
|
||||
};
|
||||
|
||||
export default basicAuthMiddleware;
|
||||
|
||||
@@ -6,7 +6,7 @@ import Handlebars from 'handlebars';
|
||||
import ipMatching from 'ip-matching';
|
||||
import isDocker from 'is-docker';
|
||||
|
||||
import { filterValidIpPatterns, getIpFromRequest } from '../express-common.js';
|
||||
import { filterValidIpPatterns, getIpFromRequest, getRealOrForwardedIp } from '../express-common.js';
|
||||
import { color, getConfigValue, safeReadFileSync } from '../util.js';
|
||||
|
||||
const whitelistPath = path.join(process.cwd(), './whitelist.txt');
|
||||
@@ -28,31 +28,6 @@ if (fs.existsSync(whitelistPath)) {
|
||||
|
||||
whitelist = filterValidIpPatterns(whitelist, (entry, message) => `${color.red('Warning')}: Ignoring invalid whitelist entry ${color.yellow(entry)} - ${message}`);
|
||||
|
||||
/**
|
||||
* Get the client IP address from the request headers.
|
||||
* @param {import('express').Request} req Express request object
|
||||
* @returns {string|undefined} The client IP address
|
||||
*/
|
||||
function getForwardedIp(req) {
|
||||
if (!enableForwardedWhitelist) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check if X-Real-IP is available
|
||||
if (req.headers['x-real-ip']) {
|
||||
return req.headers['x-real-ip'].toString();
|
||||
}
|
||||
|
||||
// Check for X-Forwarded-For and parse if available
|
||||
if (req.headers['x-forwarded-for']) {
|
||||
const ipList = req.headers['x-forwarded-for'].toString().split(',').map(ip => ip.trim());
|
||||
return ipList[0];
|
||||
}
|
||||
|
||||
// If none of the headers are available, return undefined
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the IP addresses of Docker hostnames and adds them to the whitelist.
|
||||
* @returns {Promise<void>} Promise that resolves when the Docker hostnames are resolved
|
||||
@@ -92,7 +67,7 @@ export default async function getWhitelistMiddleware() {
|
||||
|
||||
return function (req, res, next) {
|
||||
const clientIp = getIpFromRequest(req);
|
||||
const forwardedIp = getForwardedIp(req);
|
||||
const forwardedIp = enableForwardedWhitelist && getRealOrForwardedIp(req);
|
||||
const userAgent = req.headers['user-agent'];
|
||||
|
||||
/**
|
||||
@@ -107,7 +82,7 @@ export default async function getWhitelistMiddleware() {
|
||||
|
||||
//clientIp = req.connection.remoteAddress.split(':').pop();
|
||||
if (!isIPInWhitelist(whitelist, clientIp)
|
||||
|| forwardedIp && !isIPInWhitelist(whitelist, forwardedIp)
|
||||
|| (forwardedIp && !isIPInWhitelist(whitelist, forwardedIp))
|
||||
) {
|
||||
// Log the connection attempt with real IP address
|
||||
const ipDetails = forwardedIp
|
||||
|
||||
+30
-1
@@ -22,6 +22,7 @@ import { allowKeysExposure, readSecret, writeSecret, SECRETS_FILE } from './endp
|
||||
import { getContentOfType } from './endpoints/content-manager.js';
|
||||
import { serverDirectory } from './server-directory.js';
|
||||
import { filterValidIpPatterns, getIpFromRequest } from './express-common.js';
|
||||
import { extensionsEnabledFeatureGuard } from './endpoints/extensions.js';
|
||||
|
||||
export const KEY_PREFIX = 'user:';
|
||||
const AVATAR_PREFIX = 'avatar:';
|
||||
@@ -788,6 +789,7 @@ async function singleUserLogin(request) {
|
||||
const user = await storage.getItem(toKey(userHandles[0]));
|
||||
if (user && !user.password) {
|
||||
request.session.handle = userHandles[0];
|
||||
request.session.version = getAccountVersion(user);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -882,6 +884,7 @@ async function headerUserLogin(request, header = 'Remote-User') {
|
||||
const user = await storage.getItem(toKey(userHandle));
|
||||
if (user && user.enabled) {
|
||||
request.session.handle = userHandle;
|
||||
request.session.version = getAccountVersion(user);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -923,6 +926,7 @@ async function basicUserLogin(request) {
|
||||
// Verify pass again here just to be sure
|
||||
if (user && user.enabled && user.password && user.password === getPasswordHash(password, user.salt)) {
|
||||
request.session.handle = userHandle;
|
||||
request.session.version = getAccountVersion(user);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -931,6 +935,17 @@ async function basicUserLogin(request) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the account version tag for the provided user.
|
||||
* @param {User} user User account object
|
||||
* @returns {string} Account version tag
|
||||
*/
|
||||
export function getAccountVersion(user) {
|
||||
return crypto.createHash('shake256', { outputLength: 8 })
|
||||
.update(JSON.stringify([user.handle, user.password, user.salt]))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to add user data to the request object.
|
||||
* @param {import('express').Request} request Request object
|
||||
@@ -975,6 +990,20 @@ export async function setUserDataMiddleware(request, response, next) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (Object.hasOwn(request.session, 'version')) {
|
||||
if (request.session.version !== getAccountVersion(user)) {
|
||||
console.warn('User data has changed since the session was created. Invalidating session for user:', handle);
|
||||
request.session.handle = null;
|
||||
request.session.csrfToken = null;
|
||||
request.session.version = null;
|
||||
request.session = null;
|
||||
return response.sendStatus(403);
|
||||
}
|
||||
} else {
|
||||
// If there is no version in the session, it means it's an old session. Upgrade it by adding the version.
|
||||
request.session.version = getAccountVersion(user);
|
||||
}
|
||||
|
||||
const directories = getUserDirectories(handle);
|
||||
request.user = {
|
||||
profile: user,
|
||||
@@ -1187,4 +1216,4 @@ router.use('/User%20Avatars/*', createRouteHandler(req => req.user.directories.a
|
||||
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/*', createExtensionsRouteHandler(req => req.user.directories.extensions));
|
||||
router.use('/scripts/extensions/third-party/*', extensionsEnabledFeatureGuard, createExtensionsRouteHandler(req => req.user.directories.extensions));
|
||||
|
||||
Generated
-4
@@ -54,7 +54,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.7.tgz",
|
||||
"integrity": "sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.24.7",
|
||||
@@ -1199,7 +1198,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
|
||||
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1447,7 +1445,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001629",
|
||||
"electron-to-chromium": "^1.4.796",
|
||||
@@ -1787,7 +1784,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
|
||||
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
|
||||
Reference in New Issue
Block a user