diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f5b4899709..d5cc621137 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -242,8 +242,7 @@ jobs: release-pull-request: if: | - github.event_name == 'pull_request' && - github.event.pull_request.base.ref == 'main' + github.event_name == 'pull_request' runs-on: ubuntu-latest needs: [build-plugin] steps: diff --git a/api/dev/Unraid.net/myservers.cfg b/api/dev/Unraid.net/myservers.cfg index e76722fdd1..18976e3fd6 100644 --- a/api/dev/Unraid.net/myservers.cfg +++ b/api/dev/Unraid.net/myservers.cfg @@ -2,8 +2,6 @@ version="3.11.0" extraOrigins="https://google.com,https://test.com" [local] -[notifier] -apikey="unnotify_30994bfaccf839c65bae75f7fa12dd5ee16e69389f754c3b98ed7d5" [remote] wanaccess="yes" wanport="8443" @@ -14,9 +12,8 @@ email="test@example.com" username="zspearmint" avatar="https://via.placeholder.com/200" regWizTime="1611175408732_0951-1653-3509-FBA155FA23C0" -idtoken="" accesstoken="" +idtoken="" refreshtoken="" dynamicRemoteAccessType="DISABLED" -[upc] -apikey="unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810" +ssoSubIds="" diff --git a/api/dev/states/myservers.cfg b/api/dev/states/myservers.cfg index 621129eaa1..1af7aba986 100644 --- a/api/dev/states/myservers.cfg +++ b/api/dev/states/myservers.cfg @@ -2,8 +2,6 @@ version="3.11.0" extraOrigins="https://google.com,https://test.com" [local] -[notifier] -apikey="unnotify_30994bfaccf839c65bae75f7fa12dd5ee16e69389f754c3b98ed7d5" [remote] wanaccess="yes" wanport="8443" @@ -11,15 +9,15 @@ upnpEnabled="no" apikey="_______________________BIG_API_KEY_HERE_________________________" localApiKey="_______________________LOCAL_API_KEY_HERE_________________________" email="test@example.com" -username="zspearmint" -avatar="https://via.placeholder.com/200" +username="https://via.placeholder.com/200" +avatar="" regWizTime="1611175408732_0951-1653-3509-FBA155FA23C0" -idtoken="" accesstoken="" +idtoken="" refreshtoken="" -allowedOrigins="/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, http://localhost:8080, https://localhost:4443, https://tower.local:4443, https://192.168.1.150:4443, https://tower:4443, https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443, https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443, https://10-252-0-1.hash.myunraid.net:4443, https://10-252-1-1.hash.myunraid.net:4443, https://10-253-3-1.hash.myunraid.net:4443, https://10-253-4-1.hash.myunraid.net:4443, https://10-253-5-1.hash.myunraid.net:4443, https://10-100-0-1.hash.myunraid.net:4443, https://10-100-0-2.hash.myunraid.net:4443, https://10-123-1-2.hash.myunraid.net:4443, https://221-123-121-112.hash.myunraid.net:4443, https://google.com, https://test.com, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000, https://studio.apollographql.com" dynamicRemoteAccessType="DISABLED" -[upc] -apikey="unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810" +ssoSubIds="" +allowedOrigins="/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, http://localhost:8080, https://localhost:4443, https://tower.local:4443, https://192.168.1.150:4443, https://tower:4443, https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443, https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443, https://10-252-0-1.hash.myunraid.net:4443, https://10-252-1-1.hash.myunraid.net:4443, https://10-253-3-1.hash.myunraid.net:4443, https://10-253-4-1.hash.myunraid.net:4443, https://10-253-5-1.hash.myunraid.net:4443, https://10-100-0-1.hash.myunraid.net:4443, https://10-100-0-2.hash.myunraid.net:4443, https://10-123-1-2.hash.myunraid.net:4443, https://221-123-121-112.hash.myunraid.net:4443, https://google.com, https://test.com, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000, https://studio.apollographql.com" [connectionStatus] minigraph="PRE_INIT" +upnpStatus="" diff --git a/api/package-lock.json b/api/package-lock.json index 83212d8029..e827371d7d 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -49,6 +49,7 @@ "express": "^4.21.1", "filenamify": "^6.0.0", "fs-extra": "^11.2.0", + "glob": "^11.0.1", "global-agent": "^3.0.0", "got": "^14.4.4", "graphql": "^16.9.0", @@ -573,6 +574,27 @@ "node": ">=8" } }, + "node_modules/@ardatan/relay-compiler/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@ardatan/relay-compiler/node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -3285,7 +3307,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -3302,7 +3323,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, "engines": { "node": ">=12" }, @@ -3314,7 +3334,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "engines": { "node": ">=12" }, @@ -3325,14 +3344,12 @@ "node_modules/@isaacs/cliui/node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -3349,7 +3366,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -3364,7 +3380,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -7028,6 +7043,28 @@ "node": ">=10" } }, + "node_modules/commitizen/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/common-tags": { "version": "1.8.2", "license": "MIT", @@ -8215,8 +8252,7 @@ "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, "node_modules/ecc-jsbn": { "version": "0.1.2", @@ -9652,7 +9688,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dev": true, "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" @@ -9668,7 +9703,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "engines": { "node": ">=14" }, @@ -9738,6 +9772,8 @@ }, "node_modules/fs.realpath": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, "node_modules/fsevents": { @@ -10024,18 +10060,23 @@ "license": "ISC" }, "node_modules/glob": { - "version": "7.2.3", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz", + "integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==", "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^4.0.1", + "minimatch": "^10.0.0", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "engines": { - "node": "*" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -10051,6 +10092,55 @@ "node": ">= 6" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/lru-cache": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz", + "integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/global-agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", @@ -10753,6 +10843,9 @@ }, "node_modules/inflight": { "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -11125,6 +11218,21 @@ "node": ">=6" } }, + "node_modules/jackspeak": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.2.tgz", + "integrity": "sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jiti": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", @@ -12005,7 +12113,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -12831,8 +12938,7 @@ "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" }, "node_modules/pako": { "version": "0.2.9", @@ -12983,6 +13089,8 @@ }, "node_modules/path-is-absolute": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14960,7 +15068,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -14990,7 +15097,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -16418,7 +16524,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -16913,6 +17018,19 @@ "path-exists": "^4.0.0" } }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, "locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -18702,7 +18820,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "requires": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -18715,26 +18832,22 @@ "ansi-regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==" }, "ansi-styles": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==" }, "emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, "string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "requires": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -18745,7 +18858,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "requires": { "ansi-regex": "^6.0.1" } @@ -18754,7 +18866,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "requires": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -21193,6 +21304,20 @@ "jsonfile": "^6.0.1", "universalify": "^2.0.0" } + }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } } } }, @@ -21986,8 +22111,7 @@ "eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, "ecc-jsbn": { "version": "0.1.2", @@ -22909,7 +23033,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dev": true, "requires": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" @@ -22918,8 +23041,7 @@ "signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" } } }, @@ -22961,7 +23083,9 @@ } }, "fs.realpath": { - "version": "1.0.0" + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "fsevents": { "version": "2.3.3", @@ -23170,14 +23294,48 @@ } }, "glob": { - "version": "7.2.3", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz", + "integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==", "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^4.0.1", + "minimatch": "^10.0.0", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, + "lru-cache": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz", + "integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==" + }, + "minimatch": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "requires": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + } + } } }, "glob-parent": { @@ -23647,6 +23805,8 @@ }, "inflight": { "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "requires": { "once": "^1.3.0", "wrappy": "1" @@ -23896,6 +24056,14 @@ "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==" }, + "jackspeak": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.2.tgz", + "integrity": "sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==", + "requires": { + "@isaacs/cliui": "^8.0.2" + } + }, "jiti": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", @@ -24455,8 +24623,7 @@ "minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==" }, "mkdirp": { "version": "1.0.4", @@ -25004,8 +25171,7 @@ "package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" }, "pako": { "version": "0.2.9", @@ -25110,7 +25276,9 @@ } }, "path-is-absolute": { - "version": "1.0.1" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" }, "path-key": { "version": "3.1.1" @@ -26461,7 +26629,6 @@ "version": "npm:string-width@4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "requires": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -26482,7 +26649,6 @@ "version": "npm:strip-ansi@6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "requires": { "ansi-regex": "^5.0.1" } @@ -27328,7 +27494,6 @@ "version": "npm:wrap-ansi@7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "requires": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", diff --git a/api/package.json b/api/package.json index bf2cdbd3d8..b5f093cc2f 100644 --- a/api/package.json +++ b/api/package.json @@ -81,6 +81,7 @@ "express": "^4.21.1", "filenamify": "^6.0.0", "fs-extra": "^11.2.0", + "glob": "^11.0.1", "global-agent": "^3.0.0", "got": "^14.4.4", "graphql": "^16.9.0", diff --git a/api/src/__test__/common/allowed-origins.test.ts b/api/src/__test__/common/allowed-origins.test.ts index 07de93fa93..04e45b88b2 100644 --- a/api/src/__test__/common/allowed-origins.test.ts +++ b/api/src/__test__/common/allowed-origins.test.ts @@ -18,31 +18,31 @@ test('Returns allowed origins', async () => { // Get allowed origins expect(getAllowedOrigins()).toMatchInlineSnapshot(` - [ - "/var/run/unraid-notifications.sock", - "/var/run/unraid-php.sock", - "/var/run/unraid-cli.sock", - "http://localhost:8080", - "https://localhost:4443", - "https://tower.local:4443", - "https://192.168.1.150:4443", - "https://tower:4443", - "https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443", - "https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443", - "https://10-252-0-1.hash.myunraid.net:4443", - "https://10-252-1-1.hash.myunraid.net:4443", - "https://10-253-3-1.hash.myunraid.net:4443", - "https://10-253-4-1.hash.myunraid.net:4443", - "https://10-253-5-1.hash.myunraid.net:4443", - "https://10-100-0-1.hash.myunraid.net:4443", - "https://10-100-0-2.hash.myunraid.net:4443", - "https://10-123-1-2.hash.myunraid.net:4443", - "https://221-123-121-112.hash.myunraid.net:4443", - "https://google.com", - "https://test.com", - "https://connect.myunraid.net", - "https://connect-staging.myunraid.net", - "https://dev-my.myunraid.net:4000", - ] - `); + [ + "/var/run/unraid-notifications.sock", + "/var/run/unraid-php.sock", + "/var/run/unraid-cli.sock", + "http://localhost:8080", + "https://localhost:4443", + "https://tower.local:4443", + "https://192.168.1.150:4443", + "https://tower:4443", + "https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443", + "https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443", + "https://10-252-0-1.hash.myunraid.net:4443", + "https://10-252-1-1.hash.myunraid.net:4443", + "https://10-253-3-1.hash.myunraid.net:4443", + "https://10-253-4-1.hash.myunraid.net:4443", + "https://10-253-5-1.hash.myunraid.net:4443", + "https://10-100-0-1.hash.myunraid.net:4443", + "https://10-100-0-2.hash.myunraid.net:4443", + "https://10-123-1-2.hash.myunraid.net:4443", + "https://221-123-121-112.hash.myunraid.net:4443", + "https://google.com", + "https://test.com", + "https://connect.myunraid.net", + "https://connect-staging.myunraid.net", + "https://dev-my.myunraid.net:4000", + ] + `); }); diff --git a/api/src/__test__/core/utils/files/config-file-normalizer.test.ts b/api/src/__test__/core/utils/files/config-file-normalizer.test.ts index 5a1d2c4506..c3fea33220 100644 --- a/api/src/__test__/core/utils/files/config-file-normalizer.test.ts +++ b/api/src/__test__/core/utils/files/config-file-normalizer.test.ts @@ -10,72 +10,65 @@ test('it creates a FLASH config with NO OPTIONAL values', () => { const basicConfig = initialState; const config = getWriteableConfig(basicConfig, 'flash'); expect(config).toMatchInlineSnapshot(` - { - "api": { - "extraOrigins": "", - "version": "", - }, - "local": {}, - "notifier": { - "apikey": "", - }, - "remote": { - "accesstoken": "", - "apikey": "", - "avatar": "", - "dynamicRemoteAccessType": "DISABLED", - "email": "", - "idtoken": "", - "localApiKey": "", - "refreshtoken": "", - "regWizTime": "", - "username": "", - "wanaccess": "", - "wanport": "", - }, - "upc": { - "apikey": "", - }, - } - `); + { + "api": { + "extraOrigins": "", + "version": "", + }, + "local": {}, + "remote": { + "accesstoken": "", + "apikey": "", + "avatar": "", + "dynamicRemoteAccessType": "DISABLED", + "email": "", + "idtoken": "", + "localApiKey": "", + "refreshtoken": "", + "regWizTime": "", + "ssoSubIds": "", + "upnpEnabled": "", + "username": "", + "wanaccess": "", + "wanport": "", + }, + } + `); }); test('it creates a MEMORY config with NO OPTIONAL values', () => { const basicConfig = initialState; const config = getWriteableConfig(basicConfig, 'memory'); expect(config).toMatchInlineSnapshot(` - { - "api": { - "extraOrigins": "", - "version": "", - }, - "connectionStatus": { - "minigraph": "PRE_INIT", - }, - "local": {}, - "notifier": { - "apikey": "", - }, - "remote": { - "accesstoken": "", - "allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000", - "apikey": "", - "avatar": "", - "dynamicRemoteAccessType": "DISABLED", - "email": "", - "idtoken": "", - "localApiKey": "", - "refreshtoken": "", - "regWizTime": "", - "username": "", - "wanaccess": "", - "wanport": "", - }, - "upc": { - "apikey": "", - }, - } - `); + { + "api": { + "extraOrigins": "", + "version": "", + }, + "connectionStatus": { + "minigraph": "PRE_INIT", + "upnpStatus": "", + }, + "local": {}, + "remote": { + "accesstoken": "", + "allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000", + "apikey": "", + "avatar": "", + "dynamicRemoteAccessType": "DISABLED", + "email": "", + "idtoken": "", + "localApiKey": "", + "refreshtoken": "", + "regWizTime": "", + "ssoSubIds": "", + "upnpEnabled": "", + "username": "", + "wanaccess": "", + "wanport": "", + }, + } + `); }); test('it creates a FLASH config with OPTIONAL values', () => { @@ -90,35 +83,30 @@ test('it creates a FLASH config with OPTIONAL values', () => { basicConfig.connectionStatus.upnpStatus = 'Turned On'; const config = getWriteableConfig(basicConfig, 'flash'); expect(config).toMatchInlineSnapshot(` - { - "api": { - "extraOrigins": "myextra.origins", - "version": "", - }, - "local": {}, - "notifier": { - "apikey": "", - }, - "remote": { - "accesstoken": "", - "apikey": "", - "avatar": "", - "dynamicRemoteAccessType": "DISABLED", - "email": "", - "idtoken": "", - "localApiKey": "", - "refreshtoken": "", - "regWizTime": "", - "upnpEnabled": "yes", - "username": "", - "wanaccess": "", - "wanport": "", - }, - "upc": { - "apikey": "", - }, - } - `); + { + "api": { + "extraOrigins": "myextra.origins", + "version": "", + }, + "local": {}, + "remote": { + "accesstoken": "", + "apikey": "", + "avatar": "", + "dynamicRemoteAccessType": "DISABLED", + "email": "", + "idtoken": "", + "localApiKey": "", + "refreshtoken": "", + "regWizTime": "", + "ssoSubIds": "", + "upnpEnabled": "yes", + "username": "", + "wanaccess": "", + "wanport": "", + }, + } + `); }); test('it creates a MEMORY config with OPTIONAL values', () => { @@ -132,38 +120,33 @@ test('it creates a MEMORY config with OPTIONAL values', () => { basicConfig.connectionStatus.upnpStatus = 'Turned On'; const config = getWriteableConfig(basicConfig, 'memory'); expect(config).toMatchInlineSnapshot(` - { - "api": { - "extraOrigins": "myextra.origins", - "version": "", - }, - "connectionStatus": { - "minigraph": "PRE_INIT", - "upnpStatus": "Turned On", - }, - "local": {}, - "notifier": { - "apikey": "", - }, - "remote": { - "accesstoken": "", - "allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000", - "apikey": "", - "avatar": "", - "dynamicRemoteAccessType": "DISABLED", - "email": "", - "idtoken": "", - "localApiKey": "", - "refreshtoken": "", - "regWizTime": "", - "upnpEnabled": "yes", - "username": "", - "wanaccess": "", - "wanport": "", - }, - "upc": { - "apikey": "", - }, - } - `); + { + "api": { + "extraOrigins": "myextra.origins", + "version": "", + }, + "connectionStatus": { + "minigraph": "PRE_INIT", + "upnpStatus": "Turned On", + }, + "local": {}, + "remote": { + "accesstoken": "", + "allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000", + "apikey": "", + "avatar": "", + "dynamicRemoteAccessType": "DISABLED", + "email": "", + "idtoken": "", + "localApiKey": "", + "refreshtoken": "", + "regWizTime": "", + "ssoSubIds": "", + "upnpEnabled": "yes", + "username": "", + "wanaccess": "", + "wanport": "", + }, + } + `); }); diff --git a/api/src/__test__/core/utils/images/image-file-helpers.test.ts b/api/src/__test__/core/utils/images/image-file-helpers.test.ts index 2cd25f9c3b..bf9b63830a 100644 --- a/api/src/__test__/core/utils/images/image-file-helpers.test.ts +++ b/api/src/__test__/core/utils/images/image-file-helpers.test.ts @@ -4,20 +4,20 @@ import { store } from "@app/store/index"; import { expect, test } from "vitest"; -test('get case path returns expected result', () => { - expect(getCasePathIfPresent()).resolves.toContain('/dev/dynamix/case-model.png') +test('get case path returns expected result', async () => { + await expect(getCasePathIfPresent()).resolves.toContain('/dev/dynamix/case-model.png') }) -test('get banner path returns null (state unloaded)', () => { - expect(getBannerPathIfPresent()).resolves.toMatchInlineSnapshot('null') +test('get banner path returns null (state unloaded)', async () => { + await expect(getBannerPathIfPresent()).resolves.toMatchInlineSnapshot('null') }) test('get banner path returns the banner (state loaded)', async() => { await store.dispatch(loadDynamixConfigFile()).unwrap(); - expect(getBannerPathIfPresent()).resolves.toContain('/dev/dynamix/banner.png'); + await expect(getBannerPathIfPresent()).resolves.toContain('/dev/dynamix/banner.png'); }) test('get banner path returns null when no banner (state loaded)', async () => { await store.dispatch(loadDynamixConfigFile()).unwrap(); - expect(getBannerPathIfPresent('notabanner.png')).resolves.toMatchInlineSnapshot('null'); + await expect(getBannerPathIfPresent('notabanner.png')).resolves.toMatchInlineSnapshot('null'); }); \ No newline at end of file diff --git a/api/src/__test__/store/modules/__snapshots__/config.test.ts.snap b/api/src/__test__/store/modules/__snapshots__/config.test.ts.snap new file mode 100644 index 0000000000..0ac0907817 --- /dev/null +++ b/api/src/__test__/store/modules/__snapshots__/config.test.ts.snap @@ -0,0 +1,34 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Before init returns default values for all fields 1`] = ` +{ + "api": { + "extraOrigins": "", + "version": "", + }, + "connectionStatus": { + "minigraph": "PRE_INIT", + "upnpStatus": "", + }, + "local": {}, + "nodeEnv": "test", + "remote": { + "accesstoken": "", + "allowedOrigins": "", + "apikey": "", + "avatar": "", + "dynamicRemoteAccessType": "DISABLED", + "email": "", + "idtoken": "", + "localApiKey": "", + "refreshtoken": "", + "regWizTime": "", + "ssoSubIds": "", + "upnpEnabled": "", + "username": "", + "wanaccess": "", + "wanport": "", + }, + "status": "UNLOADED", +} +`; diff --git a/api/src/__test__/store/modules/config.test.ts b/api/src/__test__/store/modules/config.test.ts index b1ac6c4d0d..484960a440 100644 --- a/api/src/__test__/store/modules/config.test.ts +++ b/api/src/__test__/store/modules/config.test.ts @@ -1,46 +1,11 @@ import { expect, test } from 'vitest'; import { store } from '@app/store'; +import { MyServersConfigMemory } from '@app/types/my-servers-config'; test('Before init returns default values for all fields', async () => { const state = store.getState().config; - expect(state).toMatchInlineSnapshot(` - { - "api": { - "extraOrigins": "", - "version": "", - }, - "connectionStatus": { - "minigraph": "PRE_INIT", - "upnpStatus": "", - }, - "local": {}, - "nodeEnv": "test", - "notifier": { - "apikey": "", - }, - "remote": { - "accesstoken": "", - "allowedOrigins": "", - "apikey": "", - "avatar": "", - "dynamicRemoteAccessType": "DISABLED", - "email": "", - "idtoken": "", - "localApiKey": "", - "refreshtoken": "", - "regWizTime": "", - "upnpEnabled": "", - "username": "", - "wanaccess": "", - "wanport": "", - }, - "status": "UNLOADED", - "upc": { - "apikey": "", - }, - } - `); + expect(state).toMatchSnapshot(); }, 10_000); test('After init returns values from cfg file for all fields', async () => { @@ -63,9 +28,6 @@ test('After init returns values from cfg file for all fields', async () => { }, local: {}, nodeEnv: 'test', - notifier: { - apikey: 'unnotify_30994bfaccf839c65bae75f7fa12dd5ee16e69389f754c3b98ed7d5', - }, remote: { accesstoken: '', allowedOrigins: '', @@ -77,15 +39,13 @@ test('After init returns values from cfg file for all fields', async () => { localApiKey: '_______________________LOCAL_API_KEY_HERE_________________________', refreshtoken: '', regWizTime: '1611175408732_0951-1653-3509-FBA155FA23C0', + ssoSubIds: '', upnpEnabled: 'no', username: 'zspearmint', wanaccess: 'yes', wanport: '8443', }, status: 'LOADED', - upc: { - apikey: 'unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810', - }, }) ); }); @@ -116,9 +76,6 @@ test('updateUserConfig merges in changes to current state', async () => { }, local: {}, nodeEnv: 'test', - notifier: { - apikey: 'unnotify_30994bfaccf839c65bae75f7fa12dd5ee16e69389f754c3b98ed7d5', - }, remote: { accesstoken: '', allowedOrigins: '', @@ -130,15 +87,13 @@ test('updateUserConfig merges in changes to current state', async () => { localApiKey: '_______________________LOCAL_API_KEY_HERE_________________________', refreshtoken: '', regWizTime: '1611175408732_0951-1653-3509-FBA155FA23C0', + ssoSubIds: '', upnpEnabled: 'no', username: 'zspearmint', wanaccess: 'yes', wanport: '8443', }, status: 'LOADED', - upc: { - apikey: 'unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810', - }, - }) + } as MyServersConfigMemory) ); }); diff --git a/api/src/cli.ts b/api/src/cli.ts index 2903f84039..8002634504 100644 --- a/api/src/cli.ts +++ b/api/src/cli.ts @@ -9,14 +9,14 @@ import { cliLogger, internalLogger } from '@app/core/log'; import { CliModule } from '@app/unraid-api/cli/cli.module'; try { - const shellToUse = execSync('which bash'); + const shellToUse = execSync('which bash').toString().trim(); await CommandFactory.run(CliModule, { cliName: 'unraid-api', logger: false, completion: { fig: true, cmd: 'unraid-api', - nativeShell: { executablePath: shellToUse.toString('utf-8') }, + nativeShell: { executablePath: shellToUse }, }, }); } catch (error) { diff --git a/api/src/consts.ts b/api/src/consts.ts index 96a41c69ad..eb79bed018 100644 --- a/api/src/consts.ts +++ b/api/src/consts.ts @@ -68,6 +68,7 @@ export const JWKS_LOCAL_PAYLOAD: JSONWebKeySet = { }, ], }; + export const OAUTH_BASE_URL = 'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_btSkhlsEk'; export const OAUTH_CLIENT_ID = '53ci4o48gac8vq5jepubkjmo36'; diff --git a/api/src/core/sso/auth-request-setup.ts b/api/src/core/sso/auth-request-setup.ts new file mode 100644 index 0000000000..9b3bb0c125 --- /dev/null +++ b/api/src/core/sso/auth-request-setup.ts @@ -0,0 +1,46 @@ +import { existsSync } from 'fs'; +import { readFile, writeFile } from 'fs/promises'; + +import { glob } from 'glob'; + +import { logger } from '@app/core/log'; + +// Define constants +const AUTH_REQUEST_FILE = '/usr/local/emhttp/auth-request.php'; +const WEB_COMPS_DIR = '/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/_nuxt/'; + +const getJsFiles = async (dir: string) => { + const files = await glob(`${dir}/**/*.js`); + return files.map((file) => file.replace('/usr/local/emhttp', '')); +}; + +export const setupAuthRequest = async () => { + const JS_FILES = await getJsFiles(WEB_COMPS_DIR); + logger.debug(`Found ${JS_FILES.length} .js files in ${WEB_COMPS_DIR}`); + + const FILES_TO_ADD = ['/webGui/images/partner-logo.svg', ...JS_FILES]; + + if (existsSync(AUTH_REQUEST_FILE)) { + const fileContent = await readFile(AUTH_REQUEST_FILE, 'utf8'); + + if (fileContent.includes('$arrWhitelist')) { + const backupFile = `${AUTH_REQUEST_FILE}.bak`; + await writeFile(backupFile, fileContent); + logger.debug(`Backup of ${AUTH_REQUEST_FILE} created at ${backupFile}`); + + const filesToAddString = FILES_TO_ADD.map((file) => ` '${file}',`).join('\n'); + + const updatedContent = fileContent.replace( + /(\$arrWhitelist\s*=\s*\[)/, + `$1\n${filesToAddString}` + ); + + await writeFile(AUTH_REQUEST_FILE, updatedContent); + logger.debug(`Default values and .js files from ${WEB_COMPS_DIR} added to $arrWhitelist.`); + } else { + logger.debug(`$arrWhitelist array not found in the file.`); + } + } else { + logger.debug(`File ${AUTH_REQUEST_FILE} not found.`); + } +}; diff --git a/api/src/core/sso/sso-remove.ts b/api/src/core/sso/sso-remove.ts new file mode 100644 index 0000000000..5788a20358 --- /dev/null +++ b/api/src/core/sso/sso-remove.ts @@ -0,0 +1,20 @@ +import { existsSync, renameSync, unlinkSync } from 'node:fs'; + +import { cliLogger } from '@app/core/log'; + +export const removeSso = () => { + const path = '/usr/local/emhttp/plugins/dynamix/include/.login.php'; + const backupPath = path + '.bak'; + + // Move the backup file to the original location + if (existsSync(backupPath)) { + // Remove the SSO login inject file if it exists + if (existsSync(path)) { + unlinkSync(path); + } + renameSync(backupPath, path); + cliLogger.debug('SSO login file restored.'); + } else { + cliLogger.debug('No SSO login file backup found.'); + } +}; diff --git a/api/src/core/sso/sso-setup.ts b/api/src/core/sso/sso-setup.ts new file mode 100755 index 0000000000..9ffdd3b00e --- /dev/null +++ b/api/src/core/sso/sso-setup.ts @@ -0,0 +1,66 @@ +import { existsSync } from 'node:fs'; +import { copyFile, readFile, rename, unlink, writeFile } from 'node:fs/promises'; + + + + + +export const setupSso = async () => { + const path = '/usr/local/emhttp/plugins/dynamix/include/.login.php'; + + // Define the new PHP function to insert + const newFunction = ` +function verifyUsernamePasswordAndSSO(string $username, string $password): bool { + if ($username != "root") return false; + + $output = exec("/usr/bin/getent shadow $username"); + if ($output === false) return false; + $credentials = explode(":", $output); + $valid = password_verify($password, $credentials[1]); + if ($valid) { + return true; + } + // We may have an SSO token, attempt validation + if (strlen($password) > 800) { + $safePassword = escapeshellarg($password); + $response = exec("/usr/local/bin/unraid-api sso validate-token $safePassword", $output, $code); + my_logger("SSO Login Response: $response"); + if ($code === 0 && $response && strpos($response, '"valid":true') !== false) { + return true; + } + } + return false; +}`; + + const tagToInject = ''; + + // Backup the original file if exists + if (existsSync(path + '.bak')) { + await copyFile(path + '.bak', path); + await unlink(path + '.bak'); + } + + // Read the file content + let fileContent = await readFile(path, 'utf-8'); + + // Backup the original content + await writeFile(path + '.bak', fileContent); + + // Add new function after the opening PHP tag ( tag + fileContent = fileContent.replace(/<\/form>/i, `\n${tagToInject}`); + + // Write the updated content back to the file + await writeFile(path, fileContent); + + console.log('Function replaced successfully.'); +}; \ No newline at end of file diff --git a/api/src/core/utils/files/config-file-normalizer.ts b/api/src/core/utils/files/config-file-normalizer.ts index 3ab27adce0..2e4eaf0415 100644 --- a/api/src/core/utils/files/config-file-normalizer.ts +++ b/api/src/core/utils/files/config-file-normalizer.ts @@ -1,95 +1,44 @@ +import { isEqual } from 'lodash-es'; + import { getAllowedOrigins } from '@app/common/allowed-origins'; -import { DynamicRemoteAccessType } from '@app/graphql/generated/api/types'; +import { initialState } from '@app/store/modules/config'; import { - type SliceState as ConfigSliceState, - initialState, -} from '@app/store/modules/config'; -import { type RecursivePartial } from '@app/types'; -import type { MyServersConfig, MyServersConfigMemory, + MyServersConfigMemorySchema, + MyServersConfigSchema, } from '@app/types/my-servers-config'; -import { isEqual } from 'lodash-es'; +// Define ConfigType and ConfigObject export type ConfigType = 'flash' | 'memory'; -type ConfigObject = T extends 'flash' - ? MyServersConfig - : T extends 'memory' - ? MyServersConfigMemory - : never; + /** - * - * @param config Config to read from to create a new formatted server config to write - * @param mode 'flash' or 'memory', changes what fields are included in the writeable payload - * @returns + * Get a writeable configuration based on the mode ('flash' or 'memory'). */ - export const getWriteableConfig = ( - config: ConfigSliceState, + config: T extends 'memory' ? MyServersConfigMemory : MyServersConfig, mode: T -): ConfigObject => { - // Get current state - const { api, local, notifier, remote, upc, connectionStatus } = config; +): T extends 'memory' ? MyServersConfigMemory : MyServersConfig => { + const schema = mode === 'memory' ? MyServersConfigMemorySchema : MyServersConfigSchema; - // Create new state - - const newState: ConfigObject = { - api: { - version: api?.version ?? initialState.api.version, - extraOrigins: api?.extraOrigins ?? initialState.api.extraOrigins, - }, - local: {}, - notifier: { - apikey: notifier.apikey ?? initialState.notifier.apikey, - }, + const defaultConfig = schema.parse(initialState); + // Use a type assertion for the mergedConfig to include `connectionStatus` only if `mode === 'memory` + const mergedConfig = { + ...defaultConfig, + ...config, remote: { - wanaccess: remote.wanaccess ?? initialState.remote.wanaccess, - wanport: remote.wanport ?? initialState.remote.wanport, - ...(remote.upnpEnabled ? { upnpEnabled: remote.upnpEnabled } : {}), - apikey: remote.apikey ?? initialState.remote.apikey, - localApiKey: remote.localApiKey ?? initialState.remote.localApiKey, - email: remote.email ?? initialState.remote.email, - username: remote.username ?? initialState.remote.username, - avatar: remote.avatar ?? initialState.remote.avatar, - regWizTime: remote.regWizTime ?? initialState.remote.regWizTime, - idtoken: remote.idtoken ?? initialState.remote.idtoken, - accesstoken: remote.accesstoken ?? initialState.remote.accesstoken, - refreshtoken: - remote.refreshtoken ?? initialState.remote.refreshtoken, - ...(mode === 'memory' - ? { - allowedOrigins: - getAllowedOrigins().join(', ') - } - : {}), - dynamicRemoteAccessType: remote.dynamicRemoteAccessType ?? DynamicRemoteAccessType.DISABLED, + ...defaultConfig.remote, + ...config.remote, }, - upc: { - apikey: upc.apikey ?? initialState.upc.apikey, - }, - ...(mode === 'memory' - ? { - connectionStatus: { - minigraph: - connectionStatus.minigraph ?? - initialState.connectionStatus.minigraph, - ...(connectionStatus.upnpStatus - ? { upnpStatus: connectionStatus.upnpStatus } - : {}), - }, - } - : {}), - } as ConfigObject; - return newState; -}; + } as T extends 'memory' ? MyServersConfigMemory : MyServersConfig; -/** - * Helper function to convert an object into a normalized config file. - * This is used for loading config files and ensure changes have been made before the state is merged. - */ -export const areConfigsEquivalent = ( - newConfigFile: RecursivePartial, - currentConfig: ConfigSliceState -): boolean => - // Enable to view config diffs: logger.debug(getDiff(getWriteableConfig(currentConfig, 'flash'), newConfigFile)); - isEqual(newConfigFile, getWriteableConfig(currentConfig, 'flash')); + if (mode === 'memory') { + (mergedConfig as MyServersConfigMemory).remote.allowedOrigins = getAllowedOrigins().join(', '); + (mergedConfig as MyServersConfigMemory).connectionStatus = { + ...(defaultConfig as MyServersConfigMemory).connectionStatus, + ...(config as MyServersConfigMemory).connectionStatus, + }; + } + + return schema.parse(mergedConfig) as T extends 'memory' ? MyServersConfigMemory : MyServersConfig; // Narrowing ensures correct typing +}; diff --git a/api/src/index.ts b/api/src/index.ts index e3f5b89655..5651a1de20 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -14,6 +14,8 @@ import { WebSocket } from 'ws'; import { logger } from '@app/core/log'; import { setupLogRotation } from '@app/core/logrotate/setup-logrotate'; +import { setupAuthRequest } from '@app/core/sso/auth-request-setup'; +import { setupSso } from '@app/core/sso/sso-setup'; import { fileExistsSync } from '@app/core/utils/files/file-exists'; import { environment, PORT } from '@app/environment'; import * as envVars from '@app/environment'; @@ -100,6 +102,16 @@ try { startMiddlewareListeners(); + // If the config contains SSO IDs, enable SSO + if (store.getState().config.remote.ssoSubIds) { + try { + await setupAuthRequest(); + await setupSso(); + } catch (err) { + logger.error(err, 'Failed to setup SSO'); + } + } + // On process exit stop HTTP server exitHook((signal) => { console.log('exithook', signal); diff --git a/api/src/store/listeners/config-listener.ts b/api/src/store/listeners/config-listener.ts index 0b182c8dbc..fdd691caaf 100644 --- a/api/src/store/listeners/config-listener.ts +++ b/api/src/store/listeners/config-listener.ts @@ -1,66 +1,22 @@ -import { startAppListening } from '@app/store/listeners/listener-middleware'; -import { isEqual } from 'lodash-es'; -import { logger } from '@app/core/log'; -import { - type ConfigType, - getWriteableConfig, -} from '@app/core/utils/files/config-file-normalizer'; -import { - loadConfigFile, - loginUser, - logoutUser, -} from '@app/store/modules/config'; -import { FileLoadStatus } from '@app/store/types'; -import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer'; -import { isFulfilled } from '@reduxjs/toolkit'; -import { environment } from '@app/environment'; import { writeFileSync } from 'fs'; -const actionIsLoginOrLogout = isFulfilled(logoutUser, loginUser); + +import type { ConfigType } from '@app/core/utils/files/config-file-normalizer'; +import { logger } from '@app/core/log'; +import { getWriteableConfig } from '@app/core/utils/files/config-file-normalizer'; +import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer'; +import { startAppListening } from '@app/store/listeners/listener-middleware'; +import { configUpdateActionsFlash, configUpdateActionsMemory } from '@app/store/modules/config'; export const enableConfigFileListener = (mode: ConfigType) => () => startAppListening({ - predicate(action, currentState, previousState) { - if (!environment.IS_MAIN_PROCESS) { - return false; - } - - if (currentState.config.status === FileLoadStatus.LOADED) { - const oldFlashConfig = previousState?.config.api.version - ? getWriteableConfig(previousState.config, mode) - : null; - const newFlashConfig = getWriteableConfig( - currentState.config, - mode - ); - - if ( - !isEqual(oldFlashConfig, newFlashConfig) && - action.type !== loadConfigFile.fulfilled.type && - action.type !== loadConfigFile.rejected.type - ) { - return true; - } - - if (actionIsLoginOrLogout(action) && mode === 'memory') { - logger.trace( - 'Logout / Login Action Encountered, writing memory config' - ); - return true; - } - } - - return false; - }, + matcher: mode === 'flash' ? configUpdateActionsFlash : configUpdateActionsMemory, async effect(_, { getState }) { const { paths, config } = getState(); const pathToWrite = - mode === 'flash' - ? paths['myservers-config'] - : paths['myservers-config-states']; + mode === 'flash' ? paths['myservers-config'] : paths['myservers-config-states']; const writeableConfig = getWriteableConfig(config, mode); - const serializedConfig = - safelySerializeObjectToIni(writeableConfig); + const serializedConfig = safelySerializeObjectToIni(writeableConfig); logger.debug('Writing updated config to %s', pathToWrite); writeFileSync(pathToWrite, serializedConfig); }, diff --git a/api/src/store/modules/config.ts b/api/src/store/modules/config.ts index fe61d9b332..13261afca0 100644 --- a/api/src/store/modules/config.ts +++ b/api/src/store/modules/config.ts @@ -1,32 +1,28 @@ -import { parseConfig } from '@app/core/utils/misc/parse-config'; -import { - type MyServersConfig, - type MyServersConfigMemory, -} from '@app/types/my-servers-config'; -import { - createAsyncThunk, - createSlice, - type PayloadAction, -} from '@reduxjs/toolkit'; +import { F_OK } from 'constants'; +import { writeFileSync } from 'fs'; import { access } from 'fs/promises'; + +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createAsyncThunk, createSlice, isAnyOf } from '@reduxjs/toolkit'; +import { isEqual } from 'lodash-es'; import merge from 'lodash/merge'; -import { FileLoadStatus } from '@app/store/types'; -import { F_OK } from 'constants'; -import { type RecursivePartial } from '@app/types'; -import { DynamicRemoteAccessType, MinigraphStatus, type Owner } from '@app/graphql/generated/api/types'; -import { type RootState } from '@app/store'; -import { randomBytes } from 'crypto'; + +import type { Owner } from '@app/graphql/generated/api/types'; import { logger } from '@app/core/log'; -import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status'; +import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub'; import { getWriteableConfig } from '@app/core/utils/files/config-file-normalizer'; -import { writeFileSync } from 'fs'; import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer'; -import { PUBSUB_CHANNEL, pubsub } from '@app/core/pubsub'; -import { isEqual } from 'lodash-es'; -import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access'; +import { parseConfig } from '@app/core/utils/misc/parse-config'; import { NODE_ENV } from '@app/environment'; +import { DynamicRemoteAccessType, MinigraphStatus } from '@app/graphql/generated/api/types'; import { GraphQLClient } from '@app/mothership/graphql-client'; import { stopPingTimeoutJobs } from '@app/mothership/jobs/ping-timeout-jobs'; +import { type RootState } from '@app/store'; +import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status'; +import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access'; +import { FileLoadStatus } from '@app/store/types'; +import { type RecursivePartial } from '@app/types'; +import { type MyServersConfig, type MyServersConfigMemory } from '@app/types/my-servers-config'; export type SliceState = { status: FileLoadStatus; @@ -51,18 +47,13 @@ export const initialState: SliceState = { refreshtoken: '', allowedOrigins: '', dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED, + ssoSubIds: '', }, local: {}, api: { extraOrigins: '', version: '', }, - upc: { - apikey: '', - }, - notifier: { - apikey: '', - }, connectionStatus: { minigraph: MinigraphStatus.PRE_INIT, upnpStatus: '', @@ -70,8 +61,8 @@ export const initialState: SliceState = { } as const; export const loginUser = createAsyncThunk< - Pick, - Pick, + Pick, + Pick, { state: RootState } >('config/login-user', async (userInfo) => { logger.info('Logging in user: %s', userInfo.username); @@ -83,29 +74,28 @@ export const loginUser = createAsyncThunk< return userInfo; }); -export const logoutUser = createAsyncThunk< - void, - { reason?: string }, - { state: RootState } ->('config/logout-user', async ({ reason }) => { - logger.info('Logging out user: %s', reason ?? 'No reason provided'); - const { pubsub } = await import('@app/core/pubsub'); +export const logoutUser = createAsyncThunk( + 'config/logout-user', + async ({ reason }) => { + logger.info('Logging out user: %s', reason ?? 'No reason provided'); + const { pubsub } = await import('@app/core/pubsub'); - // Publish to servers endpoint - await pubsub.publish(PUBSUB_CHANNEL.SERVERS, { - servers: [], - }); + // Publish to servers endpoint + await pubsub.publish(PUBSUB_CHANNEL.SERVERS, { + servers: [], + }); - const owner: Owner = { - username: 'root', - url: '', - avatar: '', - }; - // Publish to owner endpoint - await pubsub.publish(PUBSUB_CHANNEL.OWNER, { owner }); - stopPingTimeoutJobs(); - await GraphQLClient.clearInstance(); -}); + const owner: Owner = { + username: 'root', + url: '', + avatar: '', + }; + // Publish to owner endpoint + await pubsub.publish(PUBSUB_CHANNEL.OWNER, { owner }); + stopPingTimeoutJobs(); + await GraphQLClient.clearInstance(); + } +); /** * Load the myservers.cfg into the store. Returns null if the state after loading doesn't change @@ -127,32 +117,6 @@ type LoadFailureConfigEqual = { }; type ConfigRejectedValues = LoadFailureConfigEqual | LoadFailureWithConfig; -const generateApiKeysIfNotExistent = ( - file: RecursivePartial -): MyServersConfig => { - const newConfigFile = merge(file, { - upc: { - apikey: - file.upc?.apikey?.trim()?.length === 64 - ? file.upc?.apikey - : `unupc_${randomBytes(58).toString('hex')}`.substring( - 0, - 64 - ), - }, - notifier: { - apikey: - file.notifier?.apikey?.trim().length === 64 - ? file.notifier?.apikey - : `unnotify_${randomBytes(58).toString('hex')}`.substring( - 0, - 64 - ), - }, - }) as MyServersConfig; - return newConfigFile; -}; - export const loadConfigFile = createAsyncThunk< MyServersConfig, string | undefined, @@ -160,71 +124,51 @@ export const loadConfigFile = createAsyncThunk< state: RootState; rejectValue: ConfigRejectedValues; } ->( - 'config/load-config-file', - async (filePath, { getState, rejectWithValue }) => { - try { - const { paths, config } = getState(); - - const path = filePath ?? paths['myservers-config']; +>('config/load-config-file', async (filePath, { getState, rejectWithValue }) => { + try { + const { paths, config } = getState(); - const fileExists = await access(path, F_OK) - .then(() => true) - .catch(() => false); - if (!fileExists) { - throw new Error('Config File Missing'); - } + const path = filePath ?? paths['myservers-config']; - const file = fileExists - ? parseConfig>({ - filePath: path, - type: 'ini', - }) - : {}; + const fileExists = await access(path, F_OK) + .then(() => true) + .catch(() => false); + if (!fileExists) { + throw new Error('Config File Missing'); + } - const newConfigFile = generateApiKeysIfNotExistent(file); + const newConfigFile = getWriteableConfig( + parseConfig({ filePath: path, type: 'ini' }), + 'flash' + ); - const isNewlyLoadedConfigEqual = isEqual( - getWriteableConfig(newConfigFile as SliceState, 'flash'), - getWriteableConfig(config, 'flash') - ); - if (isNewlyLoadedConfigEqual) { - logger.warn( - 'Not loading config because it is the same as before' - ); - return rejectWithValue({ - type: CONFIG_LOAD_ERROR.CONFIG_EQUAL, - }); - } - return newConfigFile; - } catch (error: unknown) { - logger.warn('Config file is corrupted with error: %o - recreating config', error); - const config = getWriteableConfig(initialState, 'flash'); - const newConfig = generateApiKeysIfNotExistent(config); - newConfig.remote.wanaccess = 'no'; - const serializedConfig = safelySerializeObjectToIni(newConfig); - writeFileSync( - getState().paths['myservers-config'], - serializedConfig - ); + const isNewlyLoadedConfigEqual = isEqual(newConfigFile, getWriteableConfig(config, 'flash')); + if (isNewlyLoadedConfigEqual) { + logger.warn('Not loading config because it is the same as before'); return rejectWithValue({ - type: CONFIG_LOAD_ERROR.CONFIG_CORRUPTED, - error: - error instanceof Error ? error : new Error('Unknown Error'), - config: newConfig, + type: CONFIG_LOAD_ERROR.CONFIG_EQUAL, }); } + return newConfigFile; + } catch (error: unknown) { + logger.warn('Config file is corrupted with error: %o - recreating config', error); + const newConfig = getWriteableConfig(initialState, 'flash'); + newConfig.remote.wanaccess = 'no'; + const serializedConfig = safelySerializeObjectToIni(newConfig); + writeFileSync(getState().paths['myservers-config'], serializedConfig); + return rejectWithValue({ + type: CONFIG_LOAD_ERROR.CONFIG_CORRUPTED, + error: error instanceof Error ? error : new Error('Unknown Error'), + config: newConfig, + }); } -); +}); export const config = createSlice({ name: 'config', initialState, reducers: { - updateUserConfig( - state, - action: PayloadAction> - ) { + updateUserConfig(state, action: PayloadAction>) { return merge(state, action.payload); }, updateAccessTokens( @@ -265,6 +209,26 @@ export const config = createSlice({ setWanAccess(state, action: PayloadAction<'yes' | 'no'>) { state.remote.wanaccess = action.payload; }, + addSsoUser(state, action: PayloadAction) { + // First check if state already has ID, otherwise append it + if (state.remote.ssoSubIds.includes(action.payload)) { + return; + } + const stateAsArray = state.remote.ssoSubIds.split(',').filter((id) => id !== ''); + stateAsArray.push(action.payload); + state.remote.ssoSubIds = stateAsArray.join(','); + }, + removeSsoUser(state, action: PayloadAction) { + if (action.payload === null) { + state.remote.ssoSubIds = ''; + return; + } + if (!state.remote.ssoSubIds.includes(action.payload)) { + return; + } + const stateAsArray = state.remote.ssoSubIds.split(',').filter((id) => id !== action.payload); + state.remote.ssoSubIds = stateAsArray.join(','); + }, }, extraReducers(builder) { builder.addCase(loadConfigFile.pending, (state) => { @@ -286,10 +250,7 @@ export const config = createSlice({ state.status = FileLoadStatus.LOADED; break; case CONFIG_LOAD_ERROR.CONFIG_CORRUPTED: - logger.debug( - 'Config File Load Failed - %o', - action.payload.error - ); + logger.debug('Config File Load Failed - %o', action.payload.error); merge(state, action.payload.config); state.status = FileLoadStatus.LOADED; break; @@ -332,8 +293,7 @@ export const config = createSlice({ builder.addCase(setupRemoteAccessThunk.fulfilled, (state, action) => { state.remote.wanaccess = action.payload.wanaccess; - state.remote.dynamicRemoteAccessType = - action.payload.dynamicRemoteAccessType; + state.remote.dynamicRemoteAccessType = action.payload.dynamicRemoteAccessType; state.remote.wanport = action.payload.wanport; state.remote.upnpEnabled = action.payload.upnpEnabled; }); @@ -342,12 +302,36 @@ export const config = createSlice({ const { actions, reducer } = config; export const { + addSsoUser, updateUserConfig, updateAccessTokens, updateAllowedOrigins, setUpnpState, setWanPortToValue, setWanAccess, + removeSsoUser, } = actions; +/** + * Actions that should trigger a flash write + */ +export const configUpdateActionsFlash = isAnyOf( + addSsoUser, + updateUserConfig, + updateAccessTokens, + updateAllowedOrigins, + setUpnpState, + setWanPortToValue, + setWanAccess, + setupRemoteAccessThunk.fulfilled, + logoutUser.fulfilled, + loginUser.fulfilled, + removeSsoUser +); + +/** + * Actions that should trigger a memory write + */ +export const configUpdateActionsMemory = isAnyOf(configUpdateActionsFlash, setGraphqlConnectionStatus); + export const configReducer = reducer; diff --git a/api/src/store/watch/config-watch.ts b/api/src/store/watch/config-watch.ts index 25ef6c685d..efd4cbc901 100644 --- a/api/src/store/watch/config-watch.ts +++ b/api/src/store/watch/config-watch.ts @@ -1,29 +1,39 @@ -import { getters, store } from '@app/store'; +import { existsSync, writeFileSync } from 'fs'; + import { watch } from 'chokidar'; -import { loadConfigFile, logoutUser } from '@app/store/modules/config'; + import { logger } from '@app/core/log'; -import { existsSync, writeFileSync } from 'fs'; +import { getWriteableConfig } from '@app/core/utils/files/config-file-normalizer'; +import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer'; import { CHOKIDAR_USEPOLLING, ENVIRONMENT } from '@app/environment'; +import { getters, store } from '@app/store'; +import { initialState, loadConfigFile, logoutUser } from '@app/store/modules/config'; export const setupConfigPathWatch = () => { - const myServersConfigPath = getters.paths()?.['myservers-config']; - if (myServersConfigPath) { - logger.info('Watch Setup on Config Path: %s', myServersConfigPath); - if (!existsSync(myServersConfigPath)) { - writeFileSync(myServersConfigPath, '', 'utf-8'); - } - const watcher = watch(myServersConfigPath, { - persistent: true, - ignoreInitial: false, - usePolling: CHOKIDAR_USEPOLLING === true, - }).on('change', async () => { - await store.dispatch(loadConfigFile()); - }).on('unlink', async () => { - watcher.close(); - setupConfigPathWatch(); - store.dispatch(logoutUser({ reason: 'Config File was Deleted'})) - }); - } else { - logger.error('[FATAL] Failed to setup watch on My Servers Config (Could Not Read Config Path)'); - } + const myServersConfigPath = getters.paths()?.['myservers-config']; + if (myServersConfigPath) { + logger.info('Watch Setup on Config Path: %s', myServersConfigPath); + if (!existsSync(myServersConfigPath)) { + const config = safelySerializeObjectToIni(getWriteableConfig(initialState, 'flash')); + writeFileSync(myServersConfigPath, config, 'utf-8'); + } + const watcher = watch(myServersConfigPath, { + persistent: true, + ignoreInitial: false, + usePolling: CHOKIDAR_USEPOLLING === true, + }) + .on('change', async (change) => { + logger.trace('Config File Changed, Reloading Config %s', change); + await store.dispatch(loadConfigFile()); + }) + .on('unlink', async () => { + const config = safelySerializeObjectToIni(getWriteableConfig(initialState, 'flash')); + await writeFileSync(myServersConfigPath, config, 'utf-8'); + watcher.close(); + setupConfigPathWatch(); + store.dispatch(logoutUser({ reason: 'Config File was Deleted' })); + }); + } else { + logger.error('[FATAL] Failed to setup watch on My Servers Config (Could Not Read Config Path)'); + } }; diff --git a/api/src/types/my-servers-config.d.ts b/api/src/types/my-servers-config.d.ts deleted file mode 100644 index 488c8392fb..0000000000 --- a/api/src/types/my-servers-config.d.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { type DynamicRemoteAccessType, type MinigraphStatus } from '@app/graphql/generated/api/types'; - -interface MyServersConfig extends Record { - api: { - version: string; - extraOrigins: string; - }; - local: {}; - notifier: { - apikey: string; - }; - remote: { - wanaccess: string; - wanport: string; - upnpEnabled?: string; - apikey: string; - localApiKey?: string; - email: string; - username: string; - avatar: string; - regWizTime: string; - accesstoken: string; - idtoken: string; - refreshtoken: string; - allowedOrigins?: string; - dynamicRemoteAccessType?: DynamicRemoteAccessType; - }; - upc: { - apikey: string; - }; -} - -export interface MyServersConfigWithMandatoryHiddenFields extends MyServersConfig { - api: { - extraOrigins: string; - }; - remote: MyServersConfig['remote'] & { - upnpEnabled: string; - dynamicRemoteAccessType: DynamicRemoteAccessType; - }; -} - -export interface MyServersConfigMemory extends MyServersConfig { - connectionStatus: { - minigraph: MinigraphStatus; - upnpStatus?: null | string; - }; - remote: MyServersConfig['remote'] & { - allowedOrigins: string; - }; -} - -export interface MyServersConfigMemoryWithMandatoryHiddenFields extends MyServersConfigMemory { - connectionStatus: { - minigraph: MinigraphStatus; - upnpStatus?: null | string; - }; -} diff --git a/api/src/types/my-servers-config.ts b/api/src/types/my-servers-config.ts new file mode 100644 index 0000000000..36dea5e469 --- /dev/null +++ b/api/src/types/my-servers-config.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; + +import { DynamicRemoteAccessType, MinigraphStatus } from '@app/graphql/generated/api/types'; + +// Define Zod schemas +const ApiConfigSchema = z.object({ + version: z.string(), + extraOrigins: z.string(), +}); + +const RemoteConfigSchema = z.object({ + wanaccess: z.string(), + wanport: z.string(), + upnpEnabled: z.string(), + apikey: z.string(), + localApiKey: z.string(), + email: z.string(), + username: z.string(), + avatar: z.string(), + regWizTime: z.string(), + accesstoken: z.string(), + idtoken: z.string(), + refreshtoken: z.string(), + dynamicRemoteAccessType: z.nativeEnum(DynamicRemoteAccessType), + ssoSubIds: z + .string() + .transform((val) => { + // If valid, return as is + if (val === '' || val.split(',').every((id) => id.trim().match(/^[a-zA-Z0-9-]+$/))) { + return val; + } + // Otherwise, replace with an empty string + return ''; + }) + .refine( + (val) => val === '' || val.split(',').every((id) => id.trim().match(/^[a-zA-Z0-9-]+$/)), + { + message: + 'ssoSubIds must be empty or a comma-separated list of alphanumeric strings with dashes', + } + ), +}); + +const LocalConfigSchema = z.object({}); + +// Base config schema +export const MyServersConfigSchema = z + .object({ + api: ApiConfigSchema, + local: LocalConfigSchema, + remote: RemoteConfigSchema, + }) + .strip(); + +// Memory config schema +export const ConnectionStatusSchema = z.object({ + minigraph: z.nativeEnum(MinigraphStatus), + upnpStatus: z.string().nullable().optional(), +}); + +export const MyServersConfigMemorySchema = MyServersConfigSchema.extend({ + connectionStatus: ConnectionStatusSchema, + remote: RemoteConfigSchema.extend({ + allowedOrigins: z.string(), + }), +}).strip(); + +// Infer and export types from Zod schemas +export type MyServersConfig = z.infer; +export type MyServersConfigMemory = z.infer; diff --git a/api/src/unraid-api/auth/header.strategy.ts b/api/src/unraid-api/auth/header.strategy.ts index e7c0570909..1e36efaea2 100644 --- a/api/src/unraid-api/auth/header.strategy.ts +++ b/api/src/unraid-api/auth/header.strategy.ts @@ -35,14 +35,14 @@ export class ServerHeaderStrategy extends PassportStrategy(Strategy, 'server-htt try { const user = await this.authService.validateApiKeyCasbin(key); - this.logger.debug('API key validation successful', { + this.logger.debug('API key validation successful %o', { userId: user?.id, roles: user?.roles, }); return user; } catch (error) { - this.logger.error('API key validation failed', { + this.logger.error('API key validation failed %o', { errorType: error instanceof Error ? error.constructor.name : 'Unknown', message: error instanceof Error ? error.message : 'Unknown error', }); diff --git a/api/src/unraid-api/cli/cli.module.ts b/api/src/unraid-api/cli/cli.module.ts index bd18093913..4fd6eb20c1 100644 --- a/api/src/unraid-api/cli/cli.module.ts +++ b/api/src/unraid-api/cli/cli.module.ts @@ -1,19 +1,33 @@ import { Module } from '@nestjs/common'; +import { InquirerService } from 'nest-commander'; + +import { ConfigCommand } from '@app/unraid-api/cli/config.command'; import { KeyCommand } from '@app/unraid-api/cli/key.command'; import { LogService } from '@app/unraid-api/cli/log.service'; +import { LogsCommand } from '@app/unraid-api/cli/logs.command'; import { ReportCommand } from '@app/unraid-api/cli/report.command'; import { RestartCommand } from '@app/unraid-api/cli/restart.command'; +import { AddSSOUserCommand } from '@app/unraid-api/cli/sso/add-sso-user.command'; +import { AddSSOUserQuestionSet } from '@app/unraid-api/cli/sso/add-sso-user.questions'; +import { SSOCommand } from '@app/unraid-api/cli/sso/sso.command'; +import { ValidateTokenCommand } from '@app/unraid-api/cli/sso/validate-token.command'; import { StartCommand } from '@app/unraid-api/cli/start.command'; +import { StatusCommand } from '@app/unraid-api/cli/status.command'; import { StopCommand } from '@app/unraid-api/cli/stop.command'; import { SwitchEnvCommand } from '@app/unraid-api/cli/switch-env.command'; import { VersionCommand } from '@app/unraid-api/cli/version.command'; -import { StatusCommand } from '@app/unraid-api/cli/status.command'; -import { ValidateTokenCommand } from '@app/unraid-api/cli/validate-token.command'; -import { LogsCommand } from '@app/unraid-api/cli/logs.command'; +import { RemoveSSOUserCommand } from '@app/unraid-api/cli/sso/remove-sso-user.command'; +import { RemoveSSOUserQuestionSet } from '@app/unraid-api/cli/sso/remove-sso-user.questions'; +import { ListSSOUserCommand } from '@app/unraid-api/cli/sso/list-sso-user.command'; @Module({ providers: [ + AddSSOUserCommand, + AddSSOUserQuestionSet, + RemoveSSOUserCommand, + RemoveSSOUserQuestionSet, + ListSSOUserCommand, LogService, StartCommand, StopCommand, @@ -23,8 +37,10 @@ import { LogsCommand } from '@app/unraid-api/cli/logs.command'; SwitchEnvCommand, VersionCommand, StatusCommand, + SSOCommand, ValidateTokenCommand, - LogsCommand + LogsCommand, + ConfigCommand, ], }) export class CliModule {} diff --git a/api/src/unraid-api/cli/config.command.ts b/api/src/unraid-api/cli/config.command.ts new file mode 100644 index 0000000000..d18795e629 --- /dev/null +++ b/api/src/unraid-api/cli/config.command.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { readFile } from 'fs/promises'; + +import { Command, CommandRunner } from 'nest-commander'; + +import { getters } from '@app/store/index'; +import { LogService } from '@app/unraid-api/cli/log.service'; + +@Injectable() +@Command({ + name: 'config', + description: 'Display current configuration values', +}) +export class ConfigCommand extends CommandRunner { + constructor(private readonly logger: LogService) { + super(); + } + + async run(): Promise { + this.logger.log('\nDisk Configuration:'); + const diskConfig = await readFile(getters.paths()['myservers-config'], 'utf8'); + this.logger.log(diskConfig); + process.exit(0); + } +} diff --git a/api/src/unraid-api/cli/restart.command.ts b/api/src/unraid-api/cli/restart.command.ts index c1631157d0..3e5e969212 100644 --- a/api/src/unraid-api/cli/restart.command.ts +++ b/api/src/unraid-api/cli/restart.command.ts @@ -1,38 +1,27 @@ -import { execSync } from 'child_process'; -import { join } from 'path'; - - - +import { execa } from 'execa'; import { Command, CommandRunner } from 'nest-commander'; - - import { ECOSYSTEM_PATH, PM2_PATH } from '@app/consts'; - - - - +import { LogService } from '@app/unraid-api/cli/log.service'; /** * Stop a running API process and then start it again. */ -@Command({ name: 'restart', description: 'Restart / Start the Unraid API'}) +@Command({ name: 'restart', description: 'Restart / Start the Unraid API' }) export class RestartCommand extends CommandRunner { - async run(_): Promise { - console.log( - 'Dirname is ', - import.meta.dirname, - ' command is ', - `${PM2_PATH} restart ${ECOSYSTEM_PATH} --update-env` - ); - execSync( - `${PM2_PATH} restart ${ECOSYSTEM_PATH} --update-env`, - { - env: process.env, - stdio: 'pipe', - cwd: process.cwd(), - } - ); - } - -} \ No newline at end of file + constructor(private readonly logger: LogService) { + super(); + } + + async run(_): Promise { + const { stderr, stdout } = await execa(PM2_PATH, ['restart', ECOSYSTEM_PATH, '--update-env']); + if (stderr) { + this.logger.error(stderr); + process.exit(1); + } + if (stdout) { + this.logger.info(stdout); + } + process.exit(0); + } +} diff --git a/api/src/unraid-api/cli/sso/add-sso-user.command.ts b/api/src/unraid-api/cli/sso/add-sso-user.command.ts new file mode 100644 index 0000000000..a440f2e559 --- /dev/null +++ b/api/src/unraid-api/cli/sso/add-sso-user.command.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; + +import { CommandRunner, InquirerService, Option, SubCommand } from 'nest-commander'; + +import { store } from '@app/store/index'; +import { addSsoUser, loadConfigFile } from '@app/store/modules/config'; +import { writeConfigSync } from '@app/store/sync/config-disk-sync'; +import { LogService } from '@app/unraid-api/cli/log.service'; +import { AddSSOUserQuestionSet } from '@app/unraid-api/cli/sso/add-sso-user.questions'; + +interface AddSSOUserCommandOptions { + disclaimer: string; + username: string; +} + +@Injectable() +@SubCommand({ + name: 'add-user', + aliases: ['add', 'a'], + description: 'Add a user for SSO', +}) +export class AddSSOUserCommand extends CommandRunner { + constructor( + private readonly logger: LogService, + private readonly inquirerService: InquirerService + ) { + super(); + } + + async run(_input: string[], options: AddSSOUserCommandOptions): Promise { + try { + options = await this.inquirerService.prompt(AddSSOUserQuestionSet.name, options); + console.log(options); + if (options.disclaimer === 'y' && options.username) { + await store.dispatch(loadConfigFile()); + store.dispatch(addSsoUser(options.username)); + writeConfigSync('flash'); + this.logger.info('User added ' + options.username); + } + } catch (e: unknown) { + if (e instanceof Error) { + this.logger.error('Error adding user: ' + e.message); + } else { + this.logger.error('Error adding user'); + } + } + } + + @Option({ + flags: '--username ', + description: 'Cognito Username', + }) + parseUsername(input: string) { + if ( + !/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(input) + ) { + throw new Error('Username must be in the format of a UUID (e.g., ${v4()}}\n'); + } + + return input; + } + + @Option({ + flags: '--disclaimer ', + description: 'Disclaimer (y/n)', + }) + parseDisclaimer(input: string) { + if (!input || !['y', 'n'].includes(input.toLowerCase())) { + throw new Error('Please answer the diclaimer with (y/n)\n'); + } + if (input.toLowerCase() === 'n') { + process.exit(1); + } + return input; + } +} diff --git a/api/src/unraid-api/cli/sso/add-sso-user.questions.ts b/api/src/unraid-api/cli/sso/add-sso-user.questions.ts new file mode 100644 index 0000000000..9f5d36fbb8 --- /dev/null +++ b/api/src/unraid-api/cli/sso/add-sso-user.questions.ts @@ -0,0 +1,52 @@ +import { Question, QuestionSet } from 'nest-commander'; +import { v4 as uuidv4 } from 'uuid'; + +@QuestionSet({ name: 'add-user' }) +export class AddSSOUserQuestionSet { + static name = 'add-user'; + + @Question({ + message: `Enabling Single Sign-On (SSO) will simplify authentication by centralizing access to your Unraid server. However, this comes with certain security considerations: if your SSO account is compromised, unauthorized access to your server could occur. + +Please note: your existing username and password will continue to work alongside SSO. We recommend using 2FA on your Unraid.net account or a single sign-on provider to enhance security. + +Are you sure you want to proceed with adding a user for SSO? (y/n) +`, + name: 'disclaimer', + validate(input) { + if (!input) { + return 'Please provide a response'; + } + if (!['y', 'n'].includes(input.toLowerCase())) { + return 'Please provide a valid response'; + } + if (input.toLowerCase() === 'n') { + process.exit(1); + } + return true; + }, + }) + parseDisclaimer(val: string) { + return val; + } + + @Question({ + message: + "What is your Unique Unraid Account ID? Find it in your Unraid Account at https://account.unraid.net/settings\n", + name: 'username', + validate(input) { + if (!input) { + return 'Username is required'; + } + const randomUUID = uuidv4(); + + if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(input)) { + return `Username must be in the format of a UUID (e.g., ${randomUUID}).`; + } + return true; + }, + }) + parseName(val: string) { + return val; + } +} \ No newline at end of file diff --git a/api/src/unraid-api/cli/sso/list-sso-user.command.ts b/api/src/unraid-api/cli/sso/list-sso-user.command.ts new file mode 100644 index 0000000000..b9804b7771 --- /dev/null +++ b/api/src/unraid-api/cli/sso/list-sso-user.command.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; + +import { CommandRunner, SubCommand } from 'nest-commander'; + +import { store } from '@app/store/index'; +import { loadConfigFile } from '@app/store/modules/config'; +import { LogService } from '@app/unraid-api/cli/log.service'; + +@Injectable() +@SubCommand({ + name: 'list-users', + aliases: ['list', 'l'], + description: 'List all users for SSO', +}) +export class ListSSOUserCommand extends CommandRunner { + constructor( + private readonly logger: LogService, + ) { + super(); + } + + async run(_input: string[]): Promise { + await store.dispatch(loadConfigFile()); + this.logger.info( + store.getState().config.remote.ssoSubIds.split(',').filter(Boolean).join('\n') + ); + } +} diff --git a/api/src/unraid-api/cli/sso/remove-sso-user.command.ts b/api/src/unraid-api/cli/sso/remove-sso-user.command.ts new file mode 100644 index 0000000000..39fb3adc27 --- /dev/null +++ b/api/src/unraid-api/cli/sso/remove-sso-user.command.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; + +import { CommandRunner, InquirerService, Option, OptionChoiceFor, SubCommand } from 'nest-commander'; + +import { store } from '@app/store/index'; +import { loadConfigFile, removeSsoUser } from '@app/store/modules/config'; +import { writeConfigSync } from '@app/store/sync/config-disk-sync'; +import { LogService } from '@app/unraid-api/cli/log.service'; +import { RemoveSSOUserQuestionSet } from '@app/unraid-api/cli/sso/remove-sso-user.questions'; + +interface RemoveSSOUserCommandOptions { + username: string; +} + +@Injectable() +@SubCommand({ + name: 'remove-user', + aliases: ['remove', 'r'], + description: 'Remove a user (or all users) from SSO', +}) +export class RemoveSSOUserCommand extends CommandRunner { + constructor( + private readonly logger: LogService, + private readonly inquirerService: InquirerService + ) { + super(); + } + public async run(_input: string[], options: RemoveSSOUserCommandOptions): Promise { + await store.dispatch(loadConfigFile()); + options = await this.inquirerService.prompt(RemoveSSOUserQuestionSet.name, options); + store.dispatch(removeSsoUser(options.username === 'all' ? null : options.username)); + if (options.username === 'all') { + this.logger.info('All users removed from SSO'); + } else { + this.logger.info('User removed: ' + options.username); + } + writeConfigSync('flash'); + } + + @Option({ + name: 'username', + flags: '--username ', + description: 'Cognito Username', + }) + parseUsername(input: string) { + if (!input) { + throw new Error('Username is required\n'); + } + + if ( + !/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(input) + ) { + throw new Error('Username must be in the format of a UUID (e.g., ${v4()}}\n'); + } + + return input; + } +} diff --git a/api/src/unraid-api/cli/sso/remove-sso-user.questions.ts b/api/src/unraid-api/cli/sso/remove-sso-user.questions.ts new file mode 100644 index 0000000000..cea993d187 --- /dev/null +++ b/api/src/unraid-api/cli/sso/remove-sso-user.questions.ts @@ -0,0 +1,30 @@ +import { ChoicesFor, Question, QuestionSet, } from 'nest-commander'; + +import { store } from '@app/store/index'; +import { LogService } from '@app/unraid-api/cli/log.service'; + +@QuestionSet({ name: 'remove-user' }) +export class RemoveSSOUserQuestionSet { + constructor(private readonly logger: LogService) {} + static name = 'remove-user'; + + @Question({ + message: `Please select from the following list of users to remove from SSO, or enter all to remove all users from SSO.\n`, + name: 'username', + type: 'list', + }) + parseName(val: string) { + return val; + } + + @ChoicesFor({ name: 'username' }) + async choicesForUsername() { + const users = store.getState().config.remote.ssoSubIds.split(',').filter((user) => user !== ''); + if (users.length === 0) { + this.logger.error('No SSO Users Found'); + process.exit(0); + } + users.push('all'); + return users; + } +} diff --git a/api/src/unraid-api/cli/sso/sso.command.ts b/api/src/unraid-api/cli/sso/sso.command.ts new file mode 100644 index 0000000000..bea0828811 --- /dev/null +++ b/api/src/unraid-api/cli/sso/sso.command.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; + +import { Command, CommandRunner } from 'nest-commander'; + +import { LogService } from '@app/unraid-api/cli/log.service'; +import { ValidateTokenCommand } from '@app/unraid-api/cli/sso/validate-token.command'; +import { AddSSOUserCommand } from '@app/unraid-api/cli/sso/add-sso-user.command'; +import { RemoveSSOUserCommand } from '@app/unraid-api/cli/sso/remove-sso-user.command'; +import { ListSSOUserCommand } from '@app/unraid-api/cli/sso/list-sso-user.command'; + +@Injectable() +@Command({ + name: 'sso', + description: 'Main Command to Configure / Validate SSO Tokens', + subCommands: [ValidateTokenCommand, AddSSOUserCommand, RemoveSSOUserCommand, ListSSOUserCommand], +}) +export class SSOCommand extends CommandRunner { + constructor(private readonly logger: LogService) { + super(); + } + + async run(): Promise { + this.logger.info('Please provide a subcommand or use --help for more information'); + process.exit(0); + } +} diff --git a/api/src/unraid-api/cli/validate-token.command.ts b/api/src/unraid-api/cli/sso/validate-token.command.ts similarity index 53% rename from api/src/unraid-api/cli/validate-token.command.ts rename to api/src/unraid-api/cli/sso/validate-token.command.ts index 6febc5b9db..902fa28da5 100644 --- a/api/src/unraid-api/cli/validate-token.command.ts +++ b/api/src/unraid-api/cli/sso/validate-token.command.ts @@ -1,20 +1,15 @@ import type { JWTPayload } from 'jose'; import { createLocalJWKSet, createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose'; -import { Command, CommandRunner } from 'nest-commander'; +import { CommandRunner, SubCommand } from 'nest-commander'; import { JWKS_LOCAL_PAYLOAD, JWKS_REMOTE_LINK } from '@app/consts'; import { store } from '@app/store'; import { loadConfigFile } from '@app/store/modules/config'; import { LogService } from '@app/unraid-api/cli/log.service'; -const createJsonErrorString = (errorMessage: string) => - JSON.stringify({ - error: errorMessage, - valid: false, - }); - -@Command({ +@SubCommand({ name: 'validate-token', + aliases: ['validate', 'v'], description: 'Returns JSON: { error: string | null, valid: boolean }', arguments: '', }) @@ -26,21 +21,35 @@ export class ValidateTokenCommand extends CommandRunner { this.JWKSOffline = createLocalJWKSet(JWKS_LOCAL_PAYLOAD); this.JWKSOnline = createRemoteJWKSet(new URL(JWKS_REMOTE_LINK)); } + + private createErrorAndExit = (errorMessage: string) => { + this.logger.error( + JSON.stringify({ + error: errorMessage, + valid: false, + }) + ); + process.exit(1); + }; + async run(passedParams: string[]): Promise { if (passedParams.length !== 1) { - this.logger.error('Please pass token argument only'); + this.createErrorAndExit('Please pass token argument only'); } const token = passedParams[0]; + if (typeof token !== 'string' || token.trim() === '') { + this.createErrorAndExit('Invalid token provided'); + } let caughtError: null | unknown = null; let tokenPayload: null | JWTPayload = null; try { - this.logger.debug('Attempting to validate token with local key'); + // this.logger.debug('Attempting to validate token with local key'); tokenPayload = (await jwtVerify(token, this.JWKSOffline)).payload; } catch (error: unknown) { try { - this.logger.debug('Local validation failed for key, trying remote validation'); + // this.logger.debug('Local validation failed for key, trying remote validation'); tokenPayload = (await jwtVerify(token, this.JWKSOnline)).payload; } catch (error: unknown) { caughtError = error; @@ -49,31 +58,33 @@ export class ValidateTokenCommand extends CommandRunner { if (caughtError) { if (caughtError instanceof Error) { - this.logger.error( - createJsonErrorString(`Caught error validating jwt token: ${caughtError.message}`) - ); + this.createErrorAndExit(`Caught error validating jwt token: ${caughtError.message}`); } else { - this.logger.error(createJsonErrorString('Caught error validating jwt token')); + this.createErrorAndExit('Caught unknown error validating jwt token'); } } if (tokenPayload === null) { - this.logger.error(createJsonErrorString('No data in JWT to use for user validation')); + this.createErrorAndExit('No data in JWT to use for user validation'); } - const username = tokenPayload!.username ?? tokenPayload!['cognito:username']; + const username = tokenPayload?.sub; + + if (!username) { + return this.createErrorAndExit('No ID found in token'); + } const configFile = await store.dispatch(loadConfigFile()).unwrap(); - if (!configFile.remote?.accesstoken) { - this.logger.error(createJsonErrorString('No local user token set to compare to')); + if (!configFile.remote?.ssoSubIds) { + this.createErrorAndExit( + 'No local user token set to compare to - please set any valid SSO IDs you would like to sign in with' + ); } - - const existingUserPayload = decodeJwt(configFile.remote?.accesstoken); - if (username === existingUserPayload.username) { - this.logger.info(JSON.stringify({ error: null, valid: true })); + const possibleUserIds = configFile.remote.ssoSubIds.split(','); + if (possibleUserIds.includes(username)) { + this.logger.info(JSON.stringify({ error: null, valid: true, username })); + process.exit(0); } else { - this.logger.error( - createJsonErrorString('Username on token does not match logged in user name') - ); + this.createErrorAndExit('Username on token does not match'); } } } diff --git a/api/src/unraid-api/cli/start.command.ts b/api/src/unraid-api/cli/start.command.ts index e7fdfbe401..7a81c36535 100644 --- a/api/src/unraid-api/cli/start.command.ts +++ b/api/src/unraid-api/cli/start.command.ts @@ -21,11 +21,11 @@ export class StartCommand extends CommandRunner { async run(_, options: StartCommandOptions): Promise { this.logger.info('Starting the Unraid API'); const envLog = options['log-level'] ? `LOG_LEVEL=${options['log-level']}` : ''; - const { stderr, stdout } = await execa( - `${envLog} ${PM2_PATH}`.trim(), - ['start', ECOSYSTEM_PATH, '--update-env'], - { stdio: 'inherit' } - ); + const { stderr, stdout } = await execa(`${envLog} ${PM2_PATH}`.trim(), [ + 'start', + ECOSYSTEM_PATH, + '--update-env', + ]); if (stdout) { this.logger.log(stdout); } @@ -33,6 +33,7 @@ export class StartCommand extends CommandRunner { this.logger.error(stderr); process.exit(1); } + process.exit(0); } @Option({ diff --git a/api/src/unraid-api/cli/stop.command.ts b/api/src/unraid-api/cli/stop.command.ts index 4841821a12..bf5ec3770d 100644 --- a/api/src/unraid-api/cli/stop.command.ts +++ b/api/src/unraid-api/cli/stop.command.ts @@ -19,5 +19,6 @@ export class StopCommand extends CommandRunner { this.logger.warn(stderr); process.exit(1); } + process.exit(0); } } diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index f3e5a64d33..50a2a395ef 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -759,10 +759,6 @@ if ([[ -n "${email}" && (-z "${apikey}" || "${#apikey}" -ne "64") ]]); then }' "${CFG}">"${CFG}-new" && mv "${CFG}-new" "${CFG}" CFG_CLEANED=1 echo "⚠️ Automatically signed out of Unraid.net" fi -# if there wasn't an email or the CFG was cleaned -if [[ -z "${email}" ]] || [[ CFG_CLEANED -eq 1 ]]; then - echo "✨ Sign In to Unraid.net to use Unraid Connect ✨" -fi # configure flash backup to stop when the system starts shutting down [[ ! -d /etc/rc.d/rc6.d ]] && mkdir /etc/rc.d/rc6.d diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/sso-login.php b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/sso-login.php new file mode 100644 index 0000000000..b6370fa153 --- /dev/null +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/sso-login.php @@ -0,0 +1,22 @@ +getScriptTagHtml(); +?> + + + "> + \ No newline at end of file diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php index e09f6909d1..8fe37f6a94 100644 --- a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php @@ -8,6 +8,7 @@ * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. */ + /** * @todo refactor globals – currently if you try to use $GLOBALS the class will break. */ @@ -53,6 +54,10 @@ class ServerState "nokeyserver" => 'NO_KEY_SERVER', "withdrawn" => 'WITHDRAWN', ]; + /** + * SSO Sub IDs from the my servers config file. + */ + public $ssoEnabled = false; private $osVersion; private $osVersionBranch; private $rebootDetails; @@ -67,7 +72,7 @@ class ServerState public $myServersMemoryCfg = []; public $host = 'unknown'; public $combinedKnownOrigins = []; - + public $nginxCfg = []; public $flashbackupStatus = []; public $registered = false; @@ -86,7 +91,7 @@ public function __construct() * @see - getWebguiGlobal() for usage * */ global $webguiGlobals; - $this->webguiGlobals =& $webguiGlobals; + $this->webguiGlobals = &$webguiGlobals; // echo "
" . json_encode($this->webguiGlobals, JSON_PRETTY_PRINT) . "
"; $this->var = (array)parse_ini_file('state/var.ini'); @@ -119,7 +124,8 @@ public function __construct() /** * Retrieve the value of a webgui global setting. */ - public function getWebguiGlobal(string $key, string $subkey = null) { + public function getWebguiGlobal(string $key, string $subkey = null) + { if (!$subkey) { return _var($this->webguiGlobals, $key, ''); } @@ -127,7 +133,8 @@ public function getWebguiGlobal(string $key, string $subkey = null) { return _var($keyArray, $subkey, ''); } - private function setConnectValues() { + private function setConnectValues() + { if (file_exists('/var/lib/pkgtools/packages/dynamix.unraid.net')) { $this->connectPluginInstalled = 'dynamix.unraid.net.plg'; } @@ -154,13 +161,15 @@ private function setConnectValues() { $this->getFlashBackupStatus(); } - private function getFlashBackupStatus() { + private function getFlashBackupStatus() + { $flashbackupCfg = '/var/local/emhttp/flashbackup.ini'; $this->flashbackupStatus = (file_exists($flashbackupCfg)) ? @parse_ini_file($flashbackupCfg) : []; $this->flashBackupActivated = empty($this->flashbackupStatus['activated']) ? '' : 'true'; } - private function getMyServersCfgValues() { + private function getMyServersCfgValues() + { /** * @todo can we read this from somewhere other than the flash? Connect page uses this path and /boot/config/plugins/dynamix.my.servers/myservers.cfg… * - $myservers_memory_cfg_path ='/var/local/emhttp/myservers.cfg'; @@ -193,9 +202,11 @@ private function getMyServersCfgValues() { $this->registered = !empty($this->myServersFlashCfg['remote']['apikey']) && $this->connectPluginInstalled; $this->registeredTime = $this->myServersFlashCfg['remote']['regWizTime'] ?? ''; $this->username = $this->myServersFlashCfg['remote']['username'] ?? ''; + $this->ssoEnabled = $this->myServersFlashCfg['remote']['ssoSubIds'] !== ''; } - private function getConnectKnownOrigins() { + private function getConnectKnownOrigins() + { /** * Allowed origins warning displayed when the current webGUI URL is NOT included in the known lists of allowed origins. * Include localhost in the test, but only display HTTP(S) URLs that do not include localhost. @@ -203,7 +214,7 @@ private function getConnectKnownOrigins() { $this->host = $_SERVER['HTTP_HOST'] ?? "unknown"; $memoryCfgPath = '/var/local/emhttp/myservers.cfg'; $this->myServersMemoryCfg = (file_exists($memoryCfgPath)) ? @parse_ini_file($memoryCfgPath) : []; - $this->myServersMiniGraphConnected = (($this->myServersMemoryCfg['minigraph']??'') === 'CONNECTED'); + $this->myServersMiniGraphConnected = (($this->myServersMemoryCfg['minigraph'] ?? '') === 'CONNECTED'); $allowedOrigins = $this->myServersMemoryCfg['allowedOrigins'] ?? ""; $extraOrigins = $this->myServersFlashCfg['api']['extraOrigins'] ?? ""; @@ -219,8 +230,8 @@ private function getConnectKnownOrigins() { $this->combinedKnownOrigins = explode(",", $combinedOrigins); if ($this->combinedKnownOrigins) { - foreach($this->combinedKnownOrigins as $key => $origin) { - if ( (strpos($origin, "http") === false) || (strpos($origin, "localhost") !== false) ) { + foreach ($this->combinedKnownOrigins as $key => $origin) { + if ((strpos($origin, "http") === false) || (strpos($origin, "localhost") !== false)) { // clean up $this->combinedKnownOrigins, only display warning if origins still remain to display unset($this->combinedKnownOrigins[$key]); } @@ -233,7 +244,8 @@ private function getConnectKnownOrigins() { } } - private function detectActivationCode() { + private function detectActivationCode() + { // Fresh server and we're not loading with a callback param to install if ($this->state !== 'ENOKEYFILE' || !empty($_GET['c'])) { return; @@ -307,6 +319,7 @@ public function getServerState() "registered" => $this->registered, "registeredTime" => $this->registeredTime, "site" => _var($_SERVER, 'REQUEST_SCHEME') . "://" . _var($_SERVER, 'HTTP_HOST'), + "ssoEnabled" => $this->ssoEnabled, "state" => $this->state, "theme" => [ "banner" => !empty($this->getWebguiGlobal('display', 'banner')), @@ -351,7 +364,8 @@ public function getServerState() * * @return string */ - public function getServerStateJson() { + public function getServerStateJson() + { return json_encode($this->getServerState()); } @@ -360,7 +374,8 @@ public function getServerStateJson() { * * @return string */ - public function getServerStateJsonForHtmlAttr() { + public function getServerStateJsonForHtmlAttr() + { $json = json_encode($this->getServerState()); return htmlspecialchars($json, ENT_QUOTES, 'UTF-8'); } diff --git a/web/_data/serverState.ts b/web/_data/serverState.ts index 945532889b..a1c4e91f64 100644 --- a/web/_data/serverState.ts +++ b/web/_data/serverState.ts @@ -1,3 +1,4 @@ +; // import dayjs, { extend } from 'dayjs'; // import customParseFormat from 'dayjs/plugin/customParseFormat'; // import relativeTime from 'dayjs/plugin/relativeTime'; @@ -6,11 +7,10 @@ // import QueryStringAddon from 'wretch/addons/queryString'; // import { OS_RELEASES } from '~/helpers/urls'; -import type { - Server, - ServerState, - // ServerUpdateOsResponse, -} from "~/types/server"; +import type { Server, ServerState +// ServerUpdateOsResponse, +} from '~/types/server'; + // dayjs plugins // extend(customParseFormat); @@ -44,10 +44,10 @@ import type { // EBLACKLISTED2 // ENOCONN -const state: ServerState = "ENOKEYFILE" as ServerState; -const currentFlashGuid = "1111-1111-YIJD-ZACK1234TEST"; // this is the flash drive that's been booted from -const regGuid = "1111-1111-YIJD-ZACK1234TEST"; // this guid is registered in key server -const keyfileBase64 = ""; +const state: ServerState = 'ENOKEYFILE' as ServerState; +const currentFlashGuid = '1111-1111-YIJD-ZACK1234TEST'; // this is the flash drive that's been booted from +const regGuid = '1111-1111-YIJD-ZACK1234TEST'; // this guid is registered in key server +const keyfileBase64 = ''; // const randomGuid = `1111-1111-${makeid(4)}-123412341234`; // this guid is registered in key server // const newGuid = `1234-1234-${makeid(4)}-123412341234`; // this is a new USB, not registered @@ -65,50 +65,50 @@ let expireTime = 0; let regExp: number | undefined; let regDevs = 0; -let regTy = ""; +let regTy = ''; switch (state) { - case "EEXPIRED": + case 'EEXPIRED': expireTime = uptime; // 1 hour ago break; - case "ENOCONN": + case 'ENOCONN': break; - case "TRIAL": + case 'TRIAL': expireTime = oneHourFromNow; // in 1 hour - regTy = "Trial"; + regTy = 'Trial'; break; - case "BASIC": + case 'BASIC': regDevs = 6; - regTy = "Basic"; + regTy = 'Basic'; break; - case "PLUS": + case 'PLUS': regDevs = 12; - regTy = "Plus"; + regTy = 'Plus'; break; - case "PRO": + case 'PRO': regDevs = -1; - regTy = "Pro"; + regTy = 'Pro'; break; - case "STARTER": + case 'STARTER': regDevs = 6; regExp = ninetyDaysAgo; - regTy = "Starter"; + regTy = 'Starter'; break; - case "UNLEASHED": + case 'UNLEASHED': regDevs = -1; regExp = ninetyDaysAgo; - regTy = "Unleashed"; + regTy = 'Unleashed'; break; - case "LIFETIME": + case 'LIFETIME': regDevs = -1; - regTy = "Lifetime"; + regTy = 'Lifetime'; break; } // const connectPluginInstalled = 'dynamix.unraid.net.staging.plg'; -const connectPluginInstalled = "dynamix.unraid.net.staging.plg"; +const connectPluginInstalled = 'dynamix.unraid.net.staging.plg'; -const osVersion = "7.0.0-beta.2.10"; -const osVersionBranch = "stable"; +const osVersion = '7.0.0-beta.2.10'; +const osVersionBranch = 'stable'; // const parsedRegExp = regExp ? dayjs(regExp).format('YYYY-MM-DD') : undefined; // const mimicWebguiUnraidCheck = async (): Promise => { @@ -134,62 +134,63 @@ const osVersionBranch = "stable"; export const serverState: Server = { activationCodeData: { - "code": "CC2KP3TDRF", - "partnerName": "OEM Partner", - "partnerUrl": "https://unraid.net/OEM+Partner", - "sysModel": "OEM Partner v1", - "comment": "OEM Partner NAS", - "caseIcon": "case-model.png", - "header": "#ffffff", - "headermetacolor": "#eeeeee", - "background": "#000000", - "showBannerGradient": "yes", - "partnerLogo": true, + code: 'CC2KP3TDRF', + partnerName: 'OEM Partner', + partnerUrl: 'https://unraid.net/OEM+Partner', + sysModel: 'OEM Partner v1', + comment: 'OEM Partner NAS', + caseIcon: 'case-model.png', + header: '#ffffff', + headermetacolor: '#eeeeee', + background: '#000000', + showBannerGradient: 'yes', + partnerLogo: true, }, - apiKey: "unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810", - avatar: "https://source.unsplash.com/300x300/?portrait", + apiKey: 'unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810', + avatar: 'https://source.unsplash.com/300x300/?portrait', config: { id: 'config-id', error: null, valid: false, }, connectPluginInstalled, - description: "DevServer9000", + description: 'DevServer9000', deviceCount: 3, expireTime, flashBackupActivated: !!connectPluginInstalled, - flashProduct: "SanDisk_3.2Gen1", - flashVendor: "USB", + flashProduct: 'SanDisk_3.2Gen1', + flashVendor: 'USB', guid: currentFlashGuid, // "guid": "0781-5583-8355-81071A2B0211", inIframe: false, // keyfile: 'DUMMY_KEYFILE', keyfile: keyfileBase64, - lanIp: "192.168.254.36", - license: "", - locale: "en_US", // en_US, ja - name: "dev-static", + lanIp: '192.168.254.36', + license: '', + locale: 'en_US', // en_US, ja + name: 'dev-static', osVersion, osVersionBranch, registered: connectPluginInstalled ? true : false, // registered: false, regGen: 0, regTm: twoDaysAgo, - regTo: "Zack Spear", + regTo: 'Zack Spear', regTy, regDevs, regExp, regGuid, - site: "http://localhost:4321", + site: 'http://localhost:4321', + ssoEnabled: true, state, theme: { banner: false, bannerGradient: false, - bgColor: "", + bgColor: '', descriptionShow: true, - metaColor: "", - name: "white", - textColor: "", + metaColor: '', + name: 'white', + textColor: '', }, // updateOsResponse: { // version: '6.12.6', @@ -201,6 +202,6 @@ export const serverState: Server = { // sha256: '2f5debaf80549029cf6dfab0db59180e7e3391c059e6521aace7971419c9c4bf', // }, uptime, - username: "zspearmint", - wanFQDN: "", -}; + username: 'zspearmint', + wanFQDN: '', +}; \ No newline at end of file diff --git a/web/components/SsoButton.ce.vue b/web/components/SsoButton.ce.vue new file mode 100644 index 0000000000..5b53cd6b99 --- /dev/null +++ b/web/components/SsoButton.ce.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/web/nuxt.config.ts b/web/nuxt.config.ts index b4a78b4fcb..83170261ff 100644 --- a/web/nuxt.config.ts +++ b/web/nuxt.config.ts @@ -40,119 +40,122 @@ const charsToReserve = '_$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01 // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ - ssr: false, - - devServer: { - port: 4321, - }, - - devtools: { - enabled: true, - }, - - modules: [ - "@vueuse/nuxt", - "@pinia/nuxt", - "@nuxtjs/tailwindcss", - "nuxt-custom-elements", - "@nuxt/eslint", - "shadcn-nuxt", - ], - - ignore: ['/webGui/images'], - - components: [ - { path: "~/components/Brand", prefix: "Brand" }, - { path: "~/components/ConnectSettings", prefix: "ConnectSettings" }, - { path: "~/components/Ui", prefix: "Ui" }, - { path: "~/components/UserProfile", prefix: "Upc" }, - { path: "~/components/UpdateOs", prefix: "UpdateOs" }, - "~/components", - ], - - // typescript: { - // typeCheck: true - // }, - shadcn: { - prefix: "", - componentDir: "./components/shadcn", - }, - - vite: { - plugins: [ - !process.env.VITE_ALLOW_CONSOLE_LOGS && - removeConsole({ - includes: ["log", "warn", "error", "info", "debug"], - }), - ], - build: { - minify: "terser", - terserOptions: { - mangle: { - reserved: terserReservations(charsToReserve), - toplevel: true, - }, - }, - }, - }, - - customElements: { - entries: [ - { - name: "UnraidComponents", - tags: [ - { - name: "UnraidI18nHost", - path: "@/components/I18nHost.ce", - }, - { - name: "UnraidAuth", - path: "@/components/Auth.ce", - }, - { - name: "UnraidConnectSettings", - path: "@/components/ConnectSettings/ConnectSettings.ce", - }, - { - name: "UnraidDownloadApiLogs", - path: "@/components/DownloadApiLogs.ce", - }, - { - name: "UnraidHeaderOsVersion", - path: "@/components/HeaderOsVersion.ce", - }, - { - name: "UnraidModals", - path: "@/components/Modals.ce", - }, - { - name: "UnraidUserProfile", - path: "@/components/UserProfile.ce", - }, - { - name: "UnraidUpdateOs", - path: "@/components/UpdateOs.ce", - }, - { - name: "UnraidDowngradeOs", - path: "@/components/DowngradeOs.ce", - }, - { - name: "UnraidRegistration", - path: "@/components/Registration.ce", - }, - { - name: "UnraidWanIpCheck", - path: "@/components/WanIpCheck.ce", - }, - { - name: "UnraidWelcomeModal", - path: "@/components/WelcomeModal.ce", - }, - ], - }, - ], - }, - - compatibilityDate: "2024-12-05" + ssr: false, + + devServer: { + port: 4321, + }, + + devtools: { + enabled: true, + }, + + modules: [ + '@vueuse/nuxt', + '@pinia/nuxt', + '@nuxtjs/tailwindcss', + 'nuxt-custom-elements', + '@nuxt/eslint', + 'shadcn-nuxt', + ], + + ignore: ['/webGui/images'], + + components: [ + { path: '~/components/Brand', prefix: 'Brand' }, + { path: '~/components/ConnectSettings', prefix: 'ConnectSettings' }, + { path: '~/components/Ui', prefix: 'Ui' }, + { path: '~/components/UserProfile', prefix: 'Upc' }, + { path: '~/components/UpdateOs', prefix: 'UpdateOs' }, + '~/components', + ], + + // typescript: { + // typeCheck: true + // }, + shadcn: { + prefix: '', + componentDir: './components/shadcn', + }, + + vite: { + plugins: [ + !process.env.VITE_ALLOW_CONSOLE_LOGS && + removeConsole({ + includes: ['log', 'warn', 'error', 'info', 'debug'], + }), + ], + build: { + minify: 'terser', + terserOptions: { + mangle: { + reserved: terserReservations(charsToReserve), + toplevel: true, + }, + }, + }, + }, + + customElements: { + entries: [ + { + name: 'UnraidComponents', + tags: [ + { + name: 'UnraidI18nHost', + path: '@/components/I18nHost.ce', + }, + { + name: 'UnraidAuth', + path: '@/components/Auth.ce', + }, + { + name: 'UnraidConnectSettings', + path: '@/components/ConnectSettings/ConnectSettings.ce', + }, + { + name: 'UnraidDownloadApiLogs', + path: '@/components/DownloadApiLogs.ce', + }, + { + name: 'UnraidHeaderOsVersion', + path: '@/components/HeaderOsVersion.ce', + }, + { + name: 'UnraidModals', + path: '@/components/Modals.ce', + }, + { + name: 'UnraidUserProfile', + path: '@/components/UserProfile.ce', + }, + { + name: 'UnraidUpdateOs', + path: '@/components/UpdateOs.ce', + }, + { + name: 'UnraidDowngradeOs', + path: '@/components/DowngradeOs.ce', + }, + { + name: 'UnraidRegistration', + path: '@/components/Registration.ce', + }, + { + name: 'UnraidWanIpCheck', + path: '@/components/WanIpCheck.ce', + }, + { + name: 'UnraidWelcomeModal', + path: '@/components/WelcomeModal.ce', + }, + { name: 'UnraidSsoButton', + path: '@/components/SsoButton.ce' + }, + ], + }, + ], + }, + + compatibilityDate: '2024-12-05', }); diff --git a/web/pages/index.vue b/web/pages/index.vue index e3c52db23f..b999b39105 100644 --- a/web/pages/index.vue +++ b/web/pages/index.vue @@ -4,6 +4,7 @@ import { BrandButton, BrandLogo } from '@unraid/ui'; import { serverState } from '~/_data/serverState'; import type { SendPayloads } from '~/store/callback'; import AES from 'crypto-js/aes'; +import SsoButtonCe from '~/components/SsoButton.ce.vue'; const { registerEntry } = useCustomElements(); onBeforeMount(() => { @@ -152,6 +153,11 @@ onMounted(() => { > +
+
+

SSO Button Component

+ +
diff --git a/web/pages/login.vue b/web/pages/login.vue new file mode 100644 index 0000000000..100c2a5c74 --- /dev/null +++ b/web/pages/login.vue @@ -0,0 +1,309 @@ + + + + diff --git a/web/pages/webComponents.vue b/web/pages/webComponents.vue index 0b03597cbc..42159b072a 100644 --- a/web/pages/webComponents.vue +++ b/web/pages/webComponents.vue @@ -75,7 +75,12 @@ onBeforeMount(() => {

ModalsCe

- + +
+

+ SSOSignInButtonCe +

+ diff --git a/web/store/server.ts b/web/store/server.ts index 8de3ee4037..6bf83f896a 100644 --- a/web/store/server.ts +++ b/web/store/server.ts @@ -155,6 +155,7 @@ export const useServerStore = defineStore("server", () => { return today.isAfter(parsedUpdateExpirationDate, "day"); }); const site = ref(""); + const ssoEnabled = ref(false); const state = ref(); const theme = ref(); watch(theme, (newVal) => { @@ -1208,6 +1209,9 @@ export const useServerStore = defineStore("server", () => { if (typeof data?.regTo !== "undefined") { regTo.value = data.regTo; } + if (typeof data?.ssoEnabled !== "undefined") { + ssoEnabled.value = Boolean(data.ssoEnabled); + } if (typeof data.activationCodeData !== "undefined") { const activationCodeStore = useActivationCodeStore(); @@ -1474,6 +1478,7 @@ export const useServerStore = defineStore("server", () => { parsedRegExp, regUpdatesExpired, site, + ssoEnabled, state, theme, updateOsIgnoredReleases, diff --git a/web/types/server.ts b/web/types/server.ts index 70200b8aa6..374094949e 100644 --- a/web/types/server.ts +++ b/web/types/server.ts @@ -1,7 +1,8 @@ import type { Config, PartialCloudFragment } from '~/composables/gql/graphql'; +import type { ActivationCodeData } from '~/store/activationCode'; import type { Theme } from '~/store/theme'; import type { UserProfileLink } from '~/types/userProfile'; -import type { ActivationCodeData } from '~/store/activationCode'; + export type ServerState = 'BASIC' | 'PLUS' @@ -108,6 +109,7 @@ export interface Server { regExp?: number; regUpdatesExpired?: boolean; site?: string; + ssoEnabled?: boolean; state?: ServerState; theme?: Theme | undefined; updateOsIgnoredReleases?: string[]; @@ -195,4 +197,4 @@ export interface ServerStateData { message: string; error?: ServerStateDataError | boolean; withKey?: boolean; // @todo potentially remove -} +} \ No newline at end of file