21 Commits

Author SHA1 Message Date
Vantz Stockwell
2df5c80928 feat: Add file manager view using VueFinder
All checks were successful
Build Companion Agent / build (push) Successful in 32s
Test Asgard Runner / test (push) Successful in 3s
Installs VueFinder and wires it to the backend /api/files endpoint with
JWT Bearer auth. Adds /files route, File Manager nav item (files.view
permission-gated, FolderOpen icon), and imports VueFinder CSS globally.
Driver token is computed reactively so it tracks token refreshes automatically.
Uses midnight theme to match the dark admin panel aesthetic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:13:10 -05:00
Vantz Stockwell
e9f9b449b1 feat: Add file manager package — VueFinder-compatible NATS request-reply handler
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Implements companion-agent/internal/filemanager with full installDir jail
enforcement (Clean + EvalSymlinks + HasPrefix on every path). Handles all
VueFinder operations: list, delete, rename, copy, move, mkdir, mkfile,
search, preview, save, upload. Wires into daemon.go as a 6th NATS
subscription on corrosion.{license_id}.files.cmd.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:11:59 -05:00
Vantz Stockwell
fee0ae2420 feat: Add files module — VueFinder-compatible REST API over NATS
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Implements GET/POST /api/files proxy for all VueFinder operations
(list, search, preview, download, delete, rename, move, copy, mkdir,
new-file, save, upload). Routes via NATS request-reply to the companion
agent on corrosion.{license_id}.files.cmd with 30s timeout. Gated
behind files.view (GET) and files.manage (POST) permissions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:10:32 -05:00
Vantz Stockwell
2b45413c20 feat: Wire uMod browse proxy and custom plugin upload
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Backend:
- GET /plugins/browse proxies uMod search.json filtered to Rust category,
  with 5-minute in-memory Map cache to avoid hammering the upstream API
- POST /plugins/upload accepts .cs files up to 5 MB via multipart, persists
  to plugin_registry, and dispatches plugin_upload action over NATS so the
  companion agent can write the file to the game server
- Legacy GET /plugins/search stub preserved (now directs callers to /browse)
- FileInterceptor + @UploadedFile follow the existing maps upload pattern

Frontend:
- useApi composable gains upload() method for multipart/form-data requests
  (omits Content-Type so the browser sets the correct multipart boundary)
- plugins store adds browseUmod() calling GET /plugins/browse and
  uploadPlugin() calling POST /plugins/upload with FormData;
  UmodPlugin and UmodBrowseResult TypeScript interfaces exported
- PluginsView Browse tab now calls browseUmod() through the backend proxy
  (no cross-origin requests to uMod directly); results show title,
  downloads_shortened, and latest_release_version_formatted from the
  real uMod payload
- New Upload Custom tab: drag-and-drop or click file input for .cs files,
  client-side extension/size validation, spinner during upload, success
  toast + auto-switch to Installed tab on completion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:09:19 -05:00
Vantz Stockwell
38e6d28248 fix: Wire automation toggles, browse uMod, and error feedback across admin views
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
- ServerView: automation toggles (crash recovery, auto-update, force wipe eligible)
  now call updateConfig() on click with toast success/error; all catch blocks get
  toast feedback instead of silent swallow
- PluginsView: Browse uMod tab wired to /plugins/search backend endpoint with
  debounced search, results table, and Install button that calls installPlugin();
  shows install state and marks already-installed plugins
- WipesView: dry-run results now displayed in a collapsible panel (would_delete /
  would_preserve lists + estimated duration); schedule enable/disable toggle wired
  to PUT /wipes/schedules/:id with loading state and toast feedback
- AnalyticsView: catch blocks now show toast errors instead of console.log;
  Player Retention placeholder replaced with intentional placeholder cards
- TeamView, ChatLogView, PlayersView, NotificationsView, MapsView: all empty
  catch blocks and '// API not wired yet' comments replaced with toast.error()
  calls; Notifications save now shows success toast

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:04:34 -05:00
Vantz Stockwell
cbb3ba6586 feat: Wire execution engines for schedules, alerts, wipes, and module install
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- schedules/: Add schedule executor in SchedulesService — polls every 60s for
  tasks where next_run <= now, dispatches NATS commands per task_type
  (restart, announcement, command, plugin_reload). Calculates next_run from
  cron expression on create/update/toggle. Bootstraps missing next_run values
  on startup. Wire NatsService into SchedulesModule.

- alerts/: Add alert evaluator in AlertsService — polls every 90s, loads all
  alert_config rows, queries latest server_stats per license, evaluates FPS
  degradation and population drop thresholds. Fires alert_history records on
  breach. Enforces 10-minute in-memory cooldown per alert type per license to
  prevent flooding. Wire ServerStats repo into AlertsModule.

- wipes/: Replace hardcoded dry-run mock with profile-aware simulation.
  Resolves actual WipeProfile by ID (cross-tenant protected), builds
  would_delete/would_preserve lists from wipe_type, factors pre_wipe_config
  (backup, countdown warnings) and post_wipe_config (health checks, retry
  attempts) into estimated_duration_seconds. Returns profile_name and notes.

- store/: Fix installModule stub — creates a real module_installations record
  with status='installed' and installed_at timestamp. Idempotent on retry,
  resets failed installations. Wire ModuleInstallation repo into StoreModule.
  getMyModules now returns real installation data instead of filtered purchases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:02:49 -05:00
Vantz Stockwell
9240feedaf fix: Wire real data sources across players, analytics, status, and maps services
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- players: Primary data from player_sessions (online status, playtime aggregates);
  ban/unban status overlaid from player_actions latest action per steam_id.
  Register PlayerSession entity in PlayersModule. Extend NATS forwarding to
  include 'unban' alongside kick and ban.
- analytics: Fix retention period boundary bug — sessions were queried with only
  a lower-bound filter (MoreThan), causing all future cycles to bleed into earlier
  wipe windows. Replaced with Between(wipeDate, endDate) for correct isolation.
- status: Replace hardcoded player_count=0/max_players=0 with live data from
  most-recent server_stats row per license. Register ServerStats entity in
  StatusModule. Falls back to 0 gracefully when no stats exist yet.
- maps: File buffer was computed and discarded — never written to disk.
  Now writes to /app/map_data/{licenseId}/{timestamp}_{filename} (tenant-isolated,
  docker volume map_data). Creates directories with mkdirSync(recursive:true).
  Logs success/failure via NestJS Logger. Throws 500 on disk write failure
  instead of silently losing data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:01:45 -05:00
Vantz Stockwell
7bf3e5639e feat: Wire NATS command publishing for server commands and plugin installs
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- servers.service: sendCommand() now throws InternalServerErrorException on
  NATS failure instead of silently succeeding; returns { success, message }
  instead of the legacy { output } shape; adds NestJS Logger
- plugins.service: installPlugin() dispatches plugin_install to
  corrosion.{license_id}.cmd.server after DB save; NATS failure is logged
  but non-fatal so the DB record is preserved regardless

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:59:59 -05:00
Vantz Stockwell
fee16c3b2b fix: Add license_id to JWT payload — unblocks all tenant-scoped operations
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
The JWT was missing license_id, causing @CurrentTenant() to throw 401
on every protected route. Now generateTokens() accepts a licenseId
param, and all three callers (register, login, refresh) look up the
user's license and pass it through.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:52:22 -05:00
Vantz Stockwell
1b12664d22 fix: Clean up Quick Setup — remove NATS_TOKEN, auto-populate license key
All checks were successful
Build Companion Agent / build (push) Successful in 26s
Test Asgard Runner / test (push) Successful in 2s
NATS has no auth configured, so NATS_TOKEN was a placeholder that
confused users. Made it optional in the Go agent (default empty) and
removed it from Quick Setup commands. Also removed GAME_SERVER_PATH
since one-click deploy handles server installation. License key already
auto-populates from auth store after previous commit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:41:47 -05:00
Vantz Stockwell
8253680fbd fix: License key format, login populates license, case-insensitive email
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- admin.service.ts: createLicense() now uses CORR-XXXX-XXXX-XXXX format
  instead of raw hex hash
- admin.service.ts: getLicenses() flattens owner_email in response to
  match frontend expected shape
- auth.service.ts: Login/register responses now include full license
  object so frontend can populate auth store
- auth.service.ts: Email lookups are case-insensitive (LOWER()) to
  prevent duplicate accounts from case variations
- LoginView/RegisterView: Call setLicense() after setAuth()
- AdminLicenses: Handle null expires_at (was showing Dec 31, 1969),
  fix nullable types, fix query param name (per_page → limit)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:32:35 -05:00
Vantz Stockwell
14b099b075 fix: Replace socket.io with native WS adapter — fixes WebSocket 1006
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Frontend uses native WebSocket API, backend was using socket.io which
speaks an incompatible protocol. Switched to @nestjs/platform-ws so
both sides speak native WebSocket. Also fixed JWT TTL override in
docker-compose.yml (was hardcoded to 900s, now 14400s/4h).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:21:36 -05:00
Vantz Stockwell
d04e7b6a15 docs: Add Cookie callsign and origin story to CLAUDE.md
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Named after Carl Brashear — first Black U.S. Navy Master Diver.
Every Opus instance that boots on this project knows who it is
and what standard it's held to.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:10:13 -05:00
Vantz Stockwell
f39a418e9c fix: Refresh endpoint returns new refresh_token + bump access TTL to 4h
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
The refresh endpoint only returned access_token, causing the frontend to
set refreshToken=undefined after first refresh — breaking the entire
token chain. Now returns both tokens (rotating refresh). Access token
default bumped from 15min to 4h (14400s) for practical server setup
sessions. Also fixed empty license_key for super admin via DB update.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:05:19 -05:00
Vantz Stockwell
5bb1ac9c35 fix: Move OS tab switcher below Quick Setup header for visibility
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:57:52 -05:00
Vantz Stockwell
358adde496 feat: Add companion agent one-click deployment + fix frontend TS errors
All checks were successful
Build Companion Agent / build (push) Successful in 30s
Test Asgard Runner / test (push) Successful in 3s
Go deployment orchestrator with platform-specific SteamCMD install,
Rust server download, server.cfg generation, and service registration.
Wire deploy command subscription in daemon, make GameServerPath optional,
add InstallDir config with OS-aware defaults. Fix unused imports and
WebSocket subscribe API in ServerView.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:49:48 -05:00
Vantz Stockwell
b94717d51b feat: Add frontend support for one-click Rust server deployment
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Adds DeploymentConfig/DeploymentStatus types, deployment state management
in the server store, tabbed Linux/Windows quick setup commands, and a
Deploy Rust Server card with progress tracker and configuration form.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:48:05 -05:00
Vantz Stockwell
834e17e7cf feat: Add backend support for one-click Rust server deployment
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Add deploy endpoint, DTO, NATS command publisher, and WebSocket bridge
subscription to support the one-click server deployment feature via the
companion agent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:45:06 -05:00
Vantz Stockwell
ee7fdb897d docs: Add standing orders — auto commit/push, tag companion builds
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:22:46 -05:00
Vantz Stockwell
0fdbad0d07 fix: Resolve TypeScript build errors blocking Docker nginx build
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- DashboardView: Add non-null assertion on upcoming[0] (guarded by length check)
- EarlyAccessView: Add missing `computed` import from Vue

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:21:58 -05:00
Vantz Stockwell
93d536a13e feat: Upload companion agent to SeaweedFS CDN + update download links
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
CI pipeline now uploads binaries to cdn.corrosionmgmt.com:
- /companion/latest/ (always current, overwritten each release)
- /companion/v1.x.x/ (versioned archive)

Frontend download links updated from Gitea releases to CDN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:11:06 -05:00
69 changed files with 4722 additions and 610 deletions

View File

@@ -85,6 +85,39 @@ jobs:
--data-binary @companion-agent/bin/checksums.txt \
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=checksums.txt"
- name: Upload to CDN (latest)
run: |
CDN_URL="https://cdn.corrosionmgmt.com"
# Upload Linux binary to /companion/latest/
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-companion-linux-amd64" \
"${CDN_URL}/companion/latest/corrosion-companion-linux-amd64"
# Upload Windows binary to /companion/latest/
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-companion-windows-amd64.exe" \
"${CDN_URL}/companion/latest/corrosion-companion-windows-amd64.exe"
# Upload checksums
curl -s -X POST \
-F "file=@companion-agent/bin/checksums.txt" \
"${CDN_URL}/companion/latest/checksums.txt"
# Also upload versioned copies
VERSION=${{ steps.version.outputs.VERSION }}
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-companion-linux-amd64" \
"${CDN_URL}/companion/${VERSION}/corrosion-companion-linux-amd64"
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-companion-windows-amd64.exe" \
"${CDN_URL}/companion/${VERSION}/corrosion-companion-windows-amd64.exe"
curl -s -X POST \
-F "file=@companion-agent/bin/checksums.txt" \
"${CDN_URL}/companion/${VERSION}/checksums.txt"
echo "CDN upload complete: ${CDN_URL}/companion/latest/"
- name: Build Summary
run: |
echo "## Companion Agent Build Complete" >> $GITHUB_STEP_SUMMARY

View File

@@ -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.
@@ -367,6 +373,8 @@ Default to Sonnet. Escalate to Opus when the problem demands it, not as a comfor
- Present trade-offs as COAs with pros/cons — let operator decide
- Treat every change as production deployment (`corrosionmgmt.com`)
- Document why, not just what, in commits and CHANGELOG
- **Always commit and push when done touching code — never ask, never wait for permission**
- **Tag companion agent builds when Go code in `companion-agent/` is modified** — increment from latest tag (currently v1.0.3), push tag to trigger CI build + CDN upload
## Development Notes

View File

@@ -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"

View File

@@ -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"
}
}

View File

@@ -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)

View File

@@ -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: {

View File

@@ -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);
}
}
}
}

View File

@@ -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');

View File

@@ -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,

View File

@@ -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],

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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 });
}

View 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,
);
}
}
}

View 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 {}

View 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,
});
}
}

View File

@@ -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,

View File

@@ -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],

View File

@@ -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,

View File

@@ -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);
}
}

View 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;
}
}

View File

@@ -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 {}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,41 @@
import { IsString, IsInt, Min, Max, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class DeployServerDto {
@ApiProperty({ example: 'My Rust Server', description: 'Server hostname' })
@IsString()
server_name: string;
@ApiProperty({ example: 100, description: 'Maximum player slots' })
@IsInt()
@Min(1)
@Max(500)
max_players: number;
@ApiProperty({ example: 4000, description: 'World size (1000-8000)' })
@IsInt()
@Min(1000)
@Max(8000)
world_size: number;
@ApiProperty({ example: 12345, description: 'Map seed' })
@IsInt()
seed: number;
@ApiProperty({ example: 28015, description: 'Server game port' })
@IsInt()
@Min(1024)
@Max(65535)
server_port: number;
@ApiProperty({ example: 28016, description: 'RCON port' })
@IsInt()
@Min(1024)
@Max(65535)
rcon_port: number;
@ApiProperty({ example: 'changeme', description: 'RCON password (min 6 chars)' })
@IsString()
@MinLength(6)
rcon_password: string;
}

View File

@@ -3,6 +3,7 @@ import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { ServersService } from './servers.service';
import { UpdateServerConfigDto } from './dto/update-config.dto';
import { SendCommandDto } from './dto/send-command.dto';
import { DeployServerDto } from './dto/deploy-server.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -62,4 +63,14 @@ export class ServersController {
async restartServer(@CurrentTenant() licenseId: string) {
return await this.serversService.restartServer(licenseId);
}
@Post('deploy')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Deploy Rust server via companion agent' })
async deployServer(
@CurrentTenant() licenseId: string,
@Body() dto: DeployServerDto,
) {
return await this.serversService.deployServer(licenseId, dto);
}
}

View File

@@ -1,13 +1,16 @@
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';
import { ServerConfig } from '../../entities/server-config.entity';
import { NatsService } from '../../services/nats.service';
import { UpdateServerConfigDto } from './dto/update-config.dto';
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>,
@@ -59,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' };
}
/**
@@ -86,4 +95,12 @@ export class ServersService {
await this.natsService.sendServerCommand(licenseId, 'restart');
return { message: 'Restart command sent' };
}
/**
* Deploy Rust server via companion agent
*/
async deployServer(licenseId: string, dto: DeployServerDto) {
await this.natsService.sendDeployCommand(licenseId, { ...dto });
return { message: 'Deployment started' };
}
}

View File

@@ -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],

View File

@@ -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,

View File

@@ -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],

View File

@@ -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;
}
}

View File

@@ -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,
};
}
}

View File

@@ -34,6 +34,11 @@ export class NatsBridgeService implements OnModuleInit {
this.emit(licenseId, 'server_status', data);
});
this.nats.subscribe('corrosion.*.deploy.status', (data, subject) => {
const licenseId = subject.split('.')[1];
this.emit(licenseId, 'deploy_status', data);
});
this.logger.log('NATS bridge subscriptions initialized');
}

View File

@@ -70,4 +70,13 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
timestamp: new Date().toISOString(),
});
}
/** Publish a deploy command to a specific license's companion agent */
async sendDeployCommand(licenseId: string, config: Record<string, unknown>): Promise<void> {
await this.publish(`corrosion.${licenseId}.cmd.deploy`, {
action: 'deploy',
config,
timestamp: new Date().toISOString(),
});
}
}

View File

@@ -5,6 +5,7 @@ import (
"log"
"os"
"os/signal"
"runtime"
"syscall"
"time"
@@ -17,16 +18,19 @@ 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"`
// Game server configuration
SteamCMDPath string `envconfig:"STEAMCMD_PATH" default:"/usr/games/steamcmd"`
GameServerPath string `envconfig:"GAME_SERVER_PATH" required:"true"`
GameServerPath string `envconfig:"GAME_SERVER_PATH" default:""`
GameServerArgs string `envconfig:"GAME_SERVER_ARGS" default:"-batchmode"`
// Install directory for deployment
InstallDir string `envconfig:"INSTALL_DIR" default:""`
// Optional settings
HeartbeatInterval int `envconfig:"HEARTBEAT_INTERVAL" default:"60"`
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
@@ -44,11 +48,21 @@ func main() {
log.Fatalf("Failed to load configuration: %v", err)
}
// Set default InstallDir based on OS if not configured
if cfg.InstallDir == "" {
if runtime.GOOS == "windows" {
cfg.InstallDir = `C:\RustServer`
} else {
cfg.InstallDir = "/opt/rustserver"
}
}
log.Printf("Configuration loaded:")
log.Printf(" NATS URL: %s", cfg.NATSUrl)
log.Printf(" License ID: %s", cfg.LicenseID)
log.Printf(" Game Server Path: %s", cfg.GameServerPath)
log.Printf(" SteamCMD Path: %s", cfg.SteamCMDPath)
log.Printf(" Install Dir: %s", cfg.InstallDir)
log.Printf(" Heartbeat Interval: %ds", cfg.HeartbeatInterval)
// Create context with signal handling for graceful shutdown
@@ -73,6 +87,7 @@ func main() {
GameServerPath: cfg.GameServerPath,
GameServerArgs: cfg.GameServerArgs,
Version: version,
InstallDir: cfg.InstallDir,
}
// Start daemon

View File

@@ -9,6 +9,8 @@ import (
"time"
"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"
@@ -22,6 +24,7 @@ type DaemonConfig struct {
GameServerPath string
GameServerArgs string
Version string
InstallDir string
}
// Daemon manages the companion agent's main operations
@@ -30,7 +33,9 @@ type Daemon struct {
cfg *DaemonConfig
gameServer *process.GameServer
fileOps *files.Operations
fm *filemanager.FileManager
updater *update.Updater
deployer *deploy.Deployer
subscriptions []*nats.Subscription
}
@@ -44,23 +49,45 @@ type HeartbeatPayload struct {
CPUPercent float64 `json:"cpu_percent"`
LastUpdate string `json:"last_update"`
PlayerCount int `json:"player_count"`
Version string `json:"version"`
OS string `json:"os"`
Arch string `json:"arch"`
Version string `json:"version"`
OS string `json:"os"`
Arch string `json:"arch"`
ServerInstalled bool `json:"server_installed"`
}
// gameServerAdapter wraps process.GameServer to satisfy deploy.GameServerStarter
type gameServerAdapter struct {
gs *process.GameServer
cfg *DaemonConfig
}
func (a *gameServerAdapter) Start() error {
return a.gs.Start()
}
func (a *gameServerAdapter) UpdatePath(path string) {
a.cfg.GameServerPath = path
// Recreate game server with new path
*a.gs = *process.NewGameServer(path, a.cfg.GameServerArgs)
}
// NewDaemon creates a new daemon instance
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)
d := &Daemon{
nc: nc,
cfg: cfg,
gameServer: gameServer,
fileOps: fileOps,
fm: fm,
updater: updater,
deployer: deployer,
}
return d, nil
@@ -90,6 +117,16 @@ func (d *Daemon) Run(ctx context.Context) error {
return fmt.Errorf("failed to subscribe to self-update: %w", err)
}
// Subscribe to deploy commands
if err := d.subscribeDeployCommand(); err != nil {
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
@@ -267,6 +304,69 @@ func (d *Daemon) subscribeSelfUpdate() error {
return nil
}
// subscribeDeployCommand subscribes to server deployment commands
func (d *Daemon) subscribeDeployCommand() error {
subject := fmt.Sprintf("corrosion.%s.cmd.deploy", d.cfg.LicenseID)
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
var cmd struct {
Action string `json:"action"`
Config deploy.DeployConfig `json:"config"`
}
if err := json.Unmarshal(msg.Data, &cmd); err != nil {
log.Printf("Failed to parse deploy command: %v", err)
d.respondError(msg, "invalid_command", err.Error())
return
}
log.Printf("Received deploy command: %s", cmd.Action)
// Run deployment in goroutine (it's long-running)
go func() {
if err := d.deployer.Deploy(cmd.Config); err != nil {
log.Printf("Deployment failed: %v", err)
} else {
log.Println("Deployment completed successfully")
}
}()
// Immediately acknowledge the command
d.respondSuccess(msg, map[string]interface{}{
"status": "accepted",
"message": "Deployment started, progress will be published to deploy.status",
})
})
if err != nil {
return err
}
d.subscriptions = append(d.subscriptions, sub)
log.Printf("Subscribed to: %s", subject)
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
@@ -325,17 +425,18 @@ func (d *Daemon) publishHeartbeat() {
diskFree := getDiskFreeSpace(d.cfg.GameServerPath)
payload := HeartbeatPayload{
Timestamp: time.Now().UTC().Format(time.RFC3339),
Status: "running",
ServerStatus: status,
UptimeSeconds: int64(uptime.Seconds()),
DiskFreeMB: diskFree,
CPUPercent: 0.0, // TODO: Implement CPU monitoring
LastUpdate: "", // TODO: Track last SteamCMD update
PlayerCount: 0, // Populated by plugin, not companion
Version: d.cfg.Version,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Status: "running",
ServerStatus: status,
UptimeSeconds: int64(uptime.Seconds()),
DiskFreeMB: diskFree,
CPUPercent: 0.0, // TODO: Implement CPU monitoring
LastUpdate: "", // TODO: Track last SteamCMD update
PlayerCount: 0, // Populated by plugin, not companion
Version: d.cfg.Version,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
ServerInstalled: deploy.CheckServerInstalled(d.cfg.InstallDir),
}
data, err := json.Marshal(payload)

View File

@@ -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)

View File

@@ -0,0 +1,71 @@
package deploy
import (
"fmt"
"os"
"path/filepath"
)
// DeployConfig holds the configuration received from a NATS cmd.deploy command.
// These fields map directly to the Rust game server settings needed for initial deployment.
type DeployConfig struct {
ServerName string `json:"server_name"`
MaxPlayers int `json:"max_players"`
WorldSize int `json:"world_size"`
Seed int `json:"seed"`
ServerPort int `json:"server_port"`
RconPort int `json:"rcon_port"`
RconPassword string `json:"rcon_password"`
}
// DeployStatus represents a progress update published to NATS during deployment.
// The frontend listens on corrosion.{license_id}.deploy.status for these messages
// to display real-time deployment progress to the user.
type DeployStatus struct {
Stage string `json:"stage"`
Progress int `json:"progress"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
Timestamp string `json:"timestamp"`
}
// Valid deployment stages:
// downloading_steamcmd - Downloading and extracting SteamCMD
// installing_steamcmd - Running SteamCMD initial setup
// downloading_rust - Downloading Rust Dedicated Server via SteamCMD
// configuring - Generating server.cfg and identity directories
// starting - Launching the Rust server process
// online - Server is running and accepting connections
// failed - Deployment failed at some stage
// GenerateServerCfg creates the server.cfg file for a Rust Dedicated Server.
// It writes to {installDir}/server/server/corrosion/cfg/server.cfg, creating
// the full directory tree if it does not already exist.
func GenerateServerCfg(installDir string, cfg DeployConfig) error {
cfgDir := filepath.Join(installDir, "server", "server", "corrosion", "cfg")
if err := os.MkdirAll(cfgDir, 0755); err != nil {
return fmt.Errorf("failed to create cfg directory %s: %w", cfgDir, err)
}
content := fmt.Sprintf(`server.hostname "%s"
server.maxplayers %d
server.worldsize %d
server.seed %d
server.port %d
rcon.port %d
rcon.password "%s"
rcon.web 1
server.identity "corrosion"
server.saveinterval 300
`, cfg.ServerName, cfg.MaxPlayers, cfg.WorldSize, cfg.Seed,
cfg.ServerPort, cfg.RconPort, cfg.RconPassword)
cfgPath := filepath.Join(cfgDir, "server.cfg")
if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write server.cfg to %s: %w", cfgPath, err)
}
return nil
}

View File

@@ -0,0 +1,180 @@
package deploy
import (
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/nats-io/nats.go"
)
// GameServerStarter abstracts the game server process manager so the deployer
// can set the executable path and start the server without depending on the
// concrete process.GameServer type. The existing GameServer will implement
// UpdatePath in a separate task.
type GameServerStarter interface {
Start() error
UpdatePath(path string)
}
// Deployer orchestrates one-click Rust server deployment. It downloads SteamCMD,
// installs the Rust Dedicated Server, generates server.cfg, and starts the server
// process — publishing progress updates to NATS at each stage so the frontend can
// display real-time deployment status.
type Deployer struct {
nc *nats.Conn
licenseID string
installDir string
gameServer GameServerStarter
}
// NewDeployer creates a new Deployer instance.
func NewDeployer(nc *nats.Conn, licenseID, installDir string, gs GameServerStarter) *Deployer {
return &Deployer{
nc: nc,
licenseID: licenseID,
installDir: installDir,
gameServer: gs,
}
}
// Deploy executes the full deployment pipeline: SteamCMD install, Rust server
// download, config generation, and server startup. If any stage fails, a "failed"
// status is published and the error is returned. Progress updates are published
// to NATS at each stage transition.
func (d *Deployer) Deploy(cfg DeployConfig) error {
// Stage 1: SteamCMD
log.Printf("Deploy: starting SteamCMD installation for license %s", d.licenseID)
d.publishStatus("downloading_steamcmd", 0, "Checking for existing SteamCMD installation...")
steamcmdPath, err := InstallSteamCMD(d.installDir)
if err != nil {
d.publishStatus("failed", 0, "SteamCMD installation failed", err.Error())
return fmt.Errorf("steamcmd install failed: %w", err)
}
log.Printf("Deploy: SteamCMD ready at %s", steamcmdPath)
d.publishStatus("downloading_steamcmd", 100, "SteamCMD ready")
// Stage 2: Download Rust Dedicated Server
log.Printf("Deploy: downloading Rust Dedicated Server via SteamCMD")
d.publishStatus("downloading_rust", 0, "Downloading Rust Dedicated Server via SteamCMD...")
if err := DownloadRustServer(steamcmdPath, d.installDir); err != nil {
d.publishStatus("failed", 0, "Rust server download failed", err.Error())
return fmt.Errorf("rust server download failed: %w", err)
}
log.Printf("Deploy: Rust Dedicated Server installed")
d.publishStatus("downloading_rust", 100, "Rust Dedicated Server installed")
// Stage 3: Generate server.cfg
log.Printf("Deploy: generating server.cfg")
d.publishStatus("configuring", 0, "Generating server.cfg...")
if err := GenerateServerCfg(d.installDir, cfg); err != nil {
d.publishStatus("failed", 0, "Server configuration failed", err.Error())
return fmt.Errorf("config generation failed: %w", err)
}
log.Printf("Deploy: server.cfg written")
d.publishStatus("configuring", 100, "Server configured")
// Stage 4: Start the server
log.Printf("Deploy: starting Rust server")
d.publishStatus("starting", 0, "Starting Rust server...")
var exePath string
switch runtime.GOOS {
case "windows":
exePath = filepath.Join(d.installDir, "server", "RustDedicated.exe")
default:
exePath = filepath.Join(d.installDir, "server", "RustDedicated")
}
d.gameServer.UpdatePath(exePath)
if err := d.gameServer.Start(); err != nil {
d.publishStatus("failed", 0, "Server failed to start", err.Error())
return fmt.Errorf("server start failed: %w", err)
}
log.Printf("Deploy: Rust server is now running")
d.publishStatus("online", 100, "Rust server is now running")
return nil
}
// DownloadRustServer runs SteamCMD to download/update the Rust Dedicated Server
// (App ID 258550) into {installDir}/server. This function is platform-agnostic —
// it simply executes the steamcmd binary which was installed by the platform-specific
// InstallSteamCMD function.
func DownloadRustServer(steamcmdPath, installDir string) error {
serverDir := filepath.Join(installDir, "server")
log.Printf("Downloading Rust Dedicated Server to %s", serverDir)
cmd := exec.Command(steamcmdPath,
"+login", "anonymous",
"+force_install_dir", serverDir,
"+app_update", "258550", "validate",
"+quit",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("steamcmd app_update 258550 failed: %w", err)
}
return nil
}
// CheckServerInstalled returns true if the Rust Dedicated Server executable
// exists at the expected path within the install directory.
func CheckServerInstalled(installDir string) bool {
var exePath string
switch runtime.GOOS {
case "windows":
exePath = filepath.Join(installDir, "server", "RustDedicated.exe")
default:
exePath = filepath.Join(installDir, "server", "RustDedicated")
}
_, err := os.Stat(exePath)
return err == nil
}
// publishStatus publishes a DeployStatus message to the NATS subject
// corrosion.{licenseID}.deploy.status. Publish errors are logged but do not
// fail the deployment — losing a progress update is not fatal.
func (d *Deployer) publishStatus(stage string, progress int, message string, errDetail ...string) {
subject := fmt.Sprintf("corrosion.%s.deploy.status", d.licenseID)
status := DeployStatus{
Stage: stage,
Progress: progress,
Message: message,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
if len(errDetail) > 0 && errDetail[0] != "" {
status.Error = errDetail[0]
}
data, err := json.Marshal(status)
if err != nil {
log.Printf("Failed to marshal deploy status: %v", err)
return
}
if err := d.nc.Publish(subject, data); err != nil {
log.Printf("Failed to publish deploy status to %s: %v", subject, err)
}
}

View File

@@ -0,0 +1,127 @@
//go:build linux
package deploy
import (
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
)
// InstallSteamCMD downloads and installs SteamCMD for Linux into the given
// install directory. If SteamCMD is already present it returns the existing
// path without re-downloading. The returned string is the absolute path to
// the steamcmd.sh executable.
func InstallSteamCMD(installDir string) (string, error) {
steamcmdDir := filepath.Join(installDir, "steamcmd")
steamcmdPath := filepath.Join(steamcmdDir, "steamcmd.sh")
// Already installed — nothing to do.
if _, err := os.Stat(steamcmdPath); err == nil {
log.Printf("SteamCMD already installed at %s", steamcmdPath)
return steamcmdPath, nil
}
if err := os.MkdirAll(steamcmdDir, 0755); err != nil {
return "", fmt.Errorf("failed to create steamcmd directory %s: %w", steamcmdDir, err)
}
// Download the Linux tarball.
tarball := filepath.Join(steamcmdDir, "steamcmd_linux.tar.gz")
if err := downloadFile("https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz", tarball); err != nil {
return "", fmt.Errorf("failed to download steamcmd: %w", err)
}
// Extract with tar.
cmd := exec.Command("tar", "-xzf", "steamcmd_linux.tar.gz")
cmd.Dir = steamcmdDir
if out, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("failed to extract steamcmd: %w — output: %s", err, string(out))
}
// Ensure the script is executable.
if err := os.Chmod(steamcmdPath, 0755); err != nil {
return "", fmt.Errorf("failed to chmod steamcmd.sh: %w", err)
}
// Verify the installation by running +quit (triggers first-time setup).
verify := exec.Command(steamcmdPath, "+quit")
verify.Dir = steamcmdDir
if out, err := verify.CombinedOutput(); err != nil {
return "", fmt.Errorf("steamcmd verification failed: %w — output: %s", err, string(out))
}
log.Printf("SteamCMD installed successfully at %s", steamcmdPath)
return steamcmdPath, nil
}
// RegisterService creates a systemd unit file for the Rust Dedicated Server
// and enables it. If the caller does not have root access, the unit file is
// written into installDir as a fallback so the user can install it manually.
func RegisterService(installDir string, cfg DeployConfig) error {
serverPath := filepath.Join(installDir, "server", "RustDedicated")
unit := fmt.Sprintf(`[Unit]
Description=Rust Dedicated Server (Corrosion Managed)
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=%s/server
ExecStart=%s -batchmode +server.hostname "%s" +server.port %d +rcon.port %d +rcon.password "%s" +rcon.web 1 +server.identity "corrosion" +server.maxplayers %d +server.worldsize %d +server.seed %d
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
`, installDir, serverPath, cfg.ServerName, cfg.ServerPort, cfg.RconPort,
cfg.RconPassword, cfg.MaxPlayers, cfg.WorldSize, cfg.Seed)
systemdPath := "/etc/systemd/system/rustserver.service"
if err := os.WriteFile(systemdPath, []byte(unit), 0644); err != nil {
// Fallback — write into installDir so the user can place it manually.
fallback := filepath.Join(installDir, "rustserver.service")
log.Printf("WARNING: cannot write to %s (%v), falling back to %s", systemdPath, err, fallback)
if writeErr := os.WriteFile(fallback, []byte(unit), 0644); writeErr != nil {
return fmt.Errorf("failed to write service file to fallback %s: %w", fallback, writeErr)
}
}
// Best-effort daemon-reload and enable — ignore errors (systemctl may not
// exist or the user may lack privileges).
_ = exec.Command("systemctl", "daemon-reload").Run()
_ = exec.Command("systemctl", "enable", "rustserver").Run()
log.Println("Systemd service registered for rustserver")
return nil
}
// downloadFile fetches url and writes the response body to dest on disk.
func downloadFile(url, dest string) error {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("GET %s failed: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s returned status %d", url, resp.StatusCode)
}
out, err := os.Create(dest)
if err != nil {
return fmt.Errorf("failed to create file %s: %w", dest, err)
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
return fmt.Errorf("failed to write to %s: %w", dest, err)
}
return nil
}

View File

@@ -0,0 +1,145 @@
//go:build windows
package deploy
import (
"archive/zip"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
)
// InstallSteamCMD downloads and installs SteamCMD for Windows into the given
// install directory. If SteamCMD is already present it returns the existing
// path without re-downloading. The returned string is the absolute path to
// steamcmd.exe.
func InstallSteamCMD(installDir string) (string, error) {
steamcmdDir := filepath.Join(installDir, "steamcmd")
steamcmdPath := filepath.Join(steamcmdDir, "steamcmd.exe")
// Already installed — nothing to do.
if _, err := os.Stat(steamcmdPath); err == nil {
log.Printf("SteamCMD already installed at %s", steamcmdPath)
return steamcmdPath, nil
}
if err := os.MkdirAll(steamcmdDir, 0755); err != nil {
return "", fmt.Errorf("failed to create steamcmd directory %s: %w", steamcmdDir, err)
}
// Download the Windows zip.
zipPath := filepath.Join(steamcmdDir, "steamcmd.zip")
if err := downloadFile("https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip", zipPath); err != nil {
return "", fmt.Errorf("failed to download steamcmd: %w", err)
}
// Extract the zip into steamcmdDir.
if err := extractZip(zipPath, steamcmdDir); err != nil {
return "", fmt.Errorf("failed to extract steamcmd.zip: %w", err)
}
// Verify the exe landed where expected.
if _, err := os.Stat(steamcmdPath); err != nil {
return "", fmt.Errorf("steamcmd.exe not found after extraction: %w", err)
}
log.Printf("SteamCMD installed successfully at %s", steamcmdPath)
return steamcmdPath, nil
}
// RegisterService creates a Windows service for the Rust Dedicated Server
// using sc.exe. If the caller does not have administrator privileges the
// command will fail silently with a warning log.
func RegisterService(installDir string, cfg DeployConfig) error {
serverPath := filepath.Join(installDir, "server", "RustDedicated.exe")
binPath := fmt.Sprintf(`"%s" -batchmode +server.hostname "%s" +server.port %d +rcon.port %d +rcon.password "%s" +rcon.web 1 +server.identity "corrosion" +server.maxplayers %d +server.worldsize %d +server.seed %d`,
serverPath, cfg.ServerName, cfg.ServerPort, cfg.RconPort,
cfg.RconPassword, cfg.MaxPlayers, cfg.WorldSize, cfg.Seed)
cmd := exec.Command("sc.exe", "create", "RustServer", "binPath=", binPath, "start=", "auto")
if out, err := cmd.CombinedOutput(); err != nil {
log.Printf("WARNING: sc.exe create failed (may require admin): %v — output: %s", err, string(out))
} else {
log.Println("Windows service RustServer registered successfully")
}
return nil
}
// downloadFile fetches url and writes the response body to dest on disk.
func downloadFile(url, dest string) error {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("GET %s failed: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s returned status %d", url, resp.StatusCode)
}
out, err := os.Create(dest)
if err != nil {
return fmt.Errorf("failed to create file %s: %w", dest, err)
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
return fmt.Errorf("failed to write to %s: %w", dest, err)
}
return nil
}
// extractZip extracts all files from a zip archive into destDir, preserving
// the directory structure from the archive.
func extractZip(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("failed to open zip %s: %w", zipPath, err)
}
defer r.Close()
for _, f := range r.File {
target := filepath.Join(destDir, f.Name)
if f.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", target, err)
}
continue
}
// Ensure the parent directory exists.
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return fmt.Errorf("failed to create parent dir for %s: %w", target, err)
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("failed to open zip entry %s: %w", f.Name, err)
}
outFile, err := os.Create(target)
if err != nil {
rc.Close()
return fmt.Errorf("failed to create file %s: %w", target, err)
}
if _, err := io.Copy(outFile, rc); err != nil {
outFile.Close()
rc.Close()
return fmt.Errorf("failed to extract %s: %w", f.Name, err)
}
outFile.Close()
rc.Close()
}
return nil
}

View 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

View 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)
}
}

View 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"
)

View File

@@ -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}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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' },

View File

@@ -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 }
}

View File

@@ -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)

View File

@@ -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',

View File

@@ -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,
}
})

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { ServerConnection, ServerConfig, ServerStats } from '@/types'
import type { ServerConnection, ServerConfig, ServerStats, DeploymentConfig, DeploymentStatus } from '@/types'
import { useApi } from '@/composables/useApi'
export const useServerStore = defineStore('server', () => {
@@ -8,6 +8,8 @@ export const useServerStore = defineStore('server', () => {
const config = ref<ServerConfig | null>(null)
const stats = ref<ServerStats | null>(null)
const isLoading = ref(false)
const deploymentStatus = ref<DeploymentStatus | null>(null)
const isDeploying = ref(false)
const api = useApi()
@@ -50,6 +52,30 @@ export const useServerStore = defineStore('server', () => {
return api.post('/servers/restart')
}
async function deployServer(config: DeploymentConfig) {
isDeploying.value = true
deploymentStatus.value = null
try {
await api.post('/servers/deploy', config)
} catch (e) {
console.error('Failed to start deployment:', e)
isDeploying.value = false
throw e
}
}
function updateDeploymentStatus(status: DeploymentStatus) {
deploymentStatus.value = status
if (status.stage === 'online' || status.stage === 'failed') {
isDeploying.value = false
}
}
function clearDeploymentStatus() {
deploymentStatus.value = null
isDeploying.value = false
}
function updateStats(newStats: ServerStats) {
stats.value = newStats
}
@@ -59,12 +85,17 @@ export const useServerStore = defineStore('server', () => {
config,
stats,
isLoading,
deploymentStatus,
isDeploying,
fetchServer,
updateConfig,
sendCommand,
startServer,
stopServer,
restartServer,
deployServer,
updateDeploymentStatus,
clearDeploymentStatus,
updateStats,
}
})

View File

@@ -27,6 +27,7 @@ export interface AuthResponse {
refresh_token: string
requires_totp: boolean
user: User
license: License | null
}
export interface ServerConnection {
@@ -423,3 +424,21 @@ export interface StoreTransaction {
payer_email: string | null
created_at: string
}
// Deployment types
export interface DeploymentConfig {
server_name: string
max_players: number
world_size: number
seed: number
server_port: number
rcon_port: number
rcon_password: string
}
export interface DeploymentStatus {
stage: 'downloading_steamcmd' | 'installing_steamcmd' | 'downloading_rust' | 'configuring' | 'starting' | 'online' | 'failed'
progress: number
message: string
error?: string
}

View File

@@ -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>

View File

@@ -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')
}
}

View File

@@ -27,7 +27,7 @@ const nextWipeDate = computed<string>(() => {
if (upcoming.length === 0) return 'Not Scheduled'
return upcoming[0].toLocaleDateString('en-US', {
return upcoming[0]!.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',

View 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>

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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}`)
}
}

View File

@@ -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
&bull; 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>

View File

@@ -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,
@@ -14,15 +15,35 @@ import {
Download,
Terminal,
Monitor,
Rocket,
AlertTriangle,
Check,
} from 'lucide-vue-next'
import type { DeploymentConfig, DeploymentStatus } from '@/types'
import { useWebSocket } from '@/composables/useWebSocket'
const server = useServerStore()
const auth = useAuthStore()
const toast = useToastStore()
const editMode = ref(false)
const saving = ref(false)
const actionLoading = ref<string | null>(null)
const copied = ref(false)
const setupTab = ref<'linux' | 'windows'>('linux')
const windowsCopied = ref(false)
const showDeployForm = ref(false)
const deployLoading = ref(false)
const deployForm = ref<DeploymentConfig>({
server_name: 'My Rust Server',
max_players: 100,
world_size: 4000,
seed: Math.floor(Math.random() * 2147483647),
server_port: 28015,
rcon_port: 28016,
rcon_password: '',
})
const isAgentConnected = computed(() =>
server.connection?.connection_type === 'bare_metal' &&
@@ -48,26 +69,78 @@ const agentLastSeenLabel = computed(() => {
const licenseKey = computed(() => auth.license?.license_key || 'YOUR-LICENSE-KEY')
const linuxCommands = computed(() => `# Download the agent
curl -LO https://git.corrosionmgmt.com/vantzs/corrosion-admin-panel/releases/latest/download/corrosion-companion-linux-amd64
curl -LO https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64
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`)
async function copyCommands() {
const windowsCommands = computed(() => `# Requires PowerShell (not Command Prompt)
# Download the agent
Invoke-WebRequest -Uri "https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe" -OutFile "corrosion-companion-windows-amd64.exe"
# Start with your license key
$env:LICENSE_ID="${licenseKey.value}"
$env:NATS_URL="nats://nats.corrosionmgmt.com:4222"
.\\corrosion-companion-windows-amd64.exe`)
async function copySetupCommands() {
try {
await navigator.clipboard.writeText(linuxCommands.value)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
const text = setupTab.value === 'linux' ? linuxCommands.value : windowsCommands.value
await navigator.clipboard.writeText(text)
if (setupTab.value === 'linux') {
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} else {
windowsCopied.value = true
setTimeout(() => { windowsCopied.value = false }, 2000)
}
} catch {
// Clipboard API unavailable
}
}
async function startDeploy() {
if (!deployForm.value.rcon_password || deployForm.value.rcon_password.length < 6) return
deployLoading.value = true
try {
await server.deployServer(deployForm.value)
showDeployForm.value = false
} catch {
toast.error('Failed to start deployment')
} finally {
deployLoading.value = false
}
}
const deployStages = [
{ key: 'downloading_steamcmd', label: 'Download SteamCMD' },
{ key: 'installing_steamcmd', label: 'Install SteamCMD' },
{ key: 'downloading_rust', label: 'Download Rust Server' },
{ key: 'configuring', label: 'Configure' },
{ key: 'starting', label: 'Start Server' },
{ key: 'online', label: 'Online' },
] as const
function getStageState(stageKey: string): 'pending' | 'active' | 'complete' | 'failed' {
const status = server.deploymentStatus
if (!status) return 'pending'
if (status.stage === 'failed') {
const idx = deployStages.findIndex(s => s.key === stageKey)
const failIdx = deployStages.findIndex(s => s.key === status.stage)
if (idx < failIdx) return 'complete'
if (idx === failIdx) return 'failed'
return 'pending'
}
const currentIdx = deployStages.findIndex(s => s.key === status.stage)
const thisIdx = deployStages.findIndex(s => s.key === stageKey)
if (thisIdx < currentIdx) return 'complete'
if (thisIdx === currentIdx) return status.stage === 'online' ? 'complete' : 'active'
return 'pending'
}
const form = ref({
server_name: '',
max_players: 0,
@@ -91,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
}
@@ -105,16 +179,35 @@ 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()
const ws = useWebSocket()
ws.subscribe((msg) => {
if (msg.type === 'event' && msg.event === 'deploy_status') {
server.updateDeploymentStatus(msg.data as DeploymentStatus)
}
})
})
</script>
@@ -244,7 +337,7 @@ onMounted(async () => {
</div>
<div class="flex flex-wrap gap-3">
<a
href="https://git.corrosionmgmt.com/vantzs/corrosion-admin-panel/releases/latest/download/corrosion-companion-linux-amd64"
href="https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64"
download="corrosion-companion-linux-amd64"
class="flex items-center gap-2 px-4 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 border border-neutral-700 hover:border-neutral-600 rounded-lg text-sm font-medium transition-colors"
>
@@ -252,7 +345,7 @@ onMounted(async () => {
Linux (amd64)
</a>
<a
href="https://git.corrosionmgmt.com/vantzs/corrosion-admin-panel/releases/latest/download/corrosion-companion-windows-amd64.exe"
href="https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe"
download="corrosion-companion-windows-amd64.exe"
class="flex items-center gap-2 px-4 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 border border-neutral-700 hover:border-neutral-600 rounded-lg text-sm font-medium transition-colors"
>
@@ -262,34 +355,192 @@ onMounted(async () => {
</div>
</div>
<!-- Quick Setup Section -->
<!-- Quick Setup Section Tabbed -->
<div>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<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 (Linux)</p>
<p class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Quick Setup</p>
</div>
<button
@click="copyCommands"
@click="copySetupCommands"
class="flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-md transition-colors"
:class="copied
: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'"
>
{{ copied ? 'Copied!' : 'Copy' }}
{{ (setupTab === 'linux' ? copied : windowsCopied) ? 'Copied!' : 'Copy' }}
</button>
</div>
<div class="bg-black/50 border border-neutral-800 rounded-lg p-4 font-mono text-sm text-neutral-300 overflow-x-auto">
<!-- 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 -->
<div v-if="setupTab === 'windows'" class="flex items-center gap-2 mb-3 px-3 py-2 bg-amber-500/10 border border-amber-500/20 rounded-lg">
<AlertTriangle class="w-4 h-4 text-amber-400 shrink-0" />
<p class="text-xs text-amber-300">PowerShell Required Command Prompt is not supported</p>
</div>
<!-- Linux Commands -->
<div v-if="setupTab === 'linux'" class="bg-black/50 border border-neutral-800 rounded-lg p-4 font-mono text-sm text-neutral-300 overflow-x-auto">
<p class="text-neutral-500"># Download the agent</p>
<p>curl -LO https://git.corrosionmgmt.com/vantzs/corrosion-admin-panel/releases/latest/download/corrosion-companion-linux-amd64</p>
<p>curl -LO https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64</p>
<p>chmod +x corrosion-companion-linux-amd64</p>
<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">"&lt;your-nats-token&gt;"</span></p>
<p>export GAME_SERVER_PATH=<span class="text-neutral-500">"/path/to/RustDedicated"</span></p>
<p>./corrosion-companion-linux-amd64</p>
</div>
<!-- Windows Commands -->
<div v-if="setupTab === 'windows'" class="bg-black/50 border border-neutral-800 rounded-lg p-4 font-mono text-sm text-neutral-300 overflow-x-auto">
<p class="text-neutral-500"># Requires PowerShell (not Command Prompt)</p>
<p class="text-neutral-500"># Download the agent</p>
<p>Invoke-WebRequest -Uri <span class="text-oxide-400">"https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe"</span> -OutFile <span class="text-oxide-400">"corrosion-companion-windows-amd64.exe"</span></p>
<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>.\corrosion-companion-windows-amd64.exe</p>
</div>
</div>
</div>
<!-- Deploy Rust Server -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-5">
<Rocket class="w-4 h-4 text-oxide-400" />
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Deploy Rust Server</h2>
</div>
<!-- Deployment Progress Tracker -->
<div v-if="server.deploymentStatus || server.isDeploying" class="mb-6">
<div class="space-y-3">
<div
v-for="stage in deployStages"
:key="stage.key"
class="flex items-center gap-3"
>
<!-- Stage indicator -->
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
:class="{
'bg-neutral-800 text-neutral-600': getStageState(stage.key) === 'pending',
'bg-amber-500/20 text-amber-400': getStageState(stage.key) === 'active',
'bg-green-500/20 text-green-400': getStageState(stage.key) === 'complete',
'bg-red-500/20 text-red-400': getStageState(stage.key) === 'failed',
}"
>
<Loader2 v-if="getStageState(stage.key) === 'active'" class="w-3.5 h-3.5 animate-spin" />
<Check v-else-if="getStageState(stage.key) === 'complete'" class="w-3.5 h-3.5" />
<AlertTriangle v-else-if="getStageState(stage.key) === 'failed'" class="w-3.5 h-3.5" />
<span v-else class="w-1.5 h-1.5 rounded-full bg-neutral-600" />
</div>
<!-- Stage label -->
<span
class="text-sm"
:class="{
'text-neutral-600': getStageState(stage.key) === 'pending',
'text-amber-300 font-medium': getStageState(stage.key) === 'active',
'text-green-400': getStageState(stage.key) === 'complete',
'text-red-400': getStageState(stage.key) === 'failed',
}"
>{{ stage.label }}</span>
</div>
</div>
<!-- Status message -->
<div v-if="server.deploymentStatus?.message" class="mt-4 px-3 py-2 bg-neutral-800/50 rounded-lg">
<p class="text-xs text-neutral-400">{{ server.deploymentStatus.message }}</p>
</div>
<!-- Error display -->
<div v-if="server.deploymentStatus?.error" class="mt-3 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg">
<p class="text-xs text-red-400">{{ server.deploymentStatus.error }}</p>
</div>
<!-- Retry button on failure -->
<button
v-if="server.deploymentStatus?.stage === 'failed'"
@click="server.clearDeploymentStatus(); showDeployForm = true"
class="mt-3 flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<RotateCcw class="w-4 h-4" />
Retry Deployment
</button>
</div>
<!-- Deploy Form (shown when not deploying) -->
<div v-else>
<div v-if="!showDeployForm" class="text-center py-4">
<p class="text-sm text-neutral-400 mb-4">Automatically install SteamCMD, download Rust Dedicated Server, configure, and start all with one click.</p>
<button
@click="showDeployForm = true"
class="inline-flex items-center gap-2 px-5 py-2.5 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<Rocket class="w-4 h-4" />
Deploy Server
</button>
</div>
<form v-else @submit.prevent="startDeploy" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="block text-xs text-neutral-500 mb-1">Server Name</label>
<input v-model="deployForm.server_name" type="text" required class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">Max Players</label>
<input v-model.number="deployForm.max_players" type="number" min="1" max="500" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">World Size</label>
<input v-model.number="deployForm.world_size" type="number" min="1000" max="8000" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">Map Seed</label>
<input v-model.number="deployForm.seed" type="number" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">Server Port</label>
<input v-model.number="deployForm.server_port" type="number" min="1024" max="65535" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">RCON Port</label>
<input v-model.number="deployForm.rcon_port" type="number" min="1024" max="65535" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
</div>
<div class="col-span-2">
<label class="block text-xs text-neutral-500 mb-1">RCON Password <span class="text-red-400">*</span></label>
<input v-model="deployForm.rcon_password" type="password" required minlength="6" placeholder="Minimum 6 characters" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors placeholder:text-neutral-600" />
</div>
</div>
<div class="flex items-center gap-3 pt-2">
<button
type="submit"
:disabled="deployLoading || !deployForm.rcon_password || deployForm.rcon_password.length < 6"
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
<Loader2 v-if="deployLoading" class="w-4 h-4 animate-spin" />
<Rocket v-else class="w-4 h-4" />
{{ deployLoading ? 'Deploying...' : 'Deploy Server' }}
</button>
<button
type="button"
@click="showDeployForm = false"
class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200 transition-colors"
>Cancel</button>
</div>
</form>
</div>
</div>
@@ -395,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>

View File

@@ -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}`)
}
}

View File

@@ -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>

View File

@@ -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 = ''

View File

@@ -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) {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { Shield, Users, Star, MessageCircle, Clock, ChevronRight, Check, Zap, Terminal, RefreshCw, LayoutDashboard } from 'lucide-vue-next'
// ---------- Email capture ----------

View File

@@ -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)