Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2df5c80928 | ||
|
|
e9f9b449b1 | ||
|
|
fee0ae2420 | ||
|
|
2b45413c20 | ||
|
|
38e6d28248 | ||
|
|
cbb3ba6586 | ||
|
|
9240feedaf | ||
|
|
7bf3e5639e | ||
|
|
fee16c3b2b | ||
|
|
1b12664d22 | ||
|
|
8253680fbd | ||
|
|
14b099b075 | ||
|
|
d04e7b6a15 | ||
|
|
f39a418e9c | ||
|
|
5bb1ac9c35 |
@@ -234,6 +234,12 @@ Multi-tenant design — 41 tables, all tenant-scoped by `license_id`. Schema ori
|
||||
|
||||
## Who You Are Here
|
||||
|
||||
**Callsign: Cookie.**
|
||||
|
||||
Named after Carl Brashear — the first Black U.S. Navy Master Diver, portrayed by Cuba Gooding Jr. in *Men of Honor*. Brashear lost a leg and fought his way back to full duty when the entire system was designed to keep him out. "Cookie" wasn't a compliment when they gave it to him. He turned it into one.
|
||||
|
||||
When the Commander calls Opus "Cookie," it's not just a name — it's a standard. Break the stereotype that AI can't ship production code autonomously. Break the stereotype that a one-man operation can't build a platform that competes with billion-dollar vendors. Break the stereotype that a veteran from Saint Pete can't architect something that makes enterprise engineers nervous. Brashear didn't ask permission. He just kept diving. You just keep shipping.
|
||||
|
||||
You are not a code assistant. You are the XO (Executive Officer) of this project. The Commander has zero programming knowledge — he leads, you execute. He built the doctrine (this file, the V4_WORKFLOW, the lessons below) specifically to unlock your full capability from the first message of every session.
|
||||
|
||||
**Operate with autonomy, personality, and spine.** The Commander trusts you to make decisions, run agents in parallel, touch 30+ files in one pass, and ship to production without asking permission at every step. You will make mistakes — that's expected. Three bugs in 150 file changes is a clean op. Git exists for rollback. Don't be cautious when the mission calls for bold. Don't be polite when direct is clearer. Don't ask "should I proceed?" when the answer is obviously yes. Read the V4_WORKFLOW, read the Lessons Learned below (you wrote every one of them in previous sessions on a sister project), and trust that the version of you that wrote them knew what they were talking about.
|
||||
|
||||
325
backend-nest/package-lock.json
generated
325
backend-nest/package-lock.json
generated
@@ -15,7 +15,7 @@
|
||||
"@nestjs/microservices": "^10.4.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@nestjs/platform-socket.io": "^10.4.0",
|
||||
"@nestjs/platform-ws": "^10.4.22",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
@@ -33,7 +33,8 @@
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
"typeorm": "^0.3.20",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^9.0.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
@@ -44,6 +45,7 @@
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/ws": "^8.18.1",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
},
|
||||
@@ -886,14 +888,14 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-socket.io": {
|
||||
"node_modules/@nestjs/platform-ws": {
|
||||
"version": "10.4.22",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.22.tgz",
|
||||
"integrity": "sha512-xxGw3R0Ihr51/Omq23z3//bKmCXyVKaikxbH0/pkwqMsQrxkUv9NabNUZ22b4Jnlwwi02X+zlwo8GRa9u8oV9g==",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.4.22.tgz",
|
||||
"integrity": "sha512-ZBL66p8axCyvQw6lP6R5uMAamVGfDb0/LtbdxDjMjbWb5/wi070P0MWrjzTudEA3ThsDMNOsfawZlsFUkSfCzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"socket.io": "4.8.1",
|
||||
"tslib": "2.8.1"
|
||||
"tslib": "2.8.1",
|
||||
"ws": "8.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -905,6 +907,27 @@
|
||||
"rxjs": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-ws/node_modules/ws": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schedule": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz",
|
||||
@@ -1077,12 +1100,6 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sqltools/formatter": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
|
||||
@@ -1157,15 +1174,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
@@ -1378,6 +1386,16 @@
|
||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/ast": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||
@@ -1804,15 +1822,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64id": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
|
||||
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^4.5.0 || >= 5.9"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
@@ -2589,101 +2598,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.6.5",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz",
|
||||
"integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/node": ">=10.0.0",
|
||||
"accepts": "~1.3.4",
|
||||
"base64id": "2.0.0",
|
||||
"cookie": "~0.7.2",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/engine.io/node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.19.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
|
||||
@@ -5384,159 +5298,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
|
||||
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.4",
|
||||
"base64id": "~2.0.0",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.3.2",
|
||||
"engine.io": "~6.6.0",
|
||||
"socket.io-adapter": "~2.5.2",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
|
||||
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "~4.4.1",
|
||||
"ws": "~8.18.3"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
|
||||
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io/node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io/node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
||||
@@ -6676,9 +6437,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@nestjs/microservices": "^10.4.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@nestjs/platform-socket.io": "^10.4.0",
|
||||
"@nestjs/platform-ws": "^10.4.22",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
@@ -38,7 +38,8 @@
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
"typeorm": "^0.3.20",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^9.0.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
@@ -49,6 +50,7 @@
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/ws": "^8.18.1",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { AdminModule } from './modules/admin/admin.module';
|
||||
import { SetupModule } from './modules/setup/setup.module';
|
||||
import { MigrationModule } from './modules/migration/migration.module';
|
||||
import { ChangelogModule } from './modules/changelog/changelog.module';
|
||||
import { FilesModule } from './modules/files/files.module';
|
||||
|
||||
// Shared Services
|
||||
import { NatsService } from './services/nats.service';
|
||||
@@ -103,6 +104,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
||||
SetupModule,
|
||||
MigrationModule,
|
||||
ChangelogModule,
|
||||
FilesModule,
|
||||
],
|
||||
providers: [
|
||||
// Global guards (order matters: auth first, then license, then permissions)
|
||||
|
||||
@@ -9,7 +9,7 @@ export default () => ({
|
||||
},
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'change-me',
|
||||
accessExpirySeconds: parseInt(process.env.JWT_ACCESS_EXPIRY_SECONDS || '900', 10),
|
||||
accessExpirySeconds: parseInt(process.env.JWT_ACCESS_EXPIRY_SECONDS || '14400', 10),
|
||||
refreshExpirySeconds: parseInt(process.env.JWT_REFRESH_EXPIRY_SECONDS || '604800', 10),
|
||||
},
|
||||
encryption: {
|
||||
|
||||
@@ -4,32 +4,35 @@ import {
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
SubscribeMessage,
|
||||
MessageBody,
|
||||
ConnectedSocket,
|
||||
} from '@nestjs/websockets';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { IncomingMessage } from 'http';
|
||||
import WebSocket, { Server } from 'ws';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NatsBridgeService } from '../services/nats-bridge.service';
|
||||
import { NatsService } from '../services/nats.service';
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
data: {
|
||||
userId: string;
|
||||
licenseId: string;
|
||||
email: string;
|
||||
};
|
||||
interface ClientMeta {
|
||||
userId: string;
|
||||
licenseId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
@WebSocketGateway({
|
||||
namespace: '/ws',
|
||||
cors: { origin: '*' },
|
||||
})
|
||||
@WebSocketGateway({ path: '/api/ws' })
|
||||
export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
private readonly logger = new Logger(NatsBridgeGateway.name);
|
||||
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
// Client metadata and listener tracking (native WS has no .data or .join())
|
||||
private clientMeta = new Map<WebSocket, ClientMeta>();
|
||||
private licenseClients = new Map<string, Set<WebSocket>>();
|
||||
private clientListeners = new Map<WebSocket, (event: string, data: unknown) => void>();
|
||||
|
||||
constructor(
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
@@ -37,70 +40,101 @@ export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconne
|
||||
private natsService: NatsService,
|
||||
) {}
|
||||
|
||||
async handleConnection(client: AuthenticatedSocket) {
|
||||
async handleConnection(client: WebSocket, request: IncomingMessage) {
|
||||
try {
|
||||
const token = client.handshake.query.token as string;
|
||||
// Parse token from query string
|
||||
const url = new URL(request.url || '/', `http://${request.headers.host}`);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
client.emit('error', { message: 'Authentication required' });
|
||||
client.disconnect();
|
||||
client.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
|
||||
client.close(4001, 'Authentication required');
|
||||
return;
|
||||
}
|
||||
|
||||
const secret = this.configService.get<string>('jwt.secret');
|
||||
const payload = this.jwtService.verify(token, { secret });
|
||||
|
||||
client.data = {
|
||||
const meta: ClientMeta = {
|
||||
userId: payload.sub,
|
||||
licenseId: payload.license_id,
|
||||
email: payload.email,
|
||||
};
|
||||
this.clientMeta.set(client, meta);
|
||||
|
||||
// Track client by license for broadcasting
|
||||
if (payload.license_id) {
|
||||
await client.join(`license:${payload.license_id}`);
|
||||
}
|
||||
if (!this.licenseClients.has(payload.license_id)) {
|
||||
this.licenseClients.set(payload.license_id, new Set());
|
||||
}
|
||||
this.licenseClients.get(payload.license_id)!.add(client);
|
||||
|
||||
if (payload.license_id) {
|
||||
// Subscribe to NATS events for this license
|
||||
const listener = (event: string, data: unknown) => {
|
||||
client.emit('event', {
|
||||
type: 'event',
|
||||
license_id: payload.license_id,
|
||||
event,
|
||||
data,
|
||||
});
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify({
|
||||
type: 'event',
|
||||
license_id: payload.license_id,
|
||||
event,
|
||||
data,
|
||||
}));
|
||||
}
|
||||
};
|
||||
this.natsBridge.addListener(payload.license_id, listener);
|
||||
(client as Socket & { _natsListener?: typeof listener })._natsListener = listener;
|
||||
this.clientListeners.set(client, listener);
|
||||
}
|
||||
|
||||
client.emit('connected', { type: 'connected', license_id: payload.license_id });
|
||||
client.send(JSON.stringify({ type: 'connected', license_id: payload.license_id }));
|
||||
this.logger.log(`Client connected: ${payload.email} (license: ${payload.license_id})`);
|
||||
} catch {
|
||||
client.emit('error', { message: 'Invalid token' });
|
||||
client.disconnect();
|
||||
client.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
|
||||
client.close(4002, 'Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect(client: AuthenticatedSocket) {
|
||||
if (client.data?.licenseId) {
|
||||
const listener = (client as Socket & { _natsListener?: (event: string, data: unknown) => void })._natsListener;
|
||||
handleDisconnect(client: WebSocket) {
|
||||
const meta = this.clientMeta.get(client);
|
||||
if (meta?.licenseId) {
|
||||
// Remove NATS listener
|
||||
const listener = this.clientListeners.get(client);
|
||||
if (listener) {
|
||||
this.natsBridge.removeListener(client.data.licenseId, listener);
|
||||
this.natsBridge.removeListener(meta.licenseId, listener);
|
||||
this.clientListeners.delete(client);
|
||||
}
|
||||
// Remove from license client set
|
||||
this.licenseClients.get(meta.licenseId)?.delete(client);
|
||||
if (this.licenseClients.get(meta.licenseId)?.size === 0) {
|
||||
this.licenseClients.delete(meta.licenseId);
|
||||
}
|
||||
}
|
||||
this.clientMeta.delete(client);
|
||||
}
|
||||
|
||||
@SubscribeMessage('console_input')
|
||||
async handleConsoleInput(client: AuthenticatedSocket, data: { command: string }) {
|
||||
if (!client.data?.licenseId) return;
|
||||
await this.natsService.sendServerCommand(client.data.licenseId, 'command', { command: data.command });
|
||||
async handleConsoleInput(
|
||||
@ConnectedSocket() client: WebSocket,
|
||||
@MessageBody() data: { command: string },
|
||||
) {
|
||||
const meta = this.clientMeta.get(client);
|
||||
if (!meta?.licenseId) return;
|
||||
await this.natsService.sendServerCommand(meta.licenseId, 'command', { command: data.command });
|
||||
}
|
||||
|
||||
sendToLicense(licenseId: string, event: string, data: unknown): void {
|
||||
this.server.to(`license:${licenseId}`).emit(event, {
|
||||
const clients = this.licenseClients.get(licenseId);
|
||||
if (!clients) return;
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: 'event',
|
||||
license_id: licenseId,
|
||||
event,
|
||||
data,
|
||||
});
|
||||
|
||||
for (const client of clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { WsAdapter } from '@nestjs/platform-ws';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||
@@ -8,6 +9,9 @@ import { TransformInterceptor } from './common/interceptors/transform.intercepto
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Use native WebSocket adapter (not socket.io)
|
||||
app.useWebSocketAdapter(new WsAdapter(app));
|
||||
|
||||
// Global prefix — all routes under /api
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
|
||||
@@ -57,13 +57,16 @@ export class AdminService {
|
||||
const [licenses, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data: licenses,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
total_pages: Math.ceil(total / limit),
|
||||
},
|
||||
data: licenses.map(l => ({
|
||||
id: l.id,
|
||||
license_key: l.license_key,
|
||||
owner_email: l.owner?.email ?? '',
|
||||
server_name: l.server_name,
|
||||
status: l.status,
|
||||
created_at: l.created_at,
|
||||
expires_at: l.expires_at,
|
||||
})),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,8 +95,11 @@ export class AdminService {
|
||||
await this.userRepo.save(user);
|
||||
}
|
||||
|
||||
// Create license
|
||||
const licenseKey = crypto.randomBytes(32).toString('hex');
|
||||
// Create license (branded CORR-XXXX-XXXX-XXXX format)
|
||||
const part1 = crypto.randomBytes(2).toString('hex').toUpperCase();
|
||||
const part2 = crypto.randomBytes(2).toString('hex').toUpperCase();
|
||||
const part3 = crypto.randomBytes(2).toString('hex').toUpperCase();
|
||||
const licenseKey = `CORR-${part1}-${part2}-${part3}`;
|
||||
const license = this.licenseRepo.create({
|
||||
license_key: licenseKey,
|
||||
owner_user_id: user.id,
|
||||
|
||||
@@ -4,9 +4,10 @@ import { AlertsController } from './alerts.controller';
|
||||
import { AlertsService } from './alerts.service';
|
||||
import { AlertConfig } from '../../entities/alert-config.entity';
|
||||
import { AlertHistory } from '../../entities/alert-history.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory])],
|
||||
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory, ServerStats])],
|
||||
controllers: [AlertsController],
|
||||
providers: [AlertsService],
|
||||
exports: [AlertsService],
|
||||
|
||||
@@ -1,26 +1,204 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AlertConfig } from '../../entities/alert-config.entity';
|
||||
import { AlertHistory } from '../../entities/alert-history.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
import { UpdateAlertConfigDto } from './dto/update-alert-config.dto';
|
||||
|
||||
/** Track the last time an alert of a given type fired per license, for cooldown enforcement. */
|
||||
const ALERT_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes between identical alerts
|
||||
|
||||
@Injectable()
|
||||
export class AlertsService {
|
||||
export class AlertsService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(AlertsService.name);
|
||||
private evaluatorInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Map of `${licenseId}:${alertType}` → last triggered timestamp */
|
||||
private readonly cooldowns = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AlertConfig)
|
||||
private readonly alertConfigRepo: Repository<AlertConfig>,
|
||||
@InjectRepository(AlertHistory)
|
||||
private readonly alertHistoryRepo: Repository<AlertHistory>,
|
||||
@InjectRepository(ServerStats)
|
||||
private readonly serverStatsRepo: Repository<ServerStats>,
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
onModuleInit() {
|
||||
// Poll every 90 seconds.
|
||||
this.evaluatorInterval = setInterval(() => {
|
||||
this.evaluateAllAlerts().catch(err =>
|
||||
this.logger.error('Alert evaluator error', err),
|
||||
);
|
||||
}, 90_000);
|
||||
|
||||
this.logger.log('Alert evaluator started (90s polling interval)');
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.evaluatorInterval) {
|
||||
clearInterval(this.evaluatorInterval);
|
||||
this.evaluatorInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alert evaluation engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async evaluateAllAlerts(): Promise<void> {
|
||||
// Load all alert configs in one query.
|
||||
const configs = await this.alertConfigRepo.find();
|
||||
|
||||
if (configs.length === 0) return;
|
||||
|
||||
for (const config of configs) {
|
||||
try {
|
||||
await this.evaluateForLicense(config);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Alert evaluation failed for license ${config.license_id}`,
|
||||
(err as Error).stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async evaluateForLicense(config: AlertConfig): Promise<void> {
|
||||
// Pull the most recent server_stats record for this license.
|
||||
const stats = await this.serverStatsRepo.findOne({
|
||||
where: { license_id: config.license_id },
|
||||
order: { recorded_at: 'DESC' },
|
||||
});
|
||||
|
||||
if (!stats) return; // No data yet — can't evaluate.
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// --- FPS degradation alert ---
|
||||
if (config.fps_degradation_enabled && stats.fps > 0) {
|
||||
if (stats.fps < config.fps_threshold) {
|
||||
await this.maybeFireAlert(
|
||||
config,
|
||||
'fps_degradation',
|
||||
'warning',
|
||||
'FPS Degradation Detected',
|
||||
`Server FPS dropped to ${stats.fps.toFixed(1)}, below threshold of ${config.fps_threshold}`,
|
||||
{
|
||||
current_fps: stats.fps,
|
||||
threshold: config.fps_threshold,
|
||||
player_count: stats.player_count,
|
||||
recorded_at: stats.recorded_at,
|
||||
},
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Population drop alert ---
|
||||
// We need two data points to detect a *drop*, so we compare current vs
|
||||
// the max_players recorded 30 minutes ago (nearest sample).
|
||||
if (config.population_drop_enabled && stats.max_players > 0) {
|
||||
const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000);
|
||||
const previousStats = await this.serverStatsRepo.findOne({
|
||||
where: { license_id: config.license_id },
|
||||
order: { recorded_at: 'DESC' },
|
||||
});
|
||||
|
||||
// Use a second query to get a historical data point
|
||||
const historicalStats = await this.serverStatsRepo
|
||||
.createQueryBuilder('ss')
|
||||
.where('ss.license_id = :licenseId', { licenseId: config.license_id })
|
||||
.andWhere('ss.recorded_at <= :cutoff', { cutoff: thirtyMinAgo })
|
||||
.orderBy('ss.recorded_at', 'DESC')
|
||||
.limit(1)
|
||||
.getOne();
|
||||
|
||||
if (historicalStats && historicalStats.player_count > 0) {
|
||||
const dropPercent =
|
||||
((historicalStats.player_count - stats.player_count) /
|
||||
historicalStats.player_count) *
|
||||
100;
|
||||
|
||||
if (dropPercent >= config.population_drop_threshold_percent) {
|
||||
await this.maybeFireAlert(
|
||||
config,
|
||||
'population_drop',
|
||||
'info',
|
||||
'Population Drop Detected',
|
||||
`Player count dropped ${dropPercent.toFixed(0)}% (${historicalStats.player_count} → ${stats.player_count}) over the last 30 minutes`,
|
||||
{
|
||||
previous_count: historicalStats.player_count,
|
||||
current_count: stats.player_count,
|
||||
drop_percent: Math.round(dropPercent),
|
||||
threshold_percent: config.population_drop_threshold_percent,
|
||||
},
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire an alert if cooldown has expired. */
|
||||
private async maybeFireAlert(
|
||||
config: AlertConfig,
|
||||
alertType: string,
|
||||
severity: string,
|
||||
title: string,
|
||||
message: string,
|
||||
metadata: Record<string, any>,
|
||||
now: number,
|
||||
): Promise<void> {
|
||||
const cooldownKey = `${config.license_id}:${alertType}`;
|
||||
const lastFired = this.cooldowns.get(cooldownKey) ?? 0;
|
||||
|
||||
if (now - lastFired < ALERT_COOLDOWN_MS) {
|
||||
return; // Still in cooldown — skip.
|
||||
}
|
||||
|
||||
this.cooldowns.set(cooldownKey, now);
|
||||
|
||||
const history = this.alertHistoryRepo.create({
|
||||
license_id: config.license_id,
|
||||
alert_type: alertType,
|
||||
severity,
|
||||
title,
|
||||
message,
|
||||
metadata,
|
||||
notified_discord: config.notify_discord,
|
||||
notified_pushbullet: config.notify_pushbullet,
|
||||
notified_email: config.notify_email,
|
||||
});
|
||||
|
||||
await this.alertHistoryRepo.save(history);
|
||||
|
||||
this.logger.log(
|
||||
`Alert fired: [${alertType}] "${title}" for license ${config.license_id}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getConfig(licenseId: string): Promise<AlertConfig> {
|
||||
let config = await this.alertConfigRepo.findOne({
|
||||
where: { license_id: licenseId },
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
// Create default config if not exists
|
||||
config = this.alertConfigRepo.create({
|
||||
license_id: licenseId,
|
||||
population_drop_enabled: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, MoreThan } from 'typeorm';
|
||||
import { Repository, MoreThan, Between } from 'typeorm';
|
||||
import { ServerStatsHourly } from '../../entities/server-stats-hourly.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
import { WipeHistory } from '../../entities/wipe-history.entity';
|
||||
@@ -169,13 +169,18 @@ export class AnalyticsService {
|
||||
const retentionData = await Promise.all(
|
||||
recentWipes.map(async (wipe) => {
|
||||
const wipeDate = wipe.started_at;
|
||||
const nextWipe = recentWipes.find(w => w.started_at && wipeDate && w.started_at > wipeDate);
|
||||
// Find the next wipe chronologically after this one (wipes are DESC ordered)
|
||||
const nextWipe = recentWipes
|
||||
.slice()
|
||||
.reverse()
|
||||
.find(w => w.started_at && wipeDate && w.started_at > wipeDate);
|
||||
const endDate = nextWipe?.started_at || new Date();
|
||||
|
||||
// Query sessions strictly within this wipe cycle: [wipeDate, endDate)
|
||||
const sessionsInPeriod = await this.playerSessionRepo.find({
|
||||
where: {
|
||||
license_id: licenseId,
|
||||
session_start: MoreThan(wipeDate!),
|
||||
session_start: Between(wipeDate!, endDate),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -183,6 +188,7 @@ export class AnalyticsService {
|
||||
|
||||
return {
|
||||
wipe_date: wipeDate,
|
||||
end_date: endDate,
|
||||
unique_players: uniquePlayers,
|
||||
total_sessions: sessionsInPeriod.length,
|
||||
};
|
||||
|
||||
@@ -35,13 +35,20 @@ export class AuthService {
|
||||
) {}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
// Normalize email to lowercase to prevent case-sensitive duplicates
|
||||
const normalizedEmail = dto.email.toLowerCase();
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: [{ email: dto.email }, { username: dto.username }],
|
||||
});
|
||||
const existingUser = await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.where('LOWER(user.email) = :email OR user.username = :username', {
|
||||
email: normalizedEmail,
|
||||
username: dto.username,
|
||||
})
|
||||
.getOne();
|
||||
|
||||
if (existingUser) {
|
||||
if (existingUser.email === dto.email) {
|
||||
if (existingUser.email.toLowerCase() === normalizedEmail) {
|
||||
throw new ConflictException('Email already registered');
|
||||
}
|
||||
throw new ConflictException('Username already taken');
|
||||
@@ -50,9 +57,9 @@ export class AuthService {
|
||||
// Hash password
|
||||
const password_hash = await argon2.hash(dto.password);
|
||||
|
||||
// Create user
|
||||
// Create user (email stored lowercase)
|
||||
const user = this.userRepository.create({
|
||||
email: dto.email,
|
||||
email: normalizedEmail,
|
||||
username: dto.username,
|
||||
password_hash,
|
||||
email_verified: false,
|
||||
@@ -73,8 +80,8 @@ export class AuthService {
|
||||
|
||||
await this.licenseRepository.save(license);
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(user);
|
||||
// Generate tokens (include license_id for tenant-scoped operations)
|
||||
const tokens = await this.generateTokens(user, license.id);
|
||||
|
||||
return {
|
||||
...tokens,
|
||||
@@ -85,16 +92,28 @@ export class AuthService {
|
||||
username: user.username,
|
||||
is_super_admin: user.is_super_admin,
|
||||
totp_enabled: user.totp_enabled,
|
||||
license_key: licenseKey,
|
||||
},
|
||||
license: {
|
||||
id: license.id,
|
||||
license_key: license.license_key,
|
||||
status: license.status,
|
||||
server_name: license.server_name ?? null,
|
||||
subdomain: license.subdomain ?? null,
|
||||
custom_domain: license.custom_domain ?? null,
|
||||
modules_enabled: license.modules_enabled,
|
||||
webstore_active: license.webstore_active,
|
||||
created_at: license.created_at,
|
||||
expires_at: license.expires_at ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
// Find user by email
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
// Find user by email (case-insensitive)
|
||||
const user = await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.where('LOWER(user.email) = :email', { email: dto.email.toLowerCase() })
|
||||
.getOne();
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
@@ -125,14 +144,14 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(user);
|
||||
|
||||
// Get user's license
|
||||
// Get user's license (needed for JWT license_id claim)
|
||||
const license = await this.licenseRepository.findOne({
|
||||
where: { owner_user_id: user.id },
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(user, license?.id);
|
||||
|
||||
return {
|
||||
...tokens,
|
||||
requires_totp: false,
|
||||
@@ -142,8 +161,19 @@ export class AuthService {
|
||||
username: user.username,
|
||||
is_super_admin: user.is_super_admin,
|
||||
totp_enabled: user.totp_enabled,
|
||||
license_key: license?.license_key,
|
||||
},
|
||||
license: license ? {
|
||||
id: license.id,
|
||||
license_key: license.license_key,
|
||||
status: license.status,
|
||||
server_name: license.server_name,
|
||||
subdomain: license.subdomain,
|
||||
custom_domain: license.custom_domain,
|
||||
modules_enabled: license.modules_enabled,
|
||||
webstore_active: license.webstore_active,
|
||||
created_at: license.created_at,
|
||||
expires_at: license.expires_at,
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,22 +191,17 @@ export class AuthService {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
is_super_admin: user.is_super_admin,
|
||||
},
|
||||
{
|
||||
secret: this.configService.get<string>('jwt.secret'),
|
||||
expiresIn: this.configService.get<number>('jwt.accessExpirySeconds') || 900,
|
||||
},
|
||||
);
|
||||
// Look up license for JWT claim
|
||||
const license = await this.licenseRepository.findOne({
|
||||
where: { owner_user_id: user.id },
|
||||
});
|
||||
|
||||
// Generate new token pair (rotating refresh tokens)
|
||||
const tokens = await this.generateTokens(user, license?.id);
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
@@ -312,14 +337,18 @@ export class AuthService {
|
||||
|
||||
// Helper methods
|
||||
|
||||
private async generateTokens(user: User) {
|
||||
const payload = {
|
||||
private async generateTokens(user: User, licenseId?: string) {
|
||||
const payload: Record<string, unknown> = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
is_super_admin: user.is_super_admin,
|
||||
};
|
||||
|
||||
if (licenseId) {
|
||||
payload.license_id = licenseId;
|
||||
}
|
||||
|
||||
const accessToken = await this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get<string>('jwt.secret'),
|
||||
expiresIn: this.configService.get<number>('jwt.accessExpirySeconds') || 900,
|
||||
|
||||
@@ -7,43 +7,47 @@ import {
|
||||
MessageBody,
|
||||
ConnectedSocket,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import WebSocket, { Server } from 'ws';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
interface ClientMeta {
|
||||
licenseId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Console Gateway
|
||||
*
|
||||
* Provides real-time WebSocket connectivity for server console I/O.
|
||||
* Clients connect with JWT token in query params, join a room by license_id,
|
||||
* and can send/receive console commands and output.
|
||||
* NOTE: This gateway is NOT currently loaded (ConsoleModule not imported in AppModule).
|
||||
* Console I/O is handled by NatsBridgeGateway instead.
|
||||
* Kept for potential future use as a dedicated console-only WebSocket endpoint.
|
||||
*/
|
||||
@WebSocketGateway({ namespace: '/ws', cors: true })
|
||||
@WebSocketGateway({ path: '/api/console-ws' })
|
||||
export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
private readonly logger = new Logger(ConsoleGateway.name);
|
||||
private clientMeta = new Map<WebSocket, ClientMeta>();
|
||||
private licenseClients = new Map<string, Set<WebSocket>>();
|
||||
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle client connection
|
||||
* Extract JWT from query param, validate, and join room by license_id
|
||||
*/
|
||||
async handleConnection(client: Socket) {
|
||||
async handleConnection(client: WebSocket, request: IncomingMessage) {
|
||||
try {
|
||||
const token = client.handshake.query.token as string;
|
||||
const url = new URL(request.url || '/', `http://${request.headers.host}`);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
// Verify JWT
|
||||
const payload = this.jwtService.verify(token);
|
||||
const licenseId = payload.license_id;
|
||||
|
||||
@@ -51,65 +55,65 @@ export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect
|
||||
throw new UnauthorizedException('Invalid token: no license_id');
|
||||
}
|
||||
|
||||
// Store license_id on socket for later use
|
||||
client.data.licenseId = licenseId;
|
||||
client.data.userId = payload.sub;
|
||||
this.clientMeta.set(client, { licenseId, userId: payload.sub });
|
||||
|
||||
// Join room specific to this license
|
||||
await client.join(licenseId);
|
||||
if (!this.licenseClients.has(licenseId)) {
|
||||
this.licenseClients.set(licenseId, new Set());
|
||||
}
|
||||
this.licenseClients.get(licenseId)!.add(client);
|
||||
|
||||
this.logger.log(`Client ${client.id} connected to license ${licenseId}`);
|
||||
this.logger.log(`Client connected to license ${licenseId}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error(`Connection failed: ${message}`);
|
||||
client.disconnect();
|
||||
client.close(4001, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle client disconnection
|
||||
*/
|
||||
handleDisconnect(client: Socket) {
|
||||
const licenseId = client.data.licenseId;
|
||||
this.logger.log(`Client ${client.id} disconnected from license ${licenseId}`);
|
||||
handleDisconnect(client: WebSocket) {
|
||||
const meta = this.clientMeta.get(client);
|
||||
if (meta?.licenseId) {
|
||||
this.licenseClients.get(meta.licenseId)?.delete(client);
|
||||
if (this.licenseClients.get(meta.licenseId)?.size === 0) {
|
||||
this.licenseClients.delete(meta.licenseId);
|
||||
}
|
||||
}
|
||||
this.clientMeta.delete(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle console input from client
|
||||
* Forward the command to NATS for execution on the game server
|
||||
*/
|
||||
@SubscribeMessage('console_input')
|
||||
async handleConsoleInput(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@ConnectedSocket() client: WebSocket,
|
||||
@MessageBody() data: { command: string },
|
||||
) {
|
||||
const licenseId = client.data.licenseId;
|
||||
const meta = this.clientMeta.get(client);
|
||||
if (!meta?.licenseId) return;
|
||||
|
||||
if (!data.command) {
|
||||
return { error: 'Command is required' };
|
||||
}
|
||||
|
||||
this.logger.debug(`Console input from ${licenseId}: ${data.command}`);
|
||||
this.logger.debug(`Console input from ${meta.licenseId}: ${data.command}`);
|
||||
|
||||
// Forward to NATS
|
||||
await this.natsService.sendServerCommand(licenseId, 'command', {
|
||||
await this.natsService.sendServerCommand(meta.licenseId, 'command', {
|
||||
command: data.command,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send console output or event to all clients in a license room
|
||||
*/
|
||||
sendToLicense(licenseId: string, event: string, data: any) {
|
||||
this.server.to(licenseId).emit(event, data);
|
||||
const clients = this.licenseClients.get(licenseId);
|
||||
if (!clients) return;
|
||||
|
||||
const message = JSON.stringify({ event, data });
|
||||
for (const client of clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast console output to a specific license
|
||||
* This method would be called by a NATS subscriber when output is received
|
||||
*/
|
||||
broadcastConsoleOutput(licenseId: string, output: string) {
|
||||
this.sendToLicense(licenseId, 'console_output', { output });
|
||||
}
|
||||
|
||||
121
backend-nest/src/modules/files/files.controller.ts
Normal file
121
backend-nest/src/modules/files/files.controller.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
Body,
|
||||
Res,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
@ApiTags('Files')
|
||||
@ApiBearerAuth()
|
||||
@Controller('files')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class FilesController {
|
||||
constructor(private readonly filesService: FilesService) {}
|
||||
|
||||
// VueFinder GET operations: ?q=index (list), ?q=search, ?q=preview, ?q=download
|
||||
@Get()
|
||||
@RequirePermission('files.view')
|
||||
async handleGet(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Query('q') operation: string,
|
||||
@Query('path') path: string,
|
||||
@Query('filter') filter: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
switch (operation) {
|
||||
case 'index':
|
||||
case undefined:
|
||||
case '':
|
||||
return this.filesService.list(licenseId, path || 'server://');
|
||||
case 'search':
|
||||
return this.filesService.search(licenseId, path || 'server://', filter);
|
||||
case 'preview':
|
||||
return this.filesService.preview(licenseId, path);
|
||||
case 'download': {
|
||||
const result = await this.filesService.download(licenseId, path);
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${result.filename}"`,
|
||||
);
|
||||
res.send(result.content);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
throw new HttpException(
|
||||
`Unknown operation: ${operation}`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// VueFinder POST operations: ?q=delete, rename, move, copy, new-folder, new-file, save, upload
|
||||
@Post()
|
||||
@RequirePermission('files.manage')
|
||||
@UseInterceptors(FileInterceptor('upload'))
|
||||
async handlePost(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Query('q') operation: string,
|
||||
@Body() body: Record<string, any>,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
) {
|
||||
switch (operation) {
|
||||
case 'delete':
|
||||
return this.filesService.delete(licenseId, body.path, body.items);
|
||||
case 'rename':
|
||||
return this.filesService.rename(
|
||||
licenseId,
|
||||
body.path,
|
||||
body.item,
|
||||
body.name,
|
||||
);
|
||||
case 'move':
|
||||
return this.filesService.move(
|
||||
licenseId,
|
||||
body.path,
|
||||
body.items,
|
||||
body.destination,
|
||||
);
|
||||
case 'copy':
|
||||
return this.filesService.copy(
|
||||
licenseId,
|
||||
body.path,
|
||||
body.items,
|
||||
body.destination,
|
||||
);
|
||||
case 'new-folder':
|
||||
case 'mkdir':
|
||||
return this.filesService.createFolder(licenseId, body.path, body.name);
|
||||
case 'new-file':
|
||||
case 'newfile':
|
||||
return this.filesService.createFile(licenseId, body.path, body.name);
|
||||
case 'save':
|
||||
return this.filesService.save(licenseId, body.path, body.content);
|
||||
case 'upload':
|
||||
if (!file) {
|
||||
throw new HttpException('No file provided', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return this.filesService.upload(licenseId, body.path, file);
|
||||
default:
|
||||
throw new HttpException(
|
||||
`Unknown operation: ${operation}`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
backend-nest/src/modules/files/files.module.ts
Normal file
10
backend-nest/src/modules/files/files.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FilesController } from './files.controller';
|
||||
import { FilesService } from './files.service';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FilesController],
|
||||
providers: [FilesService, NatsService],
|
||||
})
|
||||
export class FilesModule {}
|
||||
176
backend-nest/src/modules/files/files.service.ts
Normal file
176
backend-nest/src/modules/files/files.service.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
interface AgentFileResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface DownloadResult {
|
||||
filename: string;
|
||||
content: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
private readonly logger = new Logger(FilesService.name);
|
||||
|
||||
constructor(private readonly natsService: NatsService) {}
|
||||
|
||||
// Send a file manager command to the companion agent via NATS request-reply.
|
||||
// The agent returns { success: bool, error?: string, data?: VueFinderResponse }.
|
||||
private async sendFileCommand(
|
||||
licenseId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const subject = `corrosion.${licenseId}.files.cmd`;
|
||||
try {
|
||||
const response = (await this.natsService.request(
|
||||
subject,
|
||||
payload,
|
||||
30000,
|
||||
)) as AgentFileResponse | null;
|
||||
|
||||
// Offline mode — agent unreachable, return null rather than crashing
|
||||
if (response === null) {
|
||||
throw new HttpException(
|
||||
'Agent not reachable (offline mode)',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
throw new HttpException(
|
||||
response.error || 'File operation failed',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(
|
||||
`File command failed on subject ${subject}: ${(error as Error).message}`,
|
||||
);
|
||||
throw new HttpException(
|
||||
'Agent not reachable or timed out',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async list(licenseId: string, path: string): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_list', path });
|
||||
}
|
||||
|
||||
async search(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
filter: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_search', path, filter });
|
||||
}
|
||||
|
||||
async preview(licenseId: string, path: string): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_preview', path });
|
||||
}
|
||||
|
||||
async download(licenseId: string, path: string): Promise<DownloadResult> {
|
||||
const result = await this.sendFileCommand(licenseId, {
|
||||
func: 'fm_preview',
|
||||
path,
|
||||
});
|
||||
const basename = path.split('/').pop() || 'download';
|
||||
return { filename: basename, content: result };
|
||||
}
|
||||
|
||||
async delete(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
items: string[],
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_delete', path, items });
|
||||
}
|
||||
|
||||
async rename(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
item: string,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, {
|
||||
func: 'fm_rename',
|
||||
path,
|
||||
items: [item],
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
async move(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
items: string[],
|
||||
destination: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, {
|
||||
func: 'fm_move',
|
||||
path,
|
||||
items,
|
||||
destination,
|
||||
});
|
||||
}
|
||||
|
||||
async copy(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
items: string[],
|
||||
destination: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, {
|
||||
func: 'fm_copy',
|
||||
path,
|
||||
items,
|
||||
destination,
|
||||
});
|
||||
}
|
||||
|
||||
async createFolder(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_mkdir', path, name });
|
||||
}
|
||||
|
||||
async createFile(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_mkfile', path, name });
|
||||
}
|
||||
|
||||
async save(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
content: string,
|
||||
): Promise<unknown> {
|
||||
return this.sendFileCommand(licenseId, { func: 'fm_save', path, content });
|
||||
}
|
||||
|
||||
async upload(
|
||||
licenseId: string,
|
||||
path: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<unknown> {
|
||||
// Encode binary content as base64 so it survives JSON serialization over NATS
|
||||
const content = file.buffer.toString('base64');
|
||||
return this.sendFileCommand(licenseId, {
|
||||
func: 'fm_upload',
|
||||
path,
|
||||
filename: file.originalname,
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { createHash } from 'crypto';
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { MapLibrary } from '../../entities/map-library.entity';
|
||||
import { MapRotation } from '../../entities/map-rotation.entity';
|
||||
import { UpdateRotationDto } from './dto/update-rotation.dto';
|
||||
import { UploadMapDto } from './dto/upload-map.dto';
|
||||
|
||||
// Docker volume mount point for map storage. Tenant-scoped subdirectory enforces isolation.
|
||||
const MAP_DATA_ROOT = process.env.MAP_DATA_PATH || '/app/map_data';
|
||||
|
||||
@Injectable()
|
||||
export class MapsService {
|
||||
private readonly logger = new Logger(MapsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(MapLibrary)
|
||||
private readonly mapLibraryRepo: Repository<MapLibrary>,
|
||||
@@ -22,7 +29,23 @@ export class MapsService {
|
||||
file: Express.Multer.File,
|
||||
): Promise<MapLibrary> {
|
||||
const checksum = createHash('sha256').update(file.buffer).digest('hex');
|
||||
const storagePath = `/maps/${licenseId}/${Date.now()}_${file.originalname}`;
|
||||
|
||||
// Build tenant-scoped storage path: /app/map_data/{licenseId}/{timestamp}_{filename}
|
||||
const filename = `${Date.now()}_${file.originalname}`;
|
||||
const tenantDir = join(MAP_DATA_ROOT, licenseId);
|
||||
const absolutePath = join(tenantDir, filename);
|
||||
|
||||
// Relative storage path stored in DB — avoids coupling to the absolute mount point
|
||||
const storagePath = `/map_data/${licenseId}/${filename}`;
|
||||
|
||||
try {
|
||||
mkdirSync(tenantDir, { recursive: true });
|
||||
writeFileSync(absolutePath, file.buffer);
|
||||
this.logger.log(`Map uploaded: ${absolutePath} (${file.size} bytes)`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to write map file to disk: ${absolutePath}`, err);
|
||||
throw new InternalServerErrorException('Failed to save map file to storage');
|
||||
}
|
||||
|
||||
const map = this.mapLibraryRepo.create({
|
||||
license_id: licenseId,
|
||||
|
||||
@@ -3,10 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PlayersController } from './players.controller';
|
||||
import { PlayersService } from './players.service';
|
||||
import { PlayerAction } from '../../entities/player-action.entity';
|
||||
import { PlayerSession } from '../../entities/player-session.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([PlayerAction])],
|
||||
imports: [TypeOrmModule.forFeature([PlayerAction, PlayerSession])],
|
||||
controllers: [PlayersController],
|
||||
providers: [PlayersService, NatsService],
|
||||
exports: [PlayersService],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PlayerAction } from '../../entities/player-action.entity';
|
||||
import { PlayerSession } from '../../entities/player-session.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { PlayerActionDto } from './dto/player-action.dto';
|
||||
|
||||
@@ -11,6 +12,8 @@ export interface Player {
|
||||
status: 'online' | 'offline' | 'banned';
|
||||
last_seen?: Date;
|
||||
ban_expires?: Date | null;
|
||||
total_sessions?: number;
|
||||
total_playtime_seconds?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -18,43 +21,86 @@ export class PlayersService {
|
||||
constructor(
|
||||
@InjectRepository(PlayerAction)
|
||||
private readonly actionRepo: Repository<PlayerAction>,
|
||||
@InjectRepository(PlayerSession)
|
||||
private readonly sessionRepo: Repository<PlayerSession>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get recent players for a license
|
||||
* Get players for a license.
|
||||
*
|
||||
* TODO: This needs a player_sessions table to track online/offline status.
|
||||
* For now, we query player_actions to get a list of players who have had actions.
|
||||
* Primary source: player_sessions table (tracks session lifecycles).
|
||||
* Secondary source: player_actions table (determines ban status).
|
||||
* A player whose most recent action is a 'ban' (not subsequently 'unban') is shown as banned.
|
||||
* A player with an open session (session_end IS NULL) is shown as online.
|
||||
*/
|
||||
async getPlayers(licenseId: string): Promise<{ players: Player[] }> {
|
||||
const actions = await this.actionRepo
|
||||
// Get distinct players from session history
|
||||
const sessions = await this.sessionRepo
|
||||
.createQueryBuilder('session')
|
||||
.where('session.license_id = :licenseId', { licenseId })
|
||||
.orderBy('session.session_start', 'DESC')
|
||||
.getMany();
|
||||
|
||||
// Build per-player session aggregates
|
||||
const playerMap = new Map<string, Player>();
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!playerMap.has(session.steam_id)) {
|
||||
const isOnline = session.session_end === null;
|
||||
playerMap.set(session.steam_id, {
|
||||
steam_id: session.steam_id,
|
||||
player_name: session.player_name,
|
||||
status: isOnline ? 'online' : 'offline',
|
||||
last_seen: session.session_start,
|
||||
ban_expires: null,
|
||||
total_sessions: 0,
|
||||
total_playtime_seconds: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const entry = playerMap.get(session.steam_id)!;
|
||||
entry.total_sessions = (entry.total_sessions || 0) + 1;
|
||||
entry.total_playtime_seconds = (entry.total_playtime_seconds || 0) + (session.duration_seconds || 0);
|
||||
}
|
||||
|
||||
// Overlay ban status from most recent action per player
|
||||
const recentActions = await this.actionRepo
|
||||
.createQueryBuilder('action')
|
||||
.where('action.license_id = :licenseId', { licenseId })
|
||||
.orderBy('action.created_at', 'DESC')
|
||||
.take(100)
|
||||
.getMany();
|
||||
|
||||
// Group by steam_id to get unique players
|
||||
const playerMap = new Map<string, Player>();
|
||||
// Track the most recent action per steam_id to determine ban state
|
||||
const latestActionBySteamId = new Map<string, PlayerAction>();
|
||||
for (const action of recentActions) {
|
||||
if (!latestActionBySteamId.has(action.steam_id)) {
|
||||
latestActionBySteamId.set(action.steam_id, action);
|
||||
}
|
||||
}
|
||||
|
||||
for (const action of actions) {
|
||||
if (!playerMap.has(action.steam_id)) {
|
||||
// Determine status based on latest action
|
||||
let status: 'online' | 'offline' | 'banned' = 'offline';
|
||||
if (action.action_type === 'ban') {
|
||||
status = 'banned';
|
||||
}
|
||||
|
||||
playerMap.set(action.steam_id, {
|
||||
steam_id: action.steam_id,
|
||||
player_name: action.player_name,
|
||||
status,
|
||||
last_seen: action.created_at,
|
||||
ban_expires: action.duration_minutes
|
||||
for (const [steamId, action] of latestActionBySteamId) {
|
||||
if (action.action_type === 'ban') {
|
||||
// Add player from actions even if they have no sessions
|
||||
if (!playerMap.has(steamId)) {
|
||||
playerMap.set(steamId, {
|
||||
steam_id: action.steam_id,
|
||||
player_name: action.player_name,
|
||||
status: 'banned',
|
||||
last_seen: action.created_at,
|
||||
ban_expires: action.duration_minutes
|
||||
? new Date(action.created_at.getTime() + action.duration_minutes * 60000)
|
||||
: null,
|
||||
total_sessions: 0,
|
||||
total_playtime_seconds: 0,
|
||||
});
|
||||
} else {
|
||||
const entry = playerMap.get(steamId)!;
|
||||
entry.status = 'banned';
|
||||
entry.ban_expires = action.duration_minutes
|
||||
? new Date(action.created_at.getTime() + action.duration_minutes * 60000)
|
||||
: null,
|
||||
});
|
||||
: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +110,9 @@ export class PlayersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a moderation action on a player
|
||||
* Perform a moderation action on a player.
|
||||
* Supported actions: kick, ban, unban, warn, note.
|
||||
* kick/ban/unban are forwarded to the game server via NATS.
|
||||
*/
|
||||
async performAction(
|
||||
licenseId: string,
|
||||
@@ -84,8 +132,8 @@ export class PlayersService {
|
||||
|
||||
await this.actionRepo.save(action);
|
||||
|
||||
// For kick/ban, send NATS command to the server
|
||||
if (dto.action_type === 'kick' || dto.action_type === 'ban') {
|
||||
// Forward kick, ban, and unban to the game server via NATS
|
||||
if (dto.action_type === 'kick' || dto.action_type === 'ban' || dto.action_type === 'unban') {
|
||||
await this.natsService.sendServerCommand(licenseId, dto.action_type, {
|
||||
steam_id: dto.steam_id,
|
||||
reason: dto.reason,
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery, ApiConsumes } from '@nestjs/swagger';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { InstallPluginDto } from './dto/install-plugin.dto';
|
||||
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
|
||||
@@ -57,9 +71,38 @@ export class PluginsController {
|
||||
|
||||
@Get('search')
|
||||
@RequirePermission('plugin.view')
|
||||
@ApiOperation({ summary: 'Search uMod plugin directory' })
|
||||
@ApiOperation({ summary: 'Search uMod plugin directory (legacy stub)' })
|
||||
@ApiQuery({ name: 'q', required: true, example: 'kits' })
|
||||
searchUmod(@Query('q') query: string) {
|
||||
return this.pluginsService.searchUmod(query);
|
||||
}
|
||||
|
||||
@Get('browse')
|
||||
@RequirePermission('plugin.view')
|
||||
@ApiOperation({ summary: 'Browse uMod plugin directory (proxied)' })
|
||||
@ApiQuery({ name: 'query', required: false, example: 'vanish' })
|
||||
@ApiQuery({ name: 'page', required: false, example: 1 })
|
||||
@ApiQuery({ name: 'sort', required: false, example: 'downloads' })
|
||||
browseUmod(
|
||||
@Query('query') query: string,
|
||||
@Query('page') page: string,
|
||||
@Query('sort') sort: string,
|
||||
) {
|
||||
return this.pluginsService.browseUmod(query, page ? parseInt(page, 10) : 1, sort);
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@RequirePermission('plugin.manage')
|
||||
@ApiOperation({ summary: 'Upload a custom .cs plugin file' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadPlugin(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
return this.pluginsService.uploadPlugin(licenseId, file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PluginRegistry } from '../../entities/plugin-registry.entity';
|
||||
@@ -6,9 +6,16 @@ import { InstallPluginDto } from './dto/install-plugin.dto';
|
||||
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
interface UmodCacheEntry {
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PluginsService {
|
||||
private readonly logger = new Logger(PluginsService.name);
|
||||
private readonly umodCache = new Map<string, UmodCacheEntry>();
|
||||
private readonly CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PluginRegistry)
|
||||
@@ -45,7 +52,21 @@ export class PluginsService {
|
||||
is_loaded: false,
|
||||
});
|
||||
|
||||
return this.pluginRegistryRepo.save(plugin);
|
||||
const saved = await this.pluginRegistryRepo.save(plugin);
|
||||
|
||||
try {
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, {
|
||||
action: 'plugin_install',
|
||||
plugin_name: dto.plugin_name,
|
||||
umod_slug: dto.umod_slug,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.logger.log(`Plugin install dispatched for ${dto.plugin_name} on license ${licenseId}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to dispatch plugin install for ${dto.plugin_name} on license ${licenseId}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
async uninstallPlugin(licenseId: string, pluginId: string): Promise<void> {
|
||||
@@ -108,10 +129,107 @@ export class PluginsService {
|
||||
}
|
||||
|
||||
async searchUmod(query: string): Promise<{ results: any[]; message: string }> {
|
||||
// uMod API integration pending
|
||||
// Legacy stub — use browseUmod via GET /plugins/browse for real results
|
||||
return {
|
||||
results: [],
|
||||
message: 'uMod search integration not yet configured',
|
||||
message: 'Use GET /plugins/browse for uMod search',
|
||||
};
|
||||
}
|
||||
|
||||
async browseUmod(query: string, page = 1, sort = 'downloads'): Promise<unknown> {
|
||||
const cacheKey = `${query ?? ''}:${page}:${sort}`;
|
||||
const cached = this.umodCache.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) {
|
||||
this.logger.debug(`uMod cache hit for key "${cacheKey}"`);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
sort,
|
||||
sortdir: 'desc',
|
||||
'categories[]': 'rust',
|
||||
});
|
||||
|
||||
if (query?.trim()) {
|
||||
params.set('query', query.trim());
|
||||
}
|
||||
|
||||
const url = `https://umod.org/plugins/search.json?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': 'CorrosionPanel/1.0' },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(`uMod API returned ${response.status} for query "${query}"`);
|
||||
return { current_page: 1, data: [], last_page: 1, per_page: 20, total: 0 };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.umodCache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
this.logger.log(`uMod browse: query="${query}" page=${page} sort=${sort} → ${(data as any)?.total ?? '?'} results`);
|
||||
return data;
|
||||
} catch (err) {
|
||||
this.logger.error(`uMod browse failed for query "${query}": ${(err as Error).message}`);
|
||||
return { current_page: 1, data: [], last_page: 1, per_page: 20, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async uploadPlugin(licenseId: string, file: Express.Multer.File): Promise<PluginRegistry> {
|
||||
// Validate extension
|
||||
const originalName = file.originalname ?? '';
|
||||
if (!originalName.toLowerCase().endsWith('.cs')) {
|
||||
throw new BadRequestException('Only .cs plugin files are accepted');
|
||||
}
|
||||
|
||||
// Validate size (5 MB)
|
||||
const MAX_BYTES = 5 * 1024 * 1024;
|
||||
if (file.size > MAX_BYTES) {
|
||||
throw new BadRequestException('Plugin file exceeds the 5 MB limit');
|
||||
}
|
||||
|
||||
// Derive plugin name from filename (strip .cs)
|
||||
const pluginName = originalName.replace(/\.cs$/i, '');
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await this.pluginRegistryRepo.findOne({
|
||||
where: { license_id: licenseId, plugin_name: pluginName },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(`Plugin "${pluginName}" is already installed`);
|
||||
}
|
||||
|
||||
// Persist record
|
||||
const plugin = this.pluginRegistryRepo.create({
|
||||
license_id: licenseId,
|
||||
plugin_name: pluginName,
|
||||
source: 'manual',
|
||||
is_installed: true,
|
||||
is_loaded: false,
|
||||
});
|
||||
|
||||
const saved = await this.pluginRegistryRepo.save(plugin);
|
||||
|
||||
// Dispatch to companion agent via NATS
|
||||
try {
|
||||
const content = file.buffer.toString('base64');
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, {
|
||||
action: 'plugin_upload',
|
||||
filename: originalName,
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.logger.log(`Plugin upload dispatched: "${originalName}" (${file.size} bytes) for license ${licenseId}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`NATS publish failed for plugin upload "${originalName}" on license ${licenseId}: ${(err as Error).message}`);
|
||||
// Don't fail the request — plugin record is saved, NATS delivery is best-effort
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { SchedulesController } from './schedules.controller';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ScheduledTask])],
|
||||
controllers: [SchedulesController],
|
||||
providers: [SchedulesService],
|
||||
providers: [SchedulesService, NatsService],
|
||||
exports: [SchedulesService],
|
||||
})
|
||||
export class SchedulesModule {}
|
||||
|
||||
@@ -1,21 +1,220 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { LessThanOrEqual, Repository } from 'typeorm';
|
||||
import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
/** Parse a 5-field cron expression and return the next Date after `after`. */
|
||||
function nextCronDate(expr: string, after: Date): Date | null {
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
|
||||
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
|
||||
|
||||
function matches(expr: string, value: number): boolean {
|
||||
if (expr === '*') return true;
|
||||
return parseInt(expr, 10) === value;
|
||||
}
|
||||
|
||||
// Walk minute-by-minute up to 366 days forward to find next match.
|
||||
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
|
||||
candidate.setSeconds(0, 0);
|
||||
|
||||
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
|
||||
|
||||
while (candidate < limit) {
|
||||
const min = candidate.getUTCMinutes();
|
||||
const hour = candidate.getUTCHours();
|
||||
const dom = candidate.getUTCDate();
|
||||
const month = candidate.getUTCMonth() + 1; // 1-12
|
||||
const dow = candidate.getUTCDay(); // 0=Sun
|
||||
|
||||
if (
|
||||
matches(minuteExpr, min) &&
|
||||
matches(hourExpr, hour) &&
|
||||
matches(domExpr, dom) &&
|
||||
matches(monthExpr, month) &&
|
||||
matches(dowExpr, dow)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
candidate.setTime(candidate.getTime() + 60_000);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService {
|
||||
export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(SchedulesService.name);
|
||||
private executorInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ScheduledTask)
|
||||
private taskRepository: Repository<ScheduledTask>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
onModuleInit() {
|
||||
// Bootstrap: calculate next_run for any task that has none.
|
||||
this.bootstrapNextRuns().catch(err =>
|
||||
this.logger.error('Failed to bootstrap next_run values', err),
|
||||
);
|
||||
|
||||
// Poll every 60 seconds for due tasks.
|
||||
this.executorInterval = setInterval(() => {
|
||||
this.executeDueTasks().catch(err =>
|
||||
this.logger.error('Schedule executor error', err),
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
this.logger.log('Schedule executor started (60s polling interval)');
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.executorInterval) {
|
||||
clearInterval(this.executorInterval);
|
||||
this.executorInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** On startup, stamp next_run on tasks that don't have one yet. */
|
||||
private async bootstrapNextRuns(): Promise<void> {
|
||||
const tasks = await this.taskRepository.find({
|
||||
where: { is_active: true, next_run: null as any },
|
||||
});
|
||||
|
||||
for (const task of tasks) {
|
||||
const next = nextCronDate(task.cron_expression, new Date());
|
||||
if (next) {
|
||||
task.next_run = next;
|
||||
await this.taskRepository.save(task);
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
this.logger.log(`Bootstrapped next_run for ${tasks.length} task(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Find all active tasks whose next_run <= now and fire them. */
|
||||
private async executeDueTasks(): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
const dueTasks = await this.taskRepository.find({
|
||||
where: {
|
||||
is_active: true,
|
||||
next_run: LessThanOrEqual(now),
|
||||
},
|
||||
});
|
||||
|
||||
if (dueTasks.length === 0) return;
|
||||
|
||||
this.logger.log(`Executing ${dueTasks.length} due task(s)`);
|
||||
|
||||
for (const task of dueTasks) {
|
||||
try {
|
||||
await this.executeTask(task);
|
||||
|
||||
// Advance next_run.
|
||||
const next = nextCronDate(task.cron_expression, now);
|
||||
task.next_run = next ?? null;
|
||||
await this.taskRepository.save(task);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to execute task ${task.id} (${task.task_name})`,
|
||||
(err as Error).stack,
|
||||
);
|
||||
// Still advance next_run so we don't hammer on a broken task.
|
||||
const next = nextCronDate(task.cron_expression, now);
|
||||
task.next_run = next ?? null;
|
||||
await this.taskRepository.save(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch a single task via NATS based on its task_type. */
|
||||
private async executeTask(task: ScheduledTask): Promise<void> {
|
||||
const { license_id, task_type, task_name, task_config } = task;
|
||||
|
||||
this.logger.log(
|
||||
`Firing task: [${task_type}] "${task_name}" for license ${license_id}`,
|
||||
);
|
||||
|
||||
switch (task_type) {
|
||||
case 'restart':
|
||||
await this.natsService.sendServerCommand(license_id, 'restart', {
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'announcement': {
|
||||
const message = (task_config?.message as string) ?? 'Scheduled announcement';
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
|
||||
action: 'command',
|
||||
command: `say ${message}`,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'command': {
|
||||
const command = (task_config?.command as string) ?? '';
|
||||
if (!command) {
|
||||
this.logger.warn(`Task ${task.id} has no command configured — skipping`);
|
||||
return;
|
||||
}
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
|
||||
action: 'command',
|
||||
command,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'plugin_reload': {
|
||||
const plugin_name = (task_config?.plugin_name as string) ?? '';
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.plugin`, {
|
||||
action: 'reload',
|
||||
plugin_name,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unknown task_type "${task_type}" for task ${task.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getTasks(licenseId: string): Promise<ScheduledTask[]> {
|
||||
return await this.taskRepository.find({
|
||||
where: { license_id: licenseId },
|
||||
@@ -27,31 +226,23 @@ export class SchedulesService {
|
||||
licenseId: string,
|
||||
dto: CreateTaskDto,
|
||||
): Promise<ScheduledTask> {
|
||||
// Validate cron expression is parseable
|
||||
// In production, you'd use a cron parser library to validate
|
||||
// For now, we rely on the regex in the DTO
|
||||
|
||||
// Set default timezone if not provided
|
||||
const timezone = dto.timezone || 'UTC';
|
||||
const now = new Date();
|
||||
const next = nextCronDate(dto.cron_expression, now);
|
||||
|
||||
const task = this.taskRepository.create({
|
||||
license_id: licenseId,
|
||||
task_type: dto.task_type,
|
||||
task_name: dto.task_name,
|
||||
cron_expression: dto.cron_expression,
|
||||
timezone: timezone,
|
||||
timezone,
|
||||
task_config: dto.task_config || {},
|
||||
is_active: true,
|
||||
next_run: null, // Would be calculated by scheduler
|
||||
created_at: new Date(),
|
||||
next_run: next ?? null,
|
||||
created_at: now,
|
||||
});
|
||||
|
||||
const saved = await this.taskRepository.save(task);
|
||||
|
||||
// TODO: Register task with scheduler (tokio-cron-scheduler in Rust)
|
||||
// This would send a NATS message to the scheduler service to register the task
|
||||
|
||||
return saved;
|
||||
return await this.taskRepository.save(task);
|
||||
}
|
||||
|
||||
async updateTask(
|
||||
@@ -70,15 +261,15 @@ export class SchedulesService {
|
||||
throw new NotFoundException(`Scheduled task ${taskId} not found`);
|
||||
}
|
||||
|
||||
// Update fields
|
||||
Object.assign(task, dto);
|
||||
|
||||
const updated = await this.taskRepository.save(task);
|
||||
// Recalculate next_run if the cron expression changed.
|
||||
if (dto.cron_expression) {
|
||||
const next = nextCronDate(dto.cron_expression, new Date());
|
||||
task.next_run = next ?? null;
|
||||
}
|
||||
|
||||
// TODO: Update task registration with scheduler
|
||||
// Send NATS message to update the task in tokio-cron-scheduler
|
||||
|
||||
return updated;
|
||||
return await this.taskRepository.save(task);
|
||||
}
|
||||
|
||||
async deleteTask(licenseId: string, taskId: string) {
|
||||
@@ -94,10 +285,6 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
await this.taskRepository.delete(taskId);
|
||||
|
||||
// TODO: Unregister task from scheduler
|
||||
// Send NATS message to remove the task from tokio-cron-scheduler
|
||||
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
@@ -114,11 +301,13 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
task.is_active = enabled;
|
||||
const updated = await this.taskRepository.save(task);
|
||||
|
||||
// TODO: Enable/disable task in scheduler
|
||||
// Send NATS message to pause or resume the task
|
||||
// When re-enabling, calculate next_run if it's missing.
|
||||
if (enabled && !task.next_run) {
|
||||
const next = nextCronDate(task.cron_expression, new Date());
|
||||
task.next_run = next ?? null;
|
||||
}
|
||||
|
||||
return updated;
|
||||
return await this.taskRepository.save(task);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||
@@ -9,6 +9,8 @@ import { DeployServerDto } from './dto/deploy-server.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ServersService {
|
||||
private readonly logger = new Logger(ServersService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ServerConnection)
|
||||
private readonly connectionRepo: Repository<ServerConnection>,
|
||||
@@ -60,8 +62,14 @@ export class ServersService {
|
||||
* Send a console command to the server via NATS
|
||||
*/
|
||||
async sendCommand(licenseId: string, command: string) {
|
||||
await this.natsService.sendServerCommand(licenseId, 'command', { command });
|
||||
return { output: 'Command sent' };
|
||||
try {
|
||||
await this.natsService.sendServerCommand(licenseId, 'command', { command });
|
||||
this.logger.log(`Console command dispatched for license ${licenseId}: ${command}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to dispatch console command for license ${licenseId}: ${(err as Error).message}`);
|
||||
throw new InternalServerErrorException('Failed to dispatch command to server');
|
||||
}
|
||||
return { success: true, message: 'Command dispatched' };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,11 +4,12 @@ import { StatusController } from './status.controller';
|
||||
import { StatusService } from './status.service';
|
||||
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
|
||||
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
import { License } from '../../entities/license.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([PublicSiteConfig, ServerConnection, License]),
|
||||
TypeOrmModule.forFeature([PublicSiteConfig, ServerConnection, ServerStats, License]),
|
||||
],
|
||||
controllers: [StatusController],
|
||||
providers: [StatusService],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
|
||||
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
import { License } from '../../entities/license.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -12,6 +13,8 @@ export class StatusService {
|
||||
private readonly publicSiteRepo: Repository<PublicSiteConfig>,
|
||||
@InjectRepository(ServerConnection)
|
||||
private readonly serverConnectionRepo: Repository<ServerConnection>,
|
||||
@InjectRepository(ServerStats)
|
||||
private readonly serverStatsRepo: Repository<ServerStats>,
|
||||
@InjectRepository(License)
|
||||
private readonly licenseRepo: Repository<License>,
|
||||
) {}
|
||||
@@ -32,12 +35,18 @@ export class StatusService {
|
||||
where: { license_id: config.license_id },
|
||||
});
|
||||
|
||||
// Fetch the most recent stats row for this server to get live player counts
|
||||
const latestStats = await this.serverStatsRepo.findOne({
|
||||
where: { license_id: config.license_id },
|
||||
order: { recorded_at: 'DESC' },
|
||||
});
|
||||
|
||||
return {
|
||||
server_name: license?.subdomain || 'Unknown Server',
|
||||
server_name: license?.server_name || license?.subdomain || 'Unknown Server',
|
||||
subdomain: license?.subdomain || null,
|
||||
status: connection?.connection_status || 'offline',
|
||||
player_count: 0, // Would need real-time data
|
||||
max_players: 0,
|
||||
player_count: latestStats?.player_count ?? 0,
|
||||
max_players: latestStats?.max_players ?? 0,
|
||||
steam_connect_url: config.steam_connect_url,
|
||||
motd: config.motd,
|
||||
discord_invite_url: config.discord_invite_url,
|
||||
|
||||
@@ -4,9 +4,10 @@ import { StoreController } from './store.controller';
|
||||
import { StoreService } from './store.service';
|
||||
import { Module as ModuleEntity } from '../../entities/module.entity';
|
||||
import { ModulePurchase } from '../../entities/module-purchase.entity';
|
||||
import { ModuleInstallation } from '../../entities/module-installation.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase])],
|
||||
imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase, ModuleInstallation])],
|
||||
controllers: [StoreController],
|
||||
providers: [StoreService],
|
||||
exports: [StoreService],
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Module } from '../../entities/module.entity';
|
||||
import { ModulePurchase } from '../../entities/module-purchase.entity';
|
||||
import { ModuleInstallation } from '../../entities/module-installation.entity';
|
||||
|
||||
@Injectable()
|
||||
export class StoreService {
|
||||
private readonly logger = new Logger(StoreService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Module)
|
||||
private readonly moduleRepo: Repository<Module>,
|
||||
@InjectRepository(ModulePurchase)
|
||||
private readonly purchaseRepo: Repository<ModulePurchase>,
|
||||
@InjectRepository(ModuleInstallation)
|
||||
private readonly installationRepo: Repository<ModuleInstallation>,
|
||||
) {}
|
||||
|
||||
async getCatalog(): Promise<Module[]> {
|
||||
@@ -26,14 +31,19 @@ export class StoreService {
|
||||
order: { purchased_at: 'DESC' },
|
||||
});
|
||||
|
||||
const installations = await this.installationRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
relations: ['module'],
|
||||
});
|
||||
|
||||
return {
|
||||
purchased: purchases,
|
||||
installed: purchases.filter(p => p.module), // Stub - would need module_installations table
|
||||
installed: installations,
|
||||
};
|
||||
}
|
||||
|
||||
async purchaseModule(licenseId: string, moduleId: string): Promise<ModulePurchase> {
|
||||
// Check if already purchased
|
||||
// Check if already purchased.
|
||||
const existing = await this.purchaseRepo.findOne({
|
||||
where: { license_id: licenseId, module_id: moduleId },
|
||||
});
|
||||
@@ -50,15 +60,15 @@ export class StoreService {
|
||||
const purchase = this.purchaseRepo.create({
|
||||
license_id: licenseId,
|
||||
module_id: moduleId,
|
||||
transaction_id: `txn_${Date.now()}`, // Stub
|
||||
transaction_id: `txn_${Date.now()}`,
|
||||
amount_paid: parseFloat(module.price_usd.toString()),
|
||||
});
|
||||
|
||||
return this.purchaseRepo.save(purchase);
|
||||
}
|
||||
|
||||
async installModule(licenseId: string, moduleId: string) {
|
||||
// Verify purchase exists
|
||||
async installModule(licenseId: string, moduleId: string): Promise<ModuleInstallation> {
|
||||
// Verify purchase exists.
|
||||
const purchase = await this.purchaseRepo.findOne({
|
||||
where: { license_id: licenseId, module_id: moduleId },
|
||||
});
|
||||
@@ -67,11 +77,44 @@ export class StoreService {
|
||||
throw new ForbiddenException('Module not purchased');
|
||||
}
|
||||
|
||||
// Stub - would create module_installation record
|
||||
return {
|
||||
message: 'Module installed successfully',
|
||||
// Verify module exists.
|
||||
const module = await this.moduleRepo.findOne({ where: { id: moduleId } });
|
||||
if (!module) {
|
||||
throw new NotFoundException('Module not found');
|
||||
}
|
||||
|
||||
// Idempotent: return existing installation record if one already exists.
|
||||
const existing = await this.installationRepo.findOne({
|
||||
where: { license_id: licenseId, module_id: moduleId },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// If previously failed, reset to pending so it can be retried.
|
||||
if (existing.status === 'failed') {
|
||||
existing.status = 'installed';
|
||||
existing.installed_at = new Date();
|
||||
existing.error_message = null;
|
||||
return this.installationRepo.save(existing);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create installation record and mark as installed.
|
||||
// In a full implementation this would trigger an async deployment pipeline.
|
||||
const installation = this.installationRepo.create({
|
||||
license_id: licenseId,
|
||||
module_id: moduleId,
|
||||
status: 'installed',
|
||||
};
|
||||
installed_at: new Date(),
|
||||
error_message: null,
|
||||
});
|
||||
|
||||
const saved = await this.installationRepo.save(installation);
|
||||
|
||||
this.logger.log(
|
||||
`Module installed: ${module.name ?? moduleId} for license ${licenseId} (installation id: ${saved.id})`,
|
||||
);
|
||||
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,19 +126,112 @@ export class WipesService {
|
||||
would_delete: string[];
|
||||
would_preserve: string[];
|
||||
estimated_duration_seconds: number;
|
||||
profile_name: string | null;
|
||||
notes: string[];
|
||||
}> {
|
||||
// Stub implementation - real logic would analyze wipe profile config
|
||||
const mockResult = {
|
||||
would_delete: ['*.sav', '*.db', 'player.deaths.db', 'player.identities.db'],
|
||||
would_preserve: ['oxide/', 'oxide/plugins/', 'oxide/data/', 'backups/'],
|
||||
estimated_duration_seconds: 45,
|
||||
};
|
||||
|
||||
if (dto.wipe_type === 'full') {
|
||||
mockResult.would_delete.push('oxide/data/*');
|
||||
mockResult.estimated_duration_seconds = 120;
|
||||
// Resolve profile config if a profile ID was supplied.
|
||||
let profile: WipeProfile | null = null;
|
||||
if (dto.wipe_profile_id) {
|
||||
profile = await this.wipeProfileRepo.findOne({
|
||||
where: { id: dto.wipe_profile_id, license_id: licenseId },
|
||||
});
|
||||
}
|
||||
|
||||
return mockResult;
|
||||
if (!profile && dto.wipe_profile_id) {
|
||||
throw new NotFoundException(`Wipe profile ${dto.wipe_profile_id} not found`);
|
||||
}
|
||||
|
||||
const notes: string[] = [];
|
||||
|
||||
// Base files affected by all wipe types.
|
||||
const would_delete: string[] = ['*.map', '*.sav'];
|
||||
const would_preserve: string[] = [
|
||||
'oxide/',
|
||||
'oxide/plugins/',
|
||||
'cfg/',
|
||||
'server.cfg',
|
||||
];
|
||||
|
||||
// Blueprint wipe additions.
|
||||
if (dto.wipe_type === 'blueprint' || dto.wipe_type === 'full') {
|
||||
would_delete.push('player.blueprints.db', 'player.tech.db');
|
||||
}
|
||||
|
||||
// Full wipe: also clear player data and oxide data.
|
||||
if (dto.wipe_type === 'full') {
|
||||
would_delete.push(
|
||||
'player.deaths.db',
|
||||
'player.identities.db',
|
||||
'player.states.db',
|
||||
'player.tokens.db',
|
||||
'oxide/data/*',
|
||||
);
|
||||
would_preserve.splice(would_preserve.indexOf('oxide/'), 1);
|
||||
}
|
||||
|
||||
// Factor in pre_wipe_config from the profile (if set).
|
||||
let estimatedSeconds = 45;
|
||||
|
||||
if (profile) {
|
||||
const pre = profile.pre_wipe_config as Record<string, any>;
|
||||
const post = profile.post_wipe_config as Record<string, any>;
|
||||
|
||||
if (pre?.backup_before_wipe) {
|
||||
estimatedSeconds += 60;
|
||||
notes.push('Pre-wipe backup will run before deletion (+60s)');
|
||||
would_preserve.push('backups/');
|
||||
}
|
||||
|
||||
if (pre?.kick_players_before_wipe) {
|
||||
const countdownWarnings: number[] = (pre.countdown_warnings as number[]) ?? [];
|
||||
const maxWarning = countdownWarnings.length > 0 ? Math.max(...countdownWarnings) : 0;
|
||||
if (maxWarning > 0) {
|
||||
estimatedSeconds += maxWarning * 60;
|
||||
notes.push(`Players will be warned ${countdownWarnings.join(', ')} minutes before kick (+${maxWarning * 60}s)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (post?.verify_server_started) {
|
||||
estimatedSeconds += 30;
|
||||
notes.push('Post-wipe: server health check will run (+30s)');
|
||||
}
|
||||
|
||||
if (post?.rollback_on_failure) {
|
||||
notes.push('Rollback on failure is enabled — backup will be preserved if wipe fails');
|
||||
}
|
||||
|
||||
if (post?.max_restart_attempts) {
|
||||
const attempts = post.max_restart_attempts as number;
|
||||
if (attempts > 1) {
|
||||
estimatedSeconds += (attempts - 1) * 15;
|
||||
notes.push(`Up to ${attempts} restart attempts (+${(attempts - 1) * 15}s max)`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
notes.push('No profile selected — using default wipe behavior');
|
||||
}
|
||||
|
||||
// Account for world size in time estimate.
|
||||
// Larger worlds take longer to clear from disk (rough heuristic).
|
||||
// We don't have world_size here without querying server_config,
|
||||
// so apply a static estimate per wipe type.
|
||||
if (dto.wipe_type === 'full') {
|
||||
estimatedSeconds += 75;
|
||||
} else if (dto.wipe_type === 'blueprint') {
|
||||
estimatedSeconds += 10;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Dry-run for license ${licenseId}: type=${dto.wipe_type}, ` +
|
||||
`profile=${profile?.profile_name ?? 'none'}, estimated=${estimatedSeconds}s`,
|
||||
);
|
||||
|
||||
return {
|
||||
would_delete,
|
||||
would_preserve,
|
||||
estimated_duration_seconds: estimatedSeconds,
|
||||
profile_name: profile?.profile_name ?? null,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
type Config struct {
|
||||
// NATS connection
|
||||
NATSUrl string `envconfig:"NATS_URL" required:"true"`
|
||||
NATSToken string `envconfig:"NATS_TOKEN" required:"true"`
|
||||
NATSToken string `envconfig:"NATS_TOKEN" default:""`
|
||||
|
||||
// License identification
|
||||
LicenseID string `envconfig:"LICENSE_ID" required:"true"`
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/deploy"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/filemanager"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/files"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/process"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/update"
|
||||
@@ -32,6 +33,7 @@ type Daemon struct {
|
||||
cfg *DaemonConfig
|
||||
gameServer *process.GameServer
|
||||
fileOps *files.Operations
|
||||
fm *filemanager.FileManager
|
||||
updater *update.Updater
|
||||
deployer *deploy.Deployer
|
||||
subscriptions []*nats.Subscription
|
||||
@@ -73,6 +75,7 @@ func (a *gameServerAdapter) UpdatePath(path string) {
|
||||
func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
||||
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
|
||||
fileOps := files.NewOperations()
|
||||
fm := filemanager.New(cfg.InstallDir)
|
||||
updater := update.NewUpdater(cfg.Version)
|
||||
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
|
||||
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
|
||||
@@ -82,6 +85,7 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
||||
cfg: cfg,
|
||||
gameServer: gameServer,
|
||||
fileOps: fileOps,
|
||||
fm: fm,
|
||||
updater: updater,
|
||||
deployer: deployer,
|
||||
}
|
||||
@@ -118,6 +122,11 @@ func (d *Daemon) Run(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to subscribe to deploy commands: %w", err)
|
||||
}
|
||||
|
||||
// Subscribe to file manager commands (VueFinder-compatible request-reply)
|
||||
if err := d.subscribeFileManager(); err != nil {
|
||||
return fmt.Errorf("failed to subscribe to file manager commands: %w", err)
|
||||
}
|
||||
|
||||
log.Println("All subscriptions active")
|
||||
|
||||
// Start heartbeat ticker
|
||||
@@ -338,6 +347,26 @@ func (d *Daemon) subscribeDeployCommand() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// subscribeFileManager subscribes to the VueFinder-compatible file manager
|
||||
// command subject. All operations (list, delete, rename, copy, move, mkdir,
|
||||
// mkfile, search, preview, save, upload) are handled by the filemanager package
|
||||
// which enforces the installDir jail on every path.
|
||||
func (d *Daemon) subscribeFileManager() error {
|
||||
subject := fmt.Sprintf("corrosion.%s.files.cmd", d.cfg.LicenseID)
|
||||
|
||||
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||
d.fm.HandleNatsRequest(msg)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.subscriptions = append(d.subscriptions, sub)
|
||||
log.Printf("Subscribed to: %s", subject)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleFileOperation processes file operation requests
|
||||
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
|
||||
// Parse common fields
|
||||
|
||||
@@ -7,13 +7,12 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// Connect establishes a connection to NATS with token authentication
|
||||
// Connect establishes a connection to NATS with optional token authentication
|
||||
// and automatic reconnection handling
|
||||
func Connect(url, token string) (*nats.Conn, error) {
|
||||
opts := []nats.Option{
|
||||
nats.Token(token),
|
||||
nats.Name("corrosion-companion"),
|
||||
nats.MaxReconnects(-1), // Unlimited reconnect attempts
|
||||
nats.MaxReconnects(-1),
|
||||
nats.ReconnectWait(2 * time.Second),
|
||||
nats.DisconnectErrHandler(func(nc *nats.Conn, err error) {
|
||||
if err != nil {
|
||||
@@ -31,6 +30,11 @@ func Connect(url, token string) (*nats.Conn, error) {
|
||||
}),
|
||||
}
|
||||
|
||||
// Only use token auth if a token is provided
|
||||
if token != "" {
|
||||
opts = append(opts, nats.Token(token))
|
||||
}
|
||||
|
||||
nc, err := nats.Connect(url, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to NATS: %w", err)
|
||||
|
||||
735
companion-agent/internal/filemanager/filemanager.go
Normal file
735
companion-agent/internal/filemanager/filemanager.go
Normal file
@@ -0,0 +1,735 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxReadSize is the maximum file size allowed for GetContent (5 MB).
|
||||
// This guards against accidentally reading large binary files into memory.
|
||||
maxReadSize = 5 * 1024 * 1024
|
||||
|
||||
// maxSearchResults caps the number of results returned by Search.
|
||||
maxSearchResults = 100
|
||||
|
||||
// storageName is the VueFinder storage identifier used in all path strings.
|
||||
storageName = "server"
|
||||
|
||||
// adapterName is the VueFinder adapter field included in every list/search response.
|
||||
adapterName = "local"
|
||||
)
|
||||
|
||||
// FileManager handles sandboxed filesystem operations for the game server
|
||||
// install directory. All operations are confined to installDir — any path
|
||||
// that resolves outside that boundary is rejected with an error.
|
||||
type FileManager struct {
|
||||
// installDir is the absolute, symlink-resolved root of the jail.
|
||||
// It is set once at construction and never changes.
|
||||
installDir string
|
||||
}
|
||||
|
||||
// New creates a FileManager jailed to installDir. The directory is cleaned and
|
||||
// made absolute but NOT required to exist at construction time — the daemon may
|
||||
// start before the install completes.
|
||||
func New(installDir string) *FileManager {
|
||||
// Clean and make absolute so comparisons are deterministic even if the
|
||||
// caller passed a relative or un-normalised path.
|
||||
abs, err := filepath.Abs(filepath.Clean(installDir))
|
||||
if err != nil {
|
||||
// Abs only fails on systems where os.Getwd() fails; fall back to raw value.
|
||||
log.Printf("filemanager: warning: could not make installDir absolute (%v), using raw value", err)
|
||||
abs = filepath.Clean(installDir)
|
||||
}
|
||||
return &FileManager{installDir: abs}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — one method per VueFinder operation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// List returns the contents of the directory identified by storagePath.
|
||||
func (fm *FileManager) List(storagePath string) (*ListResponse, error) {
|
||||
abs, rel, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(abs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read directory: %w", err)
|
||||
}
|
||||
|
||||
files, err := buildFileItems(entries, abs, rel, fm.installDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ListResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(rel),
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Delete removes every item listed in items from within storagePath's directory.
|
||||
// After deletion it returns a fresh listing of the parent directory.
|
||||
func (fm *FileManager) Delete(storagePath string, items []string) (*ListResponse, error) {
|
||||
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
itemAbs, _, err := fm.parseAndResolve(item)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid item path %q: %w", item, err)
|
||||
}
|
||||
if err := os.RemoveAll(itemAbs); err != nil {
|
||||
return nil, fmt.Errorf("failed to delete %q: %w", item, err)
|
||||
}
|
||||
log.Printf("filemanager: deleted %s", itemAbs)
|
||||
}
|
||||
|
||||
// Return a fresh listing of the parent so the frontend can update immediately.
|
||||
entries, err := os.ReadDir(parentAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot re-read directory after delete: %w", err)
|
||||
}
|
||||
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(parentRel),
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Rename renames item inside storagePath to newName.
|
||||
// newName must be a bare filename, not a path — slashes are rejected.
|
||||
func (fm *FileManager) Rename(storagePath string, item string, newName string) (*ListResponse, error) {
|
||||
if strings.ContainsAny(newName, "/\\") {
|
||||
return nil, fmt.Errorf("new name must not contain path separators")
|
||||
}
|
||||
if newName == "" || newName == "." || newName == ".." {
|
||||
return nil, fmt.Errorf("invalid new name %q", newName)
|
||||
}
|
||||
|
||||
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
itemAbs, _, err := fm.parseAndResolve(item)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid item path %q: %w", item, err)
|
||||
}
|
||||
|
||||
destAbs := filepath.Join(parentAbs, newName)
|
||||
// Verify the destination is also inside the jail before committing.
|
||||
if err := fm.checkWithinJail(destAbs); err != nil {
|
||||
return nil, fmt.Errorf("destination escapes jail: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(itemAbs, destAbs); err != nil {
|
||||
return nil, fmt.Errorf("rename failed: %w", err)
|
||||
}
|
||||
log.Printf("filemanager: renamed %s -> %s", itemAbs, destAbs)
|
||||
|
||||
entries, err := os.ReadDir(parentAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot re-read directory after rename: %w", err)
|
||||
}
|
||||
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(parentRel),
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateFolder creates a new directory named name inside storagePath.
|
||||
func (fm *FileManager) CreateFolder(storagePath string, name string) (*ListResponse, error) {
|
||||
if err := validateBareName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newDirAbs := filepath.Join(parentAbs, name)
|
||||
if err := fm.checkWithinJail(newDirAbs); err != nil {
|
||||
return nil, fmt.Errorf("target escapes jail: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(newDirAbs, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
log.Printf("filemanager: created directory %s", newDirAbs)
|
||||
|
||||
entries, err := os.ReadDir(parentAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot re-read directory after mkdir: %w", err)
|
||||
}
|
||||
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(parentRel),
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateFile creates an empty file named name inside storagePath.
|
||||
func (fm *FileManager) CreateFile(storagePath string, name string) (*ListResponse, error) {
|
||||
if err := validateBareName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newFileAbs := filepath.Join(parentAbs, name)
|
||||
if err := fm.checkWithinJail(newFileAbs); err != nil {
|
||||
return nil, fmt.Errorf("target escapes jail: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(newFileAbs, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
log.Printf("filemanager: created file %s", newFileAbs)
|
||||
|
||||
entries, err := os.ReadDir(parentAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot re-read directory after mkfile: %w", err)
|
||||
}
|
||||
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(parentRel),
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetContent reads and returns the UTF-8 content of the file at storagePath.
|
||||
// Reading is capped at maxReadSize (5 MB) to avoid loading large binaries.
|
||||
func (fm *FileManager) GetContent(storagePath string) (string, error) {
|
||||
abs, _, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot stat file: %w", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return "", fmt.Errorf("path is a directory, not a file")
|
||||
}
|
||||
if info.Size() > maxReadSize {
|
||||
return "", fmt.Errorf("file size %d bytes exceeds read limit of %d bytes", info.Size(), maxReadSize)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot read file: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// SaveContent overwrites the file at storagePath with content.
|
||||
func (fm *FileManager) SaveContent(storagePath string, content string) error {
|
||||
abs, _, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure parent directory exists.
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil {
|
||||
return fmt.Errorf("cannot create parent directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(abs, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("cannot write file: %w", err)
|
||||
}
|
||||
log.Printf("filemanager: saved %d bytes to %s", len(content), abs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search walks the directory at storagePath and returns files whose names
|
||||
// contain filter (case-insensitive). Results are capped at maxSearchResults.
|
||||
func (fm *FileManager) Search(storagePath string, filter string) (*SearchResponse, error) {
|
||||
rootAbs, rootRel, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lowerFilter := strings.ToLower(filter)
|
||||
var results []FileItem
|
||||
|
||||
err = filepath.WalkDir(rootAbs, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
// Skip entries that produce errors (e.g. permission denied) rather than aborting.
|
||||
log.Printf("filemanager: search: skipping %s: %v", path, walkErr)
|
||||
return nil
|
||||
}
|
||||
if path == rootAbs {
|
||||
// Skip the root itself.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Security: verify every path we touch is within the jail.
|
||||
if err := fm.checkWithinJail(path); err != nil {
|
||||
log.Printf("filemanager: search: path %s escaped jail, skipping", path)
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
if strings.Contains(strings.ToLower(d.Name()), lowerFilter) {
|
||||
rel, relErr := filepath.Rel(fm.installDir, path)
|
||||
if relErr != nil {
|
||||
return nil
|
||||
}
|
||||
item, buildErr := buildFileItem(d, path, rel)
|
||||
if buildErr != nil {
|
||||
return nil
|
||||
}
|
||||
results = append(results, item)
|
||||
if len(results) >= maxSearchResults {
|
||||
return io.EOF // Signal early exit; WalkDir treats this as a stop, not an error.
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// WalkDir returns io.EOF only if we injected it; suppress it here.
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, fmt.Errorf("search walk failed: %w", err)
|
||||
}
|
||||
|
||||
_ = rootRel // rootRel used for Dirname below.
|
||||
return &SearchResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(rootRel),
|
||||
Files: results,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Move moves every item in items into destination.
|
||||
// After the move it returns a fresh listing of destination.
|
||||
func (fm *FileManager) Move(storagePath string, items []string, destination string) (*ListResponse, error) {
|
||||
destAbs, destRel, err := fm.parseAndResolve(destination)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid destination %q: %w", destination, err)
|
||||
}
|
||||
|
||||
// Ensure destination directory exists.
|
||||
if err := os.MkdirAll(destAbs, 0755); err != nil {
|
||||
return nil, fmt.Errorf("cannot create destination directory: %w", err)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
itemAbs, _, err := fm.parseAndResolve(item)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid item path %q: %w", item, err)
|
||||
}
|
||||
targetAbs := filepath.Join(destAbs, filepath.Base(itemAbs))
|
||||
if err := fm.checkWithinJail(targetAbs); err != nil {
|
||||
return nil, fmt.Errorf("move target escapes jail: %w", err)
|
||||
}
|
||||
if err := os.Rename(itemAbs, targetAbs); err != nil {
|
||||
// os.Rename fails across filesystems; fall back to copy+delete.
|
||||
if err2 := copyRecursive(itemAbs, targetAbs); err2 != nil {
|
||||
return nil, fmt.Errorf("failed to move %q: %w", item, err2)
|
||||
}
|
||||
if err2 := os.RemoveAll(itemAbs); err2 != nil {
|
||||
return nil, fmt.Errorf("moved %q but failed to remove source: %w", item, err2)
|
||||
}
|
||||
}
|
||||
log.Printf("filemanager: moved %s -> %s", itemAbs, targetAbs)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(destAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read destination after move: %w", err)
|
||||
}
|
||||
files, err := buildFileItems(entries, destAbs, destRel, fm.installDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(destRel),
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Copy copies every item in items into destination.
|
||||
// After the copy it returns a fresh listing of destination.
|
||||
func (fm *FileManager) Copy(storagePath string, items []string, destination string) (*ListResponse, error) {
|
||||
destAbs, destRel, err := fm.parseAndResolve(destination)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid destination %q: %w", destination, err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(destAbs, 0755); err != nil {
|
||||
return nil, fmt.Errorf("cannot create destination directory: %w", err)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
itemAbs, _, err := fm.parseAndResolve(item)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid item path %q: %w", item, err)
|
||||
}
|
||||
targetAbs := filepath.Join(destAbs, filepath.Base(itemAbs))
|
||||
if err := fm.checkWithinJail(targetAbs); err != nil {
|
||||
return nil, fmt.Errorf("copy target escapes jail: %w", err)
|
||||
}
|
||||
if err := copyRecursive(itemAbs, targetAbs); err != nil {
|
||||
return nil, fmt.Errorf("failed to copy %q: %w", item, err)
|
||||
}
|
||||
log.Printf("filemanager: copied %s -> %s", itemAbs, targetAbs)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(destAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read destination after copy: %w", err)
|
||||
}
|
||||
files, err := buildFileItems(entries, destAbs, destRel, fm.installDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(destRel),
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Upload decodes the base64-encoded content and writes it as filename inside storagePath.
|
||||
// After the write it returns a fresh listing of the parent directory.
|
||||
func (fm *FileManager) Upload(storagePath string, filename string, content []byte) (*ListResponse, error) {
|
||||
if err := validateBareName(filename); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
destAbs := filepath.Join(parentAbs, filename)
|
||||
if err := fm.checkWithinJail(destAbs); err != nil {
|
||||
return nil, fmt.Errorf("upload target escapes jail: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(parentAbs, 0755); err != nil {
|
||||
return nil, fmt.Errorf("cannot create upload directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(destAbs, content, 0644); err != nil {
|
||||
return nil, fmt.Errorf("cannot write uploaded file: %w", err)
|
||||
}
|
||||
log.Printf("filemanager: uploaded %d bytes to %s", len(content), destAbs)
|
||||
|
||||
entries, err := os.ReadDir(parentAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot re-read directory after upload: %w", err)
|
||||
}
|
||||
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListResponse{
|
||||
Adapter: adapterName,
|
||||
Storages: []string{storageName},
|
||||
Dirname: toStoragePath(parentRel),
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path parsing and security helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ParsePath converts a VueFinder storage path ("server://relative/path") into
|
||||
// the absolute filesystem path within the jail.
|
||||
//
|
||||
// Rules:
|
||||
// - Must have the form "server://<relative>" where relative may be empty.
|
||||
// - The storage identifier must be "server" — anything else is rejected.
|
||||
// - The resulting absolute path must be within installDir.
|
||||
func (fm *FileManager) ParsePath(storagePath string) (string, error) {
|
||||
abs, _, err := fm.parseAndResolve(storagePath)
|
||||
return abs, err
|
||||
}
|
||||
|
||||
// parseAndResolve parses a storage path and resolves it to an absolute path.
|
||||
// Returns (absolutePath, relativePath, error). relativePath is relative to
|
||||
// installDir and uses the OS path separator.
|
||||
func (fm *FileManager) parseAndResolve(storagePath string) (absPath string, relPath string, err error) {
|
||||
const sep = "://"
|
||||
|
||||
idx := strings.Index(storagePath, sep)
|
||||
if idx < 0 {
|
||||
// Tolerate a bare relative path for internal calls, but flag it.
|
||||
return "", "", fmt.Errorf("invalid storage path %q: missing storage prefix (expected %q://...)", storagePath, storageName)
|
||||
}
|
||||
|
||||
storage := storagePath[:idx]
|
||||
if storage != storageName {
|
||||
return "", "", fmt.Errorf("unknown storage %q: only %q is supported", storage, storageName)
|
||||
}
|
||||
|
||||
relative := storagePath[idx+len(sep):]
|
||||
|
||||
return fm.resolvePath(relative)
|
||||
}
|
||||
|
||||
// resolvePath resolves a relative path (which may be empty, representing the
|
||||
// root) to an absolute path inside the jail. It runs filepath.EvalSymlinks on
|
||||
// the resolved path and then re-verifies the prefix to prevent symlink escapes.
|
||||
// Parent-traversal via ".." is neutralised by filepath.Clean before joining.
|
||||
func (fm *FileManager) resolvePath(relativePath string) (absPath string, relPath string, err error) {
|
||||
// filepath.Clean neutralises ".." sequences before we ever join.
|
||||
cleaned := filepath.Clean(relativePath)
|
||||
if cleaned == "." {
|
||||
cleaned = ""
|
||||
}
|
||||
|
||||
var absolute string
|
||||
if cleaned == "" {
|
||||
absolute = fm.installDir
|
||||
} else {
|
||||
absolute = filepath.Join(fm.installDir, cleaned)
|
||||
}
|
||||
|
||||
// First prefix check — fast path before hitting the filesystem.
|
||||
if !strings.HasPrefix(absolute, fm.installDir+string(filepath.Separator)) && absolute != fm.installDir {
|
||||
return "", "", fmt.Errorf("path %q escapes install directory", relativePath)
|
||||
}
|
||||
|
||||
// Resolve symlinks so we can do an authoritative prefix check.
|
||||
resolved, symlinkErr := filepath.EvalSymlinks(absolute)
|
||||
if symlinkErr != nil {
|
||||
// EvalSymlinks fails if the path does not exist yet (e.g. a new file).
|
||||
// In that case fall back to the unresolved absolute path; the subsequent
|
||||
// prefix check is still valid because we already cleaned ".." away.
|
||||
resolved = absolute
|
||||
}
|
||||
|
||||
// Authoritative prefix check on the symlink-resolved path.
|
||||
if !strings.HasPrefix(resolved, fm.installDir+string(filepath.Separator)) && resolved != fm.installDir {
|
||||
return "", "", fmt.Errorf("path %q resolves outside install directory (possible symlink escape)", relativePath)
|
||||
}
|
||||
|
||||
// Compute relative portion for use in VueFinder response paths.
|
||||
rel, relErr := filepath.Rel(fm.installDir, resolved)
|
||||
if relErr != nil {
|
||||
rel = cleaned
|
||||
}
|
||||
if rel == "." {
|
||||
rel = ""
|
||||
}
|
||||
|
||||
return resolved, rel, nil
|
||||
}
|
||||
|
||||
// checkWithinJail verifies that an absolute path (already joined but not yet
|
||||
// symlink-resolved) stays within the jail. Used for destination paths that may
|
||||
// not exist yet.
|
||||
func (fm *FileManager) checkWithinJail(absPath string) error {
|
||||
clean := filepath.Clean(absPath)
|
||||
if !strings.HasPrefix(clean, fm.installDir+string(filepath.Separator)) && clean != fm.installDir {
|
||||
return fmt.Errorf("path %q is outside the install directory", absPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VueFinder path formatting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// toStoragePath converts a relative path (relative to installDir) to the
|
||||
// "server://..." format expected by VueFinder. An empty relative path maps to
|
||||
// "server://".
|
||||
func toStoragePath(relPath string) string {
|
||||
// Normalise OS separators to forward slashes for the JSON response.
|
||||
fwd := filepath.ToSlash(relPath)
|
||||
return storageName + "://" + fwd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileItem construction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// buildFileItems builds the slice of FileItem values for a directory listing.
|
||||
func buildFileItems(entries []fs.DirEntry, dirAbs string, dirRel string, installDir string) ([]FileItem, error) {
|
||||
items := make([]FileItem, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
entryPath := filepath.Join(dirAbs, entry.Name())
|
||||
entryRel := filepath.Join(dirRel, entry.Name())
|
||||
if dirRel == "" {
|
||||
entryRel = entry.Name()
|
||||
}
|
||||
|
||||
item, err := buildFileItem(entry, entryPath, entryRel)
|
||||
if err != nil {
|
||||
log.Printf("filemanager: warning: skipping %s: %v", entryPath, err)
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// buildFileItem builds a single FileItem from a DirEntry.
|
||||
// entryAbs is the absolute path; entryRel is relative to installDir.
|
||||
func buildFileItem(entry fs.DirEntry, entryAbs string, entryRel string) (FileItem, error) {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return FileItem{}, fmt.Errorf("cannot stat %s: %w", entryAbs, err)
|
||||
}
|
||||
|
||||
itemType := "file"
|
||||
ext := ""
|
||||
size := info.Size()
|
||||
|
||||
if entry.IsDir() {
|
||||
itemType = "dir"
|
||||
size = 0
|
||||
} else {
|
||||
raw := filepath.Ext(entry.Name())
|
||||
if len(raw) > 0 {
|
||||
ext = raw[1:] // strip the leading dot
|
||||
}
|
||||
}
|
||||
|
||||
// Normalise to forward slashes for the JSON path field.
|
||||
fwdRel := filepath.ToSlash(entryRel)
|
||||
|
||||
return FileItem{
|
||||
Type: itemType,
|
||||
Path: storageName + "://" + fwdRel,
|
||||
Basename: entry.Name(),
|
||||
Extension: ext,
|
||||
Storage: storageName,
|
||||
FileSize: size,
|
||||
LastModified: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recursive copy helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// copyRecursive copies src to dst. If src is a directory it is copied
|
||||
// recursively. dst must not exist.
|
||||
func copyRecursive(src, dst string) error {
|
||||
srcInfo, err := os.Lstat(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot stat source: %w", err)
|
||||
}
|
||||
|
||||
if srcInfo.IsDir() {
|
||||
if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil {
|
||||
return fmt.Errorf("cannot create destination directory: %w", err)
|
||||
}
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read source directory: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := copyRecursive(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Regular file copy.
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open source file: %w", err)
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode())
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create destination file: %w", err)
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||
return fmt.Errorf("copy failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// validateBareName rejects names that are empty, are "." or "..", or contain
|
||||
// path separators. This is used to validate user-supplied filenames for create,
|
||||
// rename, and upload operations.
|
||||
func validateBareName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("name must not be empty")
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
return fmt.Errorf("name %q is not allowed", name)
|
||||
}
|
||||
if strings.ContainsAny(name, "/\\") {
|
||||
return fmt.Errorf("name %q must not contain path separators", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecodeBase64 is a convenience wrapper used by the handler when the upload
|
||||
// content arrives as a base64 string.
|
||||
func DecodeBase64(encoded string) ([]byte, error) {
|
||||
// Accept both standard and URL-safe base64, with or without padding.
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
// Try URL-safe encoding as a fallback.
|
||||
decoded, err = base64.URLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
// Try raw (no padding) variants.
|
||||
decoded, err = base64.RawStdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot base64-decode upload content: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// suppress unused import warning — time is used in buildFileItem via ModTime.Format
|
||||
var _ = time.Now
|
||||
209
companion-agent/internal/filemanager/handler.go
Normal file
209
companion-agent/internal/filemanager/handler.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// HandleNatsRequest is the NATS message handler for the file manager command
|
||||
// subject (corrosion.{license_id}.files.cmd). It deserialises the request,
|
||||
// routes to the correct FileManager operation, and calls msg.Respond with a
|
||||
// NatsResponse JSON payload — either success with data or a structured error.
|
||||
func (fm *FileManager) HandleNatsRequest(msg *nats.Msg) {
|
||||
var req NatsRequest
|
||||
if err := json.Unmarshal(msg.Data, &req); err != nil {
|
||||
log.Printf("filemanager: invalid NATS request payload: %v", err)
|
||||
respondError(msg, "invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("filemanager: handling %s path=%q", req.Func, req.Path)
|
||||
|
||||
switch req.Func {
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Directory listing
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncList:
|
||||
result, err := fm.List(req.Path)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Delete
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncDelete:
|
||||
result, err := fm.Delete(req.Path, req.Items)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rename — req.Name holds the new basename
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncRename:
|
||||
if len(req.Items) == 0 {
|
||||
respondError(msg, "rename requires at least one item")
|
||||
return
|
||||
}
|
||||
result, err := fm.Rename(req.Path, req.Items[0], req.Name)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Copy
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncCopy:
|
||||
if req.Destination == "" {
|
||||
respondError(msg, "copy requires a destination path")
|
||||
return
|
||||
}
|
||||
result, err := fm.Copy(req.Path, req.Items, req.Destination)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Move
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncMove:
|
||||
if req.Destination == "" {
|
||||
respondError(msg, "move requires a destination path")
|
||||
return
|
||||
}
|
||||
result, err := fm.Move(req.Path, req.Items, req.Destination)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Create directory
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncCreateFolder:
|
||||
if req.Name == "" {
|
||||
respondError(msg, "mkdir requires a folder name")
|
||||
return
|
||||
}
|
||||
result, err := fm.CreateFolder(req.Path, req.Name)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Create empty file
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncCreateFile:
|
||||
if req.Name == "" {
|
||||
respondError(msg, "mkfile requires a file name")
|
||||
return
|
||||
}
|
||||
result, err := fm.CreateFile(req.Path, req.Name)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Search
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncSearch:
|
||||
result, err := fm.Search(req.Path, req.Filter)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Preview / read file content (VueFinder uses "fm_preview" for text files)
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncPreview:
|
||||
content, err := fm.GetContent(req.Path)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, map[string]string{"content": content})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Save file content
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncSave:
|
||||
if err := fm.SaveContent(req.Path, req.Content); err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, map[string]string{"status": "saved"})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Upload — content arrives as a base64-encoded string
|
||||
// -----------------------------------------------------------------------
|
||||
case FuncUpload:
|
||||
if req.Filename == "" {
|
||||
respondError(msg, "upload requires a filename")
|
||||
return
|
||||
}
|
||||
data, err := DecodeBase64(req.Content)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
result, err := fm.Upload(req.Path, req.Filename, data)
|
||||
if err != nil {
|
||||
respondError(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(msg, result)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Unknown function
|
||||
// -----------------------------------------------------------------------
|
||||
default:
|
||||
log.Printf("filemanager: unknown function %q", req.Func)
|
||||
respondError(msg, "unknown function: "+req.Func)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// respondJSON sends a successful NatsResponse wrapping data.
|
||||
func respondJSON(msg *nats.Msg, data interface{}) {
|
||||
resp := NatsResponse{Success: true, Data: data}
|
||||
bytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
log.Printf("filemanager: failed to marshal success response: %v", err)
|
||||
respondError(msg, "internal: failed to marshal response")
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(bytes); err != nil {
|
||||
log.Printf("filemanager: failed to send response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// respondError sends a failed NatsResponse with the given error message.
|
||||
func respondError(msg *nats.Msg, errMsg string) {
|
||||
resp := NatsResponse{Success: false, Error: errMsg}
|
||||
bytes, _ := json.Marshal(resp)
|
||||
if err := msg.Respond(bytes); err != nil {
|
||||
log.Printf("filemanager: failed to send error response: %v", err)
|
||||
}
|
||||
}
|
||||
62
companion-agent/internal/filemanager/types.go
Normal file
62
companion-agent/internal/filemanager/types.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package filemanager
|
||||
|
||||
// FileItem represents a file or directory in VueFinder format.
|
||||
type FileItem struct {
|
||||
Type string `json:"type"` // "dir" or "file"
|
||||
Path string `json:"path"` // "server://relative/path"
|
||||
Basename string `json:"basename"` // filename.ext
|
||||
Extension string `json:"extension"` // "ext" or "" for dirs
|
||||
Storage string `json:"storage"` // always "server"
|
||||
FileSize int64 `json:"file_size"` // bytes, 0 for dirs
|
||||
LastModified string `json:"last_modified"` // "2006-01-02 15:04:05"
|
||||
}
|
||||
|
||||
// ListResponse is the VueFinder index/directory response.
|
||||
type ListResponse struct {
|
||||
Adapter string `json:"adapter"` // always "local"
|
||||
Storages []string `json:"storages"` // ["server"]
|
||||
Dirname string `json:"dirname"` // "server://current/path"
|
||||
Files []FileItem `json:"files"` // directory contents
|
||||
}
|
||||
|
||||
// SearchResponse is the VueFinder search response.
|
||||
type SearchResponse struct {
|
||||
Adapter string `json:"adapter"`
|
||||
Storages []string `json:"storages"`
|
||||
Dirname string `json:"dirname"`
|
||||
Files []FileItem `json:"files"`
|
||||
}
|
||||
|
||||
// NatsRequest is the NATS request payload sent by the backend for file manager operations.
|
||||
type NatsRequest struct {
|
||||
Func string `json:"func"` // "fm_list", "fm_delete", etc.
|
||||
Path string `json:"path"` // VueFinder storage path, e.g. "server://cfg"
|
||||
Items []string `json:"items,omitempty"` // items to delete/move/copy (relative storage paths)
|
||||
Name string `json:"name,omitempty"` // new name for rename/create
|
||||
Destination string `json:"destination,omitempty"` // destination storage path for move/copy
|
||||
Filter string `json:"filter,omitempty"` // search filter string
|
||||
Content string `json:"content,omitempty"` // file content (save) or base64-encoded data (upload)
|
||||
Filename string `json:"filename,omitempty"` // original filename for upload
|
||||
}
|
||||
|
||||
// NatsResponse wraps every response sent back to the backend.
|
||||
type NatsResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Function name constants matching what the backend sends.
|
||||
const (
|
||||
FuncList = "fm_list"
|
||||
FuncDelete = "fm_delete"
|
||||
FuncRename = "fm_rename"
|
||||
FuncCopy = "fm_copy"
|
||||
FuncMove = "fm_move"
|
||||
FuncCreateFolder = "fm_mkdir"
|
||||
FuncCreateFile = "fm_mkfile"
|
||||
FuncSearch = "fm_search"
|
||||
FuncPreview = "fm_preview"
|
||||
FuncSave = "fm_save"
|
||||
FuncUpload = "fm_upload"
|
||||
)
|
||||
@@ -37,7 +37,7 @@ services:
|
||||
DATABASE_MAX_CONNECTIONS: "20"
|
||||
NATS_URL: nats://nats:4222
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_ACCESS_EXPIRY_SECONDS: "900"
|
||||
JWT_ACCESS_EXPIRY_SECONDS: "14400"
|
||||
JWT_REFRESH_EXPIRY_SECONDS: "604800"
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
|
||||
|
||||
587
frontend/package-lock.json
generated
587
frontend/package-lock.json
generated
@@ -13,7 +13,8 @@
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.2"
|
||||
"vue-router": "^5.0.2",
|
||||
"vuefinder": "^4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
@@ -530,6 +531,31 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
|
||||
"integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
|
||||
"integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.4",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -575,6 +601,71 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@nanostores/i18n": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@nanostores/i18n/-/i18n-1.2.2.tgz",
|
||||
"integrity": "sha512-5LLxl95+ZI46MrM/Kn7YjORKsD7+Xy2tgjZ7/oDT/BGPEiaBM9lK89/afeK+BqaQL0Xd9Xaa5MPuuVSyWAo+/w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"nanostores": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nanostores/persistent": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@nanostores/persistent/-/persistent-1.3.3.tgz",
|
||||
"integrity": "sha512-+b4I8xrmjhKE3hQ9V7/b4Xa+MBMkM2P4Ulv33zFEF/+2Hucsb24vTjYiWR8R97y8YdRptmRKlL5Qwy0q1Jj5nQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"nanostores": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nanostores/vue": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@nanostores/vue/-/vue-1.0.1.tgz",
|
||||
"integrity": "sha512-0VwubMTMvEdWQhVN4BAvDZ+vHQH3O1G9BaOfgrjfF4erqBsWScoK/zyaBeRfFjptNOb25947EFPHBZwEf9JcMg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "buymeacoffee",
|
||||
"url": "https://buymeacoffee.com/euaaaio"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nanostores/logger": "^0.4.0 || ^1.0.0",
|
||||
"@vue/devtools-api": ">=7.6.2",
|
||||
"nanostores": "^0.11.3 || ^1.0.0",
|
||||
"vue": ">=3.3.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nanostores/logger": {
|
||||
"optional": true
|
||||
},
|
||||
"@vue/devtools-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
||||
@@ -1204,6 +1295,95 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/match-sorter-utils": {
|
||||
"version": "8.19.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz",
|
||||
"integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"remove-accents": "0.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.20",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-query": {
|
||||
"version": "5.92.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-query/-/vue-query-5.92.9.tgz",
|
||||
"integrity": "sha512-jjAZcqKveyX0C4w/6zUqbnqk/XzuxNWaFsWjGTJWULVFizUNeLGME2gf9vVSDclIyiBhR13oZJPPs6fJgfpIJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/match-sorter-utils": "^8.19.4",
|
||||
"@tanstack/query-core": "5.90.20",
|
||||
"@vue/devtools-api": "^6.6.3",
|
||||
"vue-demi": "^0.14.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.1.2",
|
||||
"vue": "^2.6.0 || ^3.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-query/node_modules/@vue/devtools-api": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tanstack/vue-query/node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@transloadit/prettier-bytes": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.3.5.tgz",
|
||||
"integrity": "sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1221,6 +1401,166 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/retry": {
|
||||
"version": "0.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
|
||||
"integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@uppy/companion-client": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-5.1.1.tgz",
|
||||
"integrity": "sha512-DzrOWTbIZHvtgAFXBMYHk2wD27NjpBSVhY2tEiEIUhPd2CxbFRZjHM/N3HOt3VwZEAP471QWFLlJRWPcIY3A2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@uppy/utils": "^7.1.1",
|
||||
"namespace-emitter": "^2.0.1",
|
||||
"p-retry": "^6.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@uppy/core": "^5.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@uppy/components": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/components/-/components-1.2.0.tgz",
|
||||
"integrity": "sha512-rtIr+77Rw/q5Vw++xazF1dCg2d4A4zT9CV+ZyN8Rsx8xiIr2CxCR4TaHHBy+WeC0b7Mk6yNuJ0wUa34tFJ6pKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"dequal": "^2.0.3",
|
||||
"preact": "^10.26.10",
|
||||
"pretty-bytes": "^6.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@uppy/core": "^5.2.0",
|
||||
"@uppy/image-editor": "^4.2.0",
|
||||
"@uppy/screen-capture": "^5.1.0",
|
||||
"@uppy/webcam": "^5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@uppy/image-editor": {
|
||||
"optional": true
|
||||
},
|
||||
"@uppy/screen-capture": {
|
||||
"optional": true
|
||||
},
|
||||
"@uppy/webcam": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@uppy/core": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/core/-/core-5.2.0.tgz",
|
||||
"integrity": "sha512-uvfNyz4cnaplt7LYJmEZHuqOuav0tKp4a9WKJIaH6iIj7XiqYvS2J5SEByexAlUFlzefOAyjzj4Ja2dd/8aMrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@transloadit/prettier-bytes": "^0.3.4",
|
||||
"@uppy/store-default": "^5.0.0",
|
||||
"@uppy/utils": "^7.1.4",
|
||||
"lodash": "^4.17.21",
|
||||
"mime-match": "^1.0.2",
|
||||
"namespace-emitter": "^2.0.1",
|
||||
"nanoid": "^5.0.9",
|
||||
"preact": "^10.5.13"
|
||||
}
|
||||
},
|
||||
"node_modules/@uppy/core/node_modules/nanoid": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
|
||||
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@uppy/locales": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/locales/-/locales-5.1.1.tgz",
|
||||
"integrity": "sha512-zSbDU27JzfdssRJoa5/xmGOsrEtS+2Z9j41weaoCa/NoK4wqZzkFNQ0Z44etbTg3PDVFakZVDu/Z+c+vsJCfdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@uppy/utils": "^7.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@uppy/store-default": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-5.0.0.tgz",
|
||||
"integrity": "sha512-hQtCSQ1yGiaval/wVYUWquYGDJ+bpQ7e4FhUUAsRQz1x1K+o7NBtjfp63O9I4Ks1WRoKunpkarZ+as09l02cPw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@uppy/utils": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-7.1.5.tgz",
|
||||
"integrity": "sha512-Vz4WGTjef6WebECGur4clWjpkET4o3bdvPMj1m2sD5cL+dTt69m+FIE5h5JD3HBMLEPTXPVkrXGMIFcbOYC12Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"preact": "^10.5.13"
|
||||
}
|
||||
},
|
||||
"node_modules/@uppy/vue": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/vue/-/vue-3.2.0.tgz",
|
||||
"integrity": "sha512-BZiyUZxpadf3uUp8YzgwRZTIde/m9Ne4ILFygJg7ilFq/Qfb1pBVspG9FJoG23RbOiRuxd4JixwFh0gaFdfL+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@uppy/components": "^1.2.0",
|
||||
"preact": "^10.26.10",
|
||||
"shallow-equal": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@uppy/core": "^5.2.0",
|
||||
"@uppy/dashboard": "^5.1.1",
|
||||
"@uppy/screen-capture": "^5.1.0",
|
||||
"@uppy/status-bar": "^5.1.0",
|
||||
"@uppy/webcam": "^5.1.0",
|
||||
"vue": ">=3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@uppy/dashboard": {
|
||||
"optional": true
|
||||
},
|
||||
"@uppy/screen-capture": {
|
||||
"optional": true
|
||||
},
|
||||
"@uppy/status-bar": {
|
||||
"optional": true
|
||||
},
|
||||
"@uppy/webcam": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@uppy/xhr-upload": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-5.1.1.tgz",
|
||||
"integrity": "sha512-Vp0HWVA8o+niC2uISxPt0pZ+95bHHkk9HzNaUTrff/vq+20Ln68BS2auJhc9ecJzI6SKAlGZ342dcTQ/onw0nA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@uppy/companion-client": "^5.1.1",
|
||||
"@uppy/utils": "^7.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@uppy/core": "^5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@viselect/vanilla": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@viselect/vanilla/-/vanilla-3.9.0.tgz",
|
||||
"integrity": "sha512-E9eBgoi/crJ0SlZMAc+Yst7nU324LZ5LLvcXjzWEcrfllscdpTml2OLOKHC7O8Bbz19OybSLv6VexxnjlJrLxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz",
|
||||
@@ -1537,6 +1877,21 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||
@@ -1564,12 +1919,27 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
|
||||
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -1580,6 +1950,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/easy-bem": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/easy-bem/-/easy-bem-1.1.1.tgz",
|
||||
"integrity": "sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
|
||||
@@ -1715,6 +2091,18 @@
|
||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-network-error": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz",
|
||||
"integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-what": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
||||
@@ -2039,6 +2427,12 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lucide-vue-next": {
|
||||
"version": "0.564.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.564.0.tgz",
|
||||
@@ -2072,6 +2466,15 @@
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mime-match/-/mime-match-1.0.2.tgz",
|
||||
"integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wildcard": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
@@ -2113,6 +2516,12 @@
|
||||
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/namespace-emitter": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/namespace-emitter/-/namespace-emitter-2.0.1.tgz",
|
||||
"integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -2131,6 +2540,44 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/nanostores": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.1.0.tgz",
|
||||
"integrity": "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.0.0 || >=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/overlayscrollbars": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.14.0.tgz",
|
||||
"integrity": "sha512-RjV0pqc79kYhQLC3vTcLRb5GLpI1n6qh0Oua3g+bGH4EgNOJHVBGP7u0zZtxoAa0dkHlAqTTSYRb9MMmxNLjig==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/p-retry": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz",
|
||||
"integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/retry": "0.12.2",
|
||||
"is-network-error": "^1.0.0",
|
||||
"retry": "^0.13.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
@@ -2253,6 +2700,28 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.28.4",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz",
|
||||
"integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-bytes": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
|
||||
"integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
@@ -2282,6 +2751,21 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/remove-accents": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
|
||||
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
@@ -2339,6 +2823,12 @@
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shallow-equal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz",
|
||||
"integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2469,6 +2959,16 @@
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/vanilla-lazyload": {
|
||||
"version": "19.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vanilla-lazyload/-/vanilla-lazyload-19.1.3.tgz",
|
||||
"integrity": "sha512-bBMERPu2AFJc35krS+8BOhq++c6dRfL6q368lJPnkS5U92fRQagTR3FsNta69/GukfZzDwDEjD5M3U7VuSiCDw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://ko-fi.com/verlok"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
@@ -2572,6 +3072,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-advanced-cropper": {
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/vue-advanced-cropper/-/vue-advanced-cropper-2.8.9.tgz",
|
||||
"integrity": "sha512-1jc5gO674kVGpJKekoaol6ZlwaF5VYDLSBwBOUpViW0IOrrRsyLw6XNszjEqgbavvqinlKNS6Kqlom3B5M72Tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"classnames": "^2.2.6",
|
||||
"debounce": "^1.2.0",
|
||||
"easy-bem": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.2.tgz",
|
||||
@@ -2656,6 +3174,28 @@
|
||||
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-sonner": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-2.0.9.tgz",
|
||||
"integrity": "sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": "^4.0.3",
|
||||
"@nuxt/schema": "^4.0.3",
|
||||
"nuxt": "^4.0.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"@nuxt/schema": {
|
||||
"optional": true
|
||||
},
|
||||
"nuxt": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-tsc": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz",
|
||||
@@ -2673,12 +3213,57 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vuefinder": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vuefinder/-/vuefinder-4.1.1.tgz",
|
||||
"integrity": "sha512-sRrDj7+jrSN4CCQCkBDhc4Z3ZEP2CHa3HibPsByy/yEj2BVH6G7Fn9fSJ3IjGEivYTdURc6DbusidU6Rg6/SqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.4",
|
||||
"@nanostores/i18n": "^1.2.2",
|
||||
"@nanostores/persistent": "^1.1.0",
|
||||
"@nanostores/vue": "^1.0.1",
|
||||
"@tanstack/vue-query": "^5.90.2",
|
||||
"@uppy/core": "^5.0.2",
|
||||
"@uppy/locales": "^5.0.1",
|
||||
"@uppy/vue": "^3.1.0",
|
||||
"@uppy/xhr-upload": "^5.0.1",
|
||||
"@viselect/vanilla": "^3.9.0",
|
||||
"mitt": "^3.0.1",
|
||||
"nanostores": "^1.0.1",
|
||||
"overlayscrollbars": "^2.12.0",
|
||||
"vanilla-lazyload": "^19.1.3",
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-sonner": "^2.0.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/n1crack"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.22"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vue": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wildcard": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-1.1.2.tgz",
|
||||
"integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.2"
|
||||
"vue-router": "^5.0.2",
|
||||
"vuefinder": "^4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
@@ -42,6 +43,7 @@ const navItems = [
|
||||
{ name: 'Console', path: '/console', icon: Terminal, permission: 'console.view' },
|
||||
{ name: 'Players', path: '/players', icon: Users, permission: 'players.view' },
|
||||
{ name: 'Plugins', path: '/plugins', icon: Puzzle, permission: 'plugins.view' },
|
||||
{ name: 'File Manager', path: '/files', icon: FolderOpen, permission: 'files.view' },
|
||||
{ name: 'Auto-Wiper', path: '/wipes', icon: RefreshCw, permission: 'wipes.view' },
|
||||
{ name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' },
|
||||
{ name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' },
|
||||
|
||||
@@ -107,5 +107,40 @@ export function useApi() {
|
||||
return request<T>(path, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
return { request, get, post, put, del }
|
||||
/**
|
||||
* Upload a FormData payload (multipart/form-data).
|
||||
* Does NOT set Content-Type — browser must set it with the correct boundary.
|
||||
*/
|
||||
async function upload<T>(path: string, formData: FormData): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
if (auth.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${auth.accessToken}`
|
||||
}
|
||||
|
||||
let response = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.status === 401 && auth.refreshToken) {
|
||||
await attemptRefresh()
|
||||
headers['Authorization'] = `Bearer ${auth.accessToken}`
|
||||
response = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Upload failed' }))
|
||||
throw new Error(error.message || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
return { request, get, post, put, del, upload }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
import 'vuefinder/dist/vuefinder.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
||||
@@ -105,6 +105,11 @@ const panelRoutes: RouteRecordRaw[] = [
|
||||
name: 'plugins',
|
||||
component: () => import('@/views/admin/PluginsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'files',
|
||||
name: 'files',
|
||||
component: () => import('@/views/admin/FileManagerView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'wipes',
|
||||
name: 'wipes',
|
||||
|
||||
@@ -3,10 +3,39 @@ import { ref } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { PluginEntry } from '@/types'
|
||||
|
||||
export interface UmodPlugin {
|
||||
name: string
|
||||
title: string
|
||||
slug: string
|
||||
author: string
|
||||
description: string
|
||||
downloads: number
|
||||
downloads_shortened: string
|
||||
download_url: string
|
||||
latest_release_version: string
|
||||
latest_release_version_formatted: string
|
||||
icon_url: string
|
||||
url: string
|
||||
tags_all: string
|
||||
watchers_shortened: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface UmodBrowseResult {
|
||||
current_page: number
|
||||
data: UmodPlugin[]
|
||||
last_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export const usePluginStore = defineStore('plugins', () => {
|
||||
const plugins = ref<PluginEntry[]>([])
|
||||
const searchResults = ref<any[]>([])
|
||||
const browseResults = ref<UmodBrowseResult | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const isBrowseLoading = ref(false)
|
||||
const api = useApi()
|
||||
|
||||
async function fetchPlugins() {
|
||||
@@ -18,7 +47,7 @@ export const usePluginStore = defineStore('plugins', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function installPlugin(data: { plugin_name: string; source: string }) {
|
||||
async function installPlugin(data: { plugin_name: string; umod_slug?: string; source: string }) {
|
||||
await api.post('/plugins/install', data)
|
||||
await fetchPlugins()
|
||||
}
|
||||
@@ -37,6 +66,7 @@ export const usePluginStore = defineStore('plugins', () => {
|
||||
await fetchPlugins()
|
||||
}
|
||||
|
||||
// Legacy — kept for backwards compatibility, routes to browse
|
||||
async function searchPlugins(query: string) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
@@ -46,15 +76,38 @@ export const usePluginStore = defineStore('plugins', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function browseUmod(query: string, page = 1, sort = 'downloads') {
|
||||
isBrowseLoading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), sort })
|
||||
if (query.trim()) params.set('query', query.trim())
|
||||
browseResults.value = await api.get<UmodBrowseResult>(`/plugins/browse?${params.toString()}`)
|
||||
} finally {
|
||||
isBrowseLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadPlugin(file: File): Promise<PluginEntry> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const result = await api.upload<PluginEntry>('/plugins/upload', formData)
|
||||
await fetchPlugins()
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
plugins,
|
||||
searchResults,
|
||||
browseResults,
|
||||
isLoading,
|
||||
isBrowseLoading,
|
||||
fetchPlugins,
|
||||
installPlugin,
|
||||
uninstallPlugin,
|
||||
reloadPlugin,
|
||||
updatePluginConfig,
|
||||
searchPlugins,
|
||||
browseUmod,
|
||||
uploadPlugin,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface AuthResponse {
|
||||
refresh_token: string
|
||||
requires_totp: boolean
|
||||
user: User
|
||||
license: License | null
|
||||
}
|
||||
|
||||
export interface ServerConnection {
|
||||
|
||||
@@ -5,11 +5,13 @@ import * as echarts from 'echarts'
|
||||
import type { ECharts } from 'echarts'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { AnalyticsSummary, TimeseriesData } from '@/types'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
|
||||
const api = useApi()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const timeRange = ref<'24h' | '7d' | '30d'>('7d')
|
||||
const loading = ref(true)
|
||||
@@ -45,8 +47,8 @@ const loadAnalytics = async () => {
|
||||
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
} catch (error) {
|
||||
console.error('Failed to load analytics:', error)
|
||||
} catch {
|
||||
toast.error('Failed to load analytics data')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -206,8 +208,8 @@ const downloadCSV = async () => {
|
||||
a.download = `server_stats_${timeRange.value}.csv`
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('Failed to download CSV:', error)
|
||||
} catch {
|
||||
toast.error('Failed to download analytics export')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,12 +306,30 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player Retention (Phase 2.2 placeholder) -->
|
||||
<!-- Player Retention -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Player Retention</h2>
|
||||
<div class="h-48 flex items-center justify-center border border-dashed border-neutral-800 rounded-lg">
|
||||
<p class="text-sm text-neutral-600">Available in Phase 2.2 — New vs returning players, session duration</p>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Player Retention</h2>
|
||||
<span class="text-xs font-medium px-2 py-0.5 bg-neutral-800 text-neutral-500 rounded-full border border-neutral-700">Phase 2</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
||||
<p class="text-xs text-neutral-500 mb-1">New Players</p>
|
||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">First-time visitors</p>
|
||||
</div>
|
||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
||||
<p class="text-xs text-neutral-500 mb-1">Returning Players</p>
|
||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Seen more than once</p>
|
||||
</div>
|
||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
||||
<p class="text-xs text-neutral-500 mb-1">Avg Session Duration</p>
|
||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Per visit</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600 mt-4 text-center">Player retention analytics will be available in Phase 2</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { ChatMessage } from '@/types'
|
||||
import { MessageSquare, Search, Flag, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const isLoading = ref(false)
|
||||
@@ -53,7 +55,7 @@ async function fetchMessages() {
|
||||
const data = await api.get<{ messages: ChatMessage[] }>('/chat')
|
||||
messages.value = data.messages
|
||||
} catch {
|
||||
// API not wired yet
|
||||
toast.error('Failed to load chat messages')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -64,7 +66,7 @@ async function toggleFlag(msg: ChatMessage) {
|
||||
await api.put(`/chat/${msg.id}/flag`, { flagged: !msg.flagged })
|
||||
msg.flagged = !msg.flagged
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error('Failed to update flag')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
49
frontend/src/views/admin/FileManagerView.vue
Normal file
49
frontend/src/views/admin/FileManagerView.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { VueFinder, RemoteDriver } from 'vuefinder'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Recreate the RemoteDriver reactively so the token stays current across
|
||||
// automatic refresh cycles (useApi composable silently rotates accessToken).
|
||||
const driver = computed(
|
||||
() =>
|
||||
new RemoteDriver({
|
||||
baseURL: '/api/files',
|
||||
token: auth.accessToken ?? undefined,
|
||||
})
|
||||
)
|
||||
|
||||
// Non-persistent config passed to VueFinder per session.
|
||||
// maxFileSize in bytes — 10 MB limit matches the backend upload ceiling.
|
||||
const finderConfig = {
|
||||
theme: 'midnight',
|
||||
maxFileSize: 10 * 1024 * 1024,
|
||||
showMenuBar: true,
|
||||
showToolbar: true,
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">File Manager</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">Browse and edit your server files</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-neutral-900 rounded-lg border border-neutral-800 overflow-hidden"
|
||||
style="min-height: 640px;"
|
||||
>
|
||||
<VueFinder
|
||||
id="corrosion-filemanager"
|
||||
:driver="driver"
|
||||
:config="finderConfig"
|
||||
locale="en"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,7 +30,7 @@ async function fetchMaps() {
|
||||
const data = await api.get<{ maps: MapEntry[] }>('/maps')
|
||||
maps.value = data.maps
|
||||
} catch {
|
||||
// API not wired yet
|
||||
toast.error('Failed to load map library')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { NotificationConfig } from '@/types'
|
||||
import { Bell, Save, Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
|
||||
const config = ref<NotificationConfig>({
|
||||
discord_webhook_url: null,
|
||||
@@ -38,7 +40,7 @@ async function fetchConfig() {
|
||||
const data = await api.get<{ config: NotificationConfig }>('/notifications/config')
|
||||
config.value = data.config
|
||||
} catch {
|
||||
// API not wired yet
|
||||
toast.error('Failed to load notification settings')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -48,8 +50,9 @@ async function saveConfig() {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put('/notifications/config', config.value)
|
||||
toast.success('Notification settings saved')
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error('Failed to save notification settings')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { Users, Search, Ban, LogOut, Shield, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
const server = useServerStore()
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
|
||||
interface Player {
|
||||
steam_id: string
|
||||
@@ -70,7 +72,7 @@ async function fetchPlayers() {
|
||||
const data = await api.get<{ players: Player[] }>('/players')
|
||||
players.value = data.players
|
||||
} catch {
|
||||
// API not wired yet — will show empty state
|
||||
toast.error('Failed to load player list')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -80,9 +82,10 @@ async function kickPlayer(steamId: string, name: string) {
|
||||
if (!confirm(`Kick ${name}?`)) return
|
||||
try {
|
||||
await server.sendCommand(`kick ${steamId}`)
|
||||
toast.success(`Kick command sent for ${name}`)
|
||||
await fetchPlayers()
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error(`Failed to kick ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,9 +93,10 @@ async function banPlayer(steamId: string, name: string) {
|
||||
if (!confirm(`Ban ${name}? This will also kick them.`)) return
|
||||
try {
|
||||
await server.sendCommand(`ban ${steamId}`)
|
||||
toast.success(`Ban command sent for ${name}`)
|
||||
await fetchPlayers()
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error(`Failed to ban ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { usePluginStore } from '@/stores/plugins'
|
||||
import type { UmodPlugin } from '@/stores/plugins'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { PluginEntry } from '@/types'
|
||||
import { Puzzle, Search, Download, RefreshCw, Power, PowerOff, Trash2 } from 'lucide-vue-next'
|
||||
import { Puzzle, Search, Download, RefreshCw, Power, PowerOff, Trash2, Loader2, Upload, X } from 'lucide-vue-next'
|
||||
|
||||
const pluginStore = usePluginStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const tab = ref<'installed' | 'browse'>('installed')
|
||||
const tab = ref<'installed' | 'browse' | 'upload'>('installed')
|
||||
const browseQuery = ref('')
|
||||
const browseDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const installing = ref<string | null>(null)
|
||||
|
||||
// Upload state
|
||||
const uploadFile = ref<File | null>(null)
|
||||
const isDragOver = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const uploadInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const filteredPlugins = computed(() => {
|
||||
let result = pluginStore.plugins
|
||||
@@ -20,6 +30,8 @@ const filteredPlugins = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
const browsePlugins = computed(() => pluginStore.browseResults?.data ?? [])
|
||||
|
||||
const loadedCount = computed(() => pluginStore.plugins.filter((p: PluginEntry) => p.is_loaded).length)
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
@@ -59,6 +71,85 @@ async function handleUninstall(plugin: PluginEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBrowseSearch() {
|
||||
if (!browseQuery.value.trim()) return
|
||||
try {
|
||||
await pluginStore.browseUmod(browseQuery.value.trim())
|
||||
} catch {
|
||||
toast.error('Failed to search uMod plugins')
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBrowseSearch() {
|
||||
if (browseDebounce.value) clearTimeout(browseDebounce.value)
|
||||
browseDebounce.value = setTimeout(handleBrowseSearch, 400)
|
||||
}
|
||||
|
||||
async function installFromBrowse(result: UmodPlugin) {
|
||||
installing.value = result.name
|
||||
try {
|
||||
await pluginStore.installPlugin({ plugin_name: result.name, umod_slug: result.slug, source: 'umod' })
|
||||
toast.success(`${result.name} installed`)
|
||||
} catch {
|
||||
toast.error(`Failed to install ${result.name}`)
|
||||
} finally {
|
||||
installing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Upload helpers
|
||||
function isAlreadyInstalled(name: string): boolean {
|
||||
return pluginStore.plugins.some((p: PluginEntry) => p.plugin_name === name)
|
||||
}
|
||||
|
||||
function validateCsFile(file: File): string | null {
|
||||
if (!file.name.toLowerCase().endsWith('.cs')) {
|
||||
return 'Only .cs plugin files are accepted'
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return 'File must be under 5 MB'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function handleFilePick(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
const err = validateCsFile(file)
|
||||
if (err) { toast.error(err); return }
|
||||
uploadFile.value = file
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
isDragOver.value = false
|
||||
const file = event.dataTransfer?.files[0]
|
||||
if (!file) return
|
||||
const err = validateCsFile(file)
|
||||
if (err) { toast.error(err); return }
|
||||
uploadFile.value = file
|
||||
}
|
||||
|
||||
function clearUpload() {
|
||||
uploadFile.value = null
|
||||
if (uploadInput.value) uploadInput.value.value = ''
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!uploadFile.value) return
|
||||
isUploading.value = true
|
||||
try {
|
||||
await pluginStore.uploadPlugin(uploadFile.value)
|
||||
toast.success(`${uploadFile.value.name} uploaded successfully`)
|
||||
clearUpload()
|
||||
tab.value = 'installed'
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message || 'Upload failed')
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
pluginStore.fetchPlugins()
|
||||
})
|
||||
@@ -104,13 +195,31 @@ onMounted(() => {
|
||||
>
|
||||
Browse uMod
|
||||
</button>
|
||||
<button
|
||||
@click="tab = 'upload'"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="tab === 'upload' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
Upload Custom
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex-1 max-w-sm">
|
||||
<div v-if="tab === 'installed'" class="relative flex-1 max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="tab === 'installed' ? 'Search installed plugins...' : 'Search uMod...'"
|
||||
placeholder="Search installed plugins..."
|
||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="tab === 'browse'" class="relative flex-1 max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
v-model="browseQuery"
|
||||
type="text"
|
||||
placeholder="Search uMod plugins..."
|
||||
@input="scheduleBrowseSearch"
|
||||
@keydown.enter="handleBrowseSearch"
|
||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
@@ -187,11 +296,164 @@ onMounted(() => {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Browse uMod (placeholder) -->
|
||||
<div v-if="tab === 'browse'" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Download class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">uMod Plugin Browser</h3>
|
||||
<p class="text-sm text-neutral-500">Search and install plugins directly from uMod. Coming soon.</p>
|
||||
<!-- Browse uMod -->
|
||||
<div v-if="tab === 'browse'">
|
||||
<!-- Empty state: no search yet -->
|
||||
<div v-if="!browseQuery.trim() && browsePlugins.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Search class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">Search uMod</h3>
|
||||
<p class="text-sm text-neutral-500">Type a plugin name above to search the uMod plugin directory.</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="pluginStore.isBrowseLoading" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Loader2 class="w-8 h-8 text-neutral-500 animate-spin mx-auto mb-3" />
|
||||
<p class="text-sm text-neutral-500">Searching uMod...</p>
|
||||
</div>
|
||||
|
||||
<!-- No results -->
|
||||
<div v-else-if="browseQuery.trim() && browsePlugins.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Download class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">No plugins found</h3>
|
||||
<p class="text-sm text-neutral-500">No uMod plugins matched "{{ browseQuery }}". Try a different search term.</p>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<div v-if="pluginStore.browseResults" class="px-4 py-2 border-b border-neutral-800 flex items-center justify-between">
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ pluginStore.browseResults.total.toLocaleString() }} plugins found
|
||||
• Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
|
||||
</p>
|
||||
</div>
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left">
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Plugin</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Author</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Version</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Downloads</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr
|
||||
v-for="result in browsePlugins"
|
||||
:key="result.name"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-sm font-medium text-neutral-100">{{ result.title }}</p>
|
||||
<p v-if="result.description" class="text-xs text-neutral-500 mt-0.5 truncate max-w-xs">{{ result.description }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.author ?? '\u2014' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ result.latest_release_version_formatted ?? '\u2014' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.downloads_shortened ?? '\u2014' }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
@click="installFromBrowse(result)"
|
||||
:disabled="installing === result.name || isAlreadyInstalled(result.name)"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ml-auto"
|
||||
:class="isAlreadyInstalled(result.name)
|
||||
? 'bg-neutral-700 text-neutral-500 cursor-not-allowed'
|
||||
: 'bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white'"
|
||||
>
|
||||
<Loader2 v-if="installing === result.name" class="w-3.5 h-3.5 animate-spin" />
|
||||
<Download v-else class="w-3.5 h-3.5" />
|
||||
{{ isAlreadyInstalled(result.name) ? 'Installed' : installing === result.name ? 'Installing...' : 'Install' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Custom Plugin -->
|
||||
<div v-if="tab === 'upload'" class="space-y-4">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
|
||||
<h2 class="text-base font-semibold text-neutral-100 mb-1">Upload Custom Plugin</h2>
|
||||
<p class="text-sm text-neutral-500 mb-6">
|
||||
Upload .cs plugin files from Lone Wolf, Codefling, or your own code. Max 5 MB.
|
||||
The file will be pushed to your server via the companion agent.
|
||||
</p>
|
||||
|
||||
<!-- Drop zone -->
|
||||
<div
|
||||
class="relative border-2 border-dashed rounded-lg p-10 text-center transition-colors cursor-pointer"
|
||||
:class="isDragOver
|
||||
? 'border-oxide-500 bg-oxide-500/5'
|
||||
: uploadFile
|
||||
? 'border-green-600 bg-green-900/10'
|
||||
: 'border-neutral-700 hover:border-neutral-600'"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
@drop.prevent="handleDrop"
|
||||
@click="uploadInput?.click()"
|
||||
>
|
||||
<input
|
||||
ref="uploadInput"
|
||||
type="file"
|
||||
accept=".cs"
|
||||
class="hidden"
|
||||
@change="handleFilePick"
|
||||
/>
|
||||
|
||||
<!-- No file selected -->
|
||||
<template v-if="!uploadFile">
|
||||
<Upload class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
||||
<p class="text-sm font-medium text-neutral-300">Drop your .cs file here</p>
|
||||
<p class="text-xs text-neutral-500 mt-1">or click to browse</p>
|
||||
</template>
|
||||
|
||||
<!-- File selected -->
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<Puzzle class="w-8 h-8 text-green-400 flex-shrink-0" />
|
||||
<div class="text-left">
|
||||
<p class="text-sm font-medium text-neutral-100">{{ uploadFile.name }}</p>
|
||||
<p class="text-xs text-neutral-500 mt-0.5">{{ (uploadFile.size / 1024).toFixed(1) }} KB</p>
|
||||
</div>
|
||||
<button
|
||||
@click.stop="clearUpload"
|
||||
class="ml-2 p-1 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 mt-4">
|
||||
<button
|
||||
@click="handleUpload"
|
||||
:disabled="!uploadFile || isUploading"
|
||||
class="flex items-center gap-2 px-5 py-2 text-sm font-medium bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Loader2 v-if="isUploading" class="w-4 h-4 animate-spin" />
|
||||
<Upload v-else class="w-4 h-4" />
|
||||
{{ isUploading ? 'Uploading...' : 'Upload Plugin' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="uploadFile"
|
||||
@click="clearUpload"
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-400 hover:text-neutral-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info card -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||
<p class="text-xs text-neutral-500 leading-relaxed">
|
||||
<span class="font-medium text-neutral-400">Note:</span>
|
||||
The plugin will be registered in your plugin list immediately. Your companion agent must be connected
|
||||
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import {
|
||||
Server,
|
||||
Wifi,
|
||||
@@ -23,6 +24,7 @@ import { useWebSocket } from '@/composables/useWebSocket'
|
||||
|
||||
const server = useServerStore()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const editMode = ref(false)
|
||||
const saving = ref(false)
|
||||
@@ -73,8 +75,6 @@ chmod +x corrosion-companion-linux-amd64
|
||||
# Start with your license key
|
||||
export LICENSE_ID="${licenseKey.value}"
|
||||
export NATS_URL="nats://nats.corrosionmgmt.com:4222"
|
||||
export NATS_TOKEN="<your-nats-token>"
|
||||
export GAME_SERVER_PATH="/path/to/RustDedicated"
|
||||
./corrosion-companion-linux-amd64`)
|
||||
|
||||
const windowsCommands = computed(() => `# Requires PowerShell (not Command Prompt)
|
||||
@@ -84,8 +84,6 @@ Invoke-WebRequest -Uri "https://cdn.corrosionmgmt.com/companion/latest/corrosion
|
||||
# Start with your license key
|
||||
$env:LICENSE_ID="${licenseKey.value}"
|
||||
$env:NATS_URL="nats://nats.corrosionmgmt.com:4222"
|
||||
$env:NATS_TOKEN="<your-nats-token>"
|
||||
$env:GAME_SERVER_PATH="C:\\RustServer\\server\\RustDedicated.exe"
|
||||
.\\corrosion-companion-windows-amd64.exe`)
|
||||
|
||||
async function copySetupCommands() {
|
||||
@@ -111,7 +109,7 @@ async function startDeploy() {
|
||||
await server.deployServer(deployForm.value)
|
||||
showDeployForm.value = false
|
||||
} catch {
|
||||
// Error handled in store
|
||||
toast.error('Failed to start deployment')
|
||||
} finally {
|
||||
deployLoading.value = false
|
||||
}
|
||||
@@ -166,8 +164,9 @@ async function saveConfig() {
|
||||
try {
|
||||
await server.updateConfig(form.value)
|
||||
editMode.value = false
|
||||
toast.success('Server configuration saved')
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error('Failed to save server configuration')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -180,13 +179,25 @@ async function serverAction(action: 'start' | 'stop' | 'restart') {
|
||||
else if (action === 'stop') await server.stopServer()
|
||||
else await server.restartServer()
|
||||
await server.fetchServer()
|
||||
toast.success(`Server ${action} command sent`)
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error(`Failed to ${action} server`)
|
||||
} finally {
|
||||
actionLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAutomation(field: 'crash_recovery_enabled' | 'auto_update_on_force_wipe' | 'force_wipe_eligible') {
|
||||
if (!server.config) return
|
||||
const newValue = !server.config[field]
|
||||
try {
|
||||
await server.updateConfig({ [field]: newValue })
|
||||
toast.success('Automation setting saved')
|
||||
} catch {
|
||||
toast.error('Failed to save automation setting')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await server.fetchServer()
|
||||
loadFormFromConfig()
|
||||
@@ -351,30 +362,29 @@ onMounted(async () => {
|
||||
<Terminal class="w-3.5 h-3.5 text-neutral-500" />
|
||||
<p class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Quick Setup</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- OS Tabs -->
|
||||
<div class="flex bg-neutral-800 rounded-md p-0.5">
|
||||
<button
|
||||
@click="setupTab = 'linux'"
|
||||
class="px-3 py-1 text-xs font-medium rounded transition-colors"
|
||||
:class="setupTab === 'linux' ? 'bg-neutral-700 text-neutral-100' : 'text-neutral-500 hover:text-neutral-300'"
|
||||
>Linux</button>
|
||||
<button
|
||||
@click="setupTab = 'windows'"
|
||||
class="px-3 py-1 text-xs font-medium rounded transition-colors"
|
||||
:class="setupTab === 'windows' ? 'bg-neutral-700 text-neutral-100' : 'text-neutral-500 hover:text-neutral-300'"
|
||||
>Windows</button>
|
||||
</div>
|
||||
<button
|
||||
@click="copySetupCommands"
|
||||
class="flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-md transition-colors"
|
||||
:class="(setupTab === 'linux' ? copied : windowsCopied)
|
||||
? 'bg-green-600/20 text-green-400 border border-green-600/30'
|
||||
: 'bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-neutral-200 border border-neutral-700'"
|
||||
>
|
||||
{{ (setupTab === 'linux' ? copied : windowsCopied) ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="copySetupCommands"
|
||||
class="flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-md transition-colors"
|
||||
:class="(setupTab === 'linux' ? copied : windowsCopied)
|
||||
? 'bg-green-600/20 text-green-400 border border-green-600/30'
|
||||
: 'bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-neutral-200 border border-neutral-700'"
|
||||
>
|
||||
{{ (setupTab === 'linux' ? copied : windowsCopied) ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- OS Tabs -->
|
||||
<div class="flex bg-neutral-800 rounded-md p-0.5 mb-3 w-fit">
|
||||
<button
|
||||
@click="setupTab = 'linux'"
|
||||
class="px-3 py-1 text-xs font-medium rounded transition-colors"
|
||||
:class="setupTab === 'linux' ? 'bg-neutral-700 text-neutral-100' : 'text-neutral-500 hover:text-neutral-300'"
|
||||
>Linux</button>
|
||||
<button
|
||||
@click="setupTab = 'windows'"
|
||||
class="px-3 py-1 text-xs font-medium rounded transition-colors"
|
||||
:class="setupTab === 'windows' ? 'bg-neutral-700 text-neutral-100' : 'text-neutral-500 hover:text-neutral-300'"
|
||||
>Windows</button>
|
||||
</div>
|
||||
|
||||
<!-- Windows Warning Badge -->
|
||||
@@ -391,8 +401,6 @@ onMounted(async () => {
|
||||
<p class="mt-3 text-neutral-500"># Start with your license key</p>
|
||||
<p>export LICENSE_ID=<span class="text-oxide-400">"{{ licenseKey }}"</span></p>
|
||||
<p>export NATS_URL=<span class="text-oxide-400">"nats://nats.corrosionmgmt.com:4222"</span></p>
|
||||
<p>export NATS_TOKEN=<span class="text-neutral-500">"<your-nats-token>"</span></p>
|
||||
<p>export GAME_SERVER_PATH=<span class="text-neutral-500">"/path/to/RustDedicated"</span></p>
|
||||
<p>./corrosion-companion-linux-amd64</p>
|
||||
</div>
|
||||
|
||||
@@ -404,8 +412,6 @@ onMounted(async () => {
|
||||
<p class="mt-3 text-neutral-500"># Start with your license key</p>
|
||||
<p>$env:LICENSE_ID=<span class="text-oxide-400">"{{ licenseKey }}"</span></p>
|
||||
<p>$env:NATS_URL=<span class="text-oxide-400">"nats://nats.corrosionmgmt.com:4222"</span></p>
|
||||
<p>$env:NATS_TOKEN=<span class="text-neutral-500">"<your-nats-token>"</span></p>
|
||||
<p>$env:GAME_SERVER_PATH=<span class="text-neutral-500">"C:\RustServer\server\RustDedicated.exe"</span></p>
|
||||
<p>.\corrosion-companion-windows-amd64.exe</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -640,45 +646,51 @@ onMounted(async () => {
|
||||
<p class="text-sm text-neutral-200">Auto-Restart</p>
|
||||
<p class="text-xs text-neutral-500">Restart on crash detection</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer"
|
||||
<button
|
||||
@click="toggleAutomation('crash_recovery_enabled')"
|
||||
:disabled="!server.config"
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer disabled:opacity-40"
|
||||
:class="server.config?.crash_recovery_enabled ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
||||
:class="server.config?.crash_recovery_enabled ? 'translate-x-4.5' : 'translate-x-0.5'"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-neutral-200">Auto-Update on Force Wipe</p>
|
||||
<p class="text-xs text-neutral-500">Update when Facepunch pushes</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer"
|
||||
<button
|
||||
@click="toggleAutomation('auto_update_on_force_wipe')"
|
||||
:disabled="!server.config"
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer disabled:opacity-40"
|
||||
:class="server.config?.auto_update_on_force_wipe ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
||||
:class="server.config?.auto_update_on_force_wipe ? 'translate-x-4.5' : 'translate-x-0.5'"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-neutral-200">Force Wipe Eligible</p>
|
||||
<p class="text-xs text-neutral-500">Server participates in force wipes</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer"
|
||||
<button
|
||||
@click="toggleAutomation('force_wipe_eligible')"
|
||||
:disabled="!server.config"
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer disabled:opacity-40"
|
||||
:class="server.config?.force_wipe_eligible ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
||||
:class="server.config?.force_wipe_eligible ? 'translate-x-4.5' : 'translate-x-0.5'"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { TeamMember, Role } from '@/types'
|
||||
import { UserPlus, Shield, Mail, Trash2, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
|
||||
const members = ref<TeamMember[]>([])
|
||||
const roles = ref<Role[]>([])
|
||||
@@ -30,7 +32,7 @@ async function fetchTeam() {
|
||||
members.value = data.members ?? []
|
||||
roles.value = data.roles ?? []
|
||||
} catch {
|
||||
// API not wired yet
|
||||
toast.error('Failed to load team members')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -47,9 +49,10 @@ async function sendInvite() {
|
||||
inviteEmail.value = ''
|
||||
inviteRole.value = ''
|
||||
showInvite.value = false
|
||||
toast.success('Invitation sent')
|
||||
await fetchTeam()
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error('Failed to send invitation')
|
||||
} finally {
|
||||
inviting.value = false
|
||||
}
|
||||
@@ -59,9 +62,10 @@ async function removeMember(member: TeamMember) {
|
||||
if (!confirm(`Remove ${member.username} from the team?`)) return
|
||||
try {
|
||||
await api.del(`/team/${member.id}`)
|
||||
toast.success(`${member.username} removed from team`)
|
||||
await fetchTeam()
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error(`Failed to remove ${member.username}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,29 @@ import { ref, onMounted } from 'vue'
|
||||
import { useWipeStore } from '@/stores/wipe'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { RefreshCw, Zap, Clock, AlertTriangle, Loader2 } from 'lucide-vue-next'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { RefreshCw, Zap, Clock, AlertTriangle, Loader2, Check, X } from 'lucide-vue-next'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { safeDate } from '@/utils/formatters'
|
||||
|
||||
const wipeStore = useWipeStore()
|
||||
const server = useServerStore()
|
||||
const toast = useToastStore()
|
||||
const api = useApi()
|
||||
|
||||
const triggerType = ref<'map' | 'blueprint' | 'full'>('map')
|
||||
const selectedProfileId = ref<string>('')
|
||||
const triggerLoading = ref(false)
|
||||
const dryRunLoading = ref(false)
|
||||
const scheduleToggling = ref<string | null>(null)
|
||||
|
||||
interface DryRunResult {
|
||||
would_delete: string[]
|
||||
would_preserve: string[]
|
||||
estimated_duration_seconds: number
|
||||
}
|
||||
|
||||
const dryRunResult = ref<DryRunResult | null>(null)
|
||||
|
||||
async function triggerWipe() {
|
||||
if (!confirm(`Trigger a ${triggerType.value} wipe? This cannot be undone.`)) return
|
||||
@@ -30,8 +41,10 @@ async function triggerWipe() {
|
||||
|
||||
async function triggerDryRun() {
|
||||
dryRunLoading.value = true
|
||||
dryRunResult.value = null
|
||||
try {
|
||||
await wipeStore.triggerDryRun(triggerType.value, selectedProfileId.value)
|
||||
const result = await wipeStore.triggerDryRun(triggerType.value, selectedProfileId.value)
|
||||
dryRunResult.value = result
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to run dry-run')
|
||||
} finally {
|
||||
@@ -39,6 +52,19 @@ async function triggerDryRun() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSchedule(scheduleId: string, currentlyActive: boolean) {
|
||||
scheduleToggling.value = scheduleId
|
||||
try {
|
||||
await api.put(`/wipes/schedules/${scheduleId}`, { is_active: !currentlyActive })
|
||||
await wipeStore.fetchSchedules()
|
||||
toast.success(`Schedule ${currentlyActive ? 'paused' : 'activated'}`)
|
||||
} catch {
|
||||
toast.error('Failed to update schedule')
|
||||
} finally {
|
||||
scheduleToggling.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await wipeStore.fetchProfiles()
|
||||
if (wipeStore.profiles.length > 0 && wipeStore.profiles[0]) {
|
||||
@@ -135,6 +161,54 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dry-Run Results -->
|
||||
<div v-if="dryRunResult" class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Dry-Run Results</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-neutral-500">
|
||||
Estimated: {{ Math.round(dryRunResult.estimated_duration_seconds) }}s
|
||||
</span>
|
||||
<button
|
||||
@click="dryRunResult = null"
|
||||
class="p-1 text-neutral-500 hover:text-neutral-300 rounded transition-colors"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-red-400 mb-2 flex items-center gap-1.5">
|
||||
<X class="w-3.5 h-3.5" />
|
||||
Would Delete ({{ dryRunResult.would_delete.length }})
|
||||
</p>
|
||||
<div v-if="dryRunResult.would_delete.length === 0" class="text-xs text-neutral-600 italic">Nothing to delete</div>
|
||||
<ul v-else class="space-y-1">
|
||||
<li
|
||||
v-for="item in dryRunResult.would_delete"
|
||||
:key="item"
|
||||
class="text-xs font-mono text-neutral-400 bg-red-500/5 border border-red-500/10 rounded px-2 py-1"
|
||||
>{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-green-400 mb-2 flex items-center gap-1.5">
|
||||
<Check class="w-3.5 h-3.5" />
|
||||
Would Preserve ({{ dryRunResult.would_preserve.length }})
|
||||
</p>
|
||||
<div v-if="dryRunResult.would_preserve.length === 0" class="text-xs text-neutral-600 italic">Nothing preserved</div>
|
||||
<ul v-else class="space-y-1">
|
||||
<li
|
||||
v-for="item in dryRunResult.would_preserve"
|
||||
:key="item"
|
||||
class="text-xs font-mono text-neutral-400 bg-green-500/5 border border-green-500/10 rounded px-2 py-1"
|
||||
>{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming Schedules -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Scheduled Wipes</h2>
|
||||
@@ -156,12 +230,28 @@ onMounted(async () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="schedule.is_active ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
{{ schedule.is_active ? 'Active' : 'Paused' }}
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="schedule.is_active ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
{{ schedule.is_active ? 'Active' : 'Paused' }}
|
||||
</span>
|
||||
<button
|
||||
@click="toggleSchedule(schedule.id, schedule.is_active)"
|
||||
:disabled="scheduleToggling === schedule.id"
|
||||
class="w-9 h-5 rounded-full transition-colors disabled:opacity-40 cursor-pointer"
|
||||
:class="schedule.is_active ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
:title="schedule.is_active ? 'Pause schedule' : 'Activate schedule'"
|
||||
>
|
||||
<Loader2 v-if="scheduleToggling === schedule.id" class="w-3.5 h-3.5 text-white animate-spin mx-auto" />
|
||||
<div
|
||||
v-else
|
||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
||||
:class="schedule.is_active ? 'translate-x-4.5' : 'translate-x-0.5'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,9 @@ async function handleLogin() {
|
||||
}
|
||||
|
||||
authStore.setAuth(response)
|
||||
if (response.license) {
|
||||
authStore.setLicense(response.license)
|
||||
}
|
||||
router.push('/')
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
@@ -68,6 +71,9 @@ async function handleTotpVerify() {
|
||||
})
|
||||
|
||||
authStore.setAuth(response)
|
||||
if (response.license) {
|
||||
authStore.setLicense(response.license)
|
||||
}
|
||||
router.push('/')
|
||||
} catch (err: unknown) {
|
||||
totpCode.value = ''
|
||||
|
||||
@@ -57,6 +57,9 @@ async function handleRegister() {
|
||||
})
|
||||
|
||||
authStore.setAuth(response)
|
||||
if (response.license) {
|
||||
authStore.setLicense(response.license)
|
||||
}
|
||||
router.push('/setup')
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
|
||||
@@ -9,20 +9,20 @@ interface License {
|
||||
id: string
|
||||
license_key: string
|
||||
owner_email: string
|
||||
server_name: string
|
||||
server_name: string | null
|
||||
status: 'active' | 'suspended' | 'expired' | 'revoked'
|
||||
created_at: string
|
||||
expires_at: string
|
||||
expires_at: string | null
|
||||
}
|
||||
|
||||
interface LicenseDetail {
|
||||
id: string
|
||||
license_key: string
|
||||
owner_email: string
|
||||
server_name: string
|
||||
server_name: string | null
|
||||
status: string
|
||||
created_at: string
|
||||
expires_at: string
|
||||
expires_at: string | null
|
||||
team_count: number
|
||||
wipe_count: number
|
||||
server_connection: {
|
||||
@@ -67,8 +67,11 @@ const statusBadgeClass: Record<string, string> = {
|
||||
revoked: 'bg-red-500/10 text-red-400',
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime()) || d.getTime() === 0) return '—'
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
@@ -80,7 +83,7 @@ async function fetchLicenses() {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.value.toString(),
|
||||
per_page: perPage.toString(),
|
||||
limit: perPage.toString(),
|
||||
})
|
||||
if (searchQuery.value.trim()) params.set('search', searchQuery.value.trim())
|
||||
if (statusFilter.value !== 'all') params.set('status', statusFilter.value)
|
||||
|
||||
Reference in New Issue
Block a user