From e855f5786d9e277f81e63369388bec0eb1d43fb5 Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Mon, 2 Mar 2026 00:43:04 +0100 Subject: [PATCH] Add Go server and security audit; update web console Add a new betterdesk-server Go codebase (server, api, auth, db, relay, signal, metrics, audit, ratelimit, proto, tools, tests) and related deployment/migration scripts. Add a comprehensive SECURITY_AUDIT_2026-03-01 report and .gitattributes; update copilot-instructions (ALL-IN-ONE v2.4.0), README, VERSION, Dockerfiles, scripts, docker-compose and entrypoint. Large updates to web-nodejs (translations, routes, services, frontend assets and middleware) and numerous new utilities; remove legacy Flask web files and archive hbbs-patch-v2 artifacts. Prepares repository for PostgreSQL support, DB migration tooling and the new Go server as the production backend. --- .gitattributes | 45 + .github/SECURITY_AUDIT_2026-03-01.md | 676 ++ .github/copilot-instructions.md | 249 +- .github/go-server-context.md | 776 ++ .gitignore | 43 +- Dockerfile.console | 98 +- Dockerfile.hbbr | 65 +- Dockerfile.hbbs | 85 +- README.md | 2157 ++-- VERSION | 2 +- betterdesk-docker.sh | 736 +- betterdesk-server/.gitignore | 34 + betterdesk-server/DEPLOY.md | 343 + betterdesk-server/admin/server.go | 450 + betterdesk-server/admin/server_test.go | 144 + betterdesk-server/api/auth_handlers.go | 700 ++ betterdesk-server/api/server.go | 891 ++ betterdesk-server/api/server_test.go | 436 + betterdesk-server/api/token_handlers.go | 510 + betterdesk-server/audit/logger.go | 174 + betterdesk-server/audit/logger_test.go | 113 + betterdesk-server/auth/jwt.go | 123 + betterdesk-server/auth/jwt_test.go | 74 + betterdesk-server/auth/roles.go | 34 + betterdesk-server/auth/totp.go | 83 + betterdesk-server/auth/totp_test.go | 81 + betterdesk-server/codec/framing.go | 227 + betterdesk-server/codec/framing_test.go | 307 + betterdesk-server/codec/ws.go | 86 + betterdesk-server/config/config.go | 270 + betterdesk-server/config/constants.go | 61 + betterdesk-server/config/tls.go | 98 + betterdesk-server/config/tls_test.go | 281 + betterdesk-server/crypto/addr_mangle.go | 72 + betterdesk-server/crypto/addr_mangle_test.go | 158 + betterdesk-server/crypto/keys.go | 145 + betterdesk-server/crypto/keys_test.go | 169 + betterdesk-server/crypto/secure.go | 357 + betterdesk-server/db/database.go | 165 + betterdesk-server/db/open.go | 22 + betterdesk-server/db/postgres.go | 972 ++ betterdesk-server/db/sqlite.go | 973 ++ betterdesk-server/db/sqlite_test.go | 328 + betterdesk-server/deploy.sh | 529 + betterdesk-server/events/bus.go | 118 + betterdesk-server/events/bus_test.go | 122 + betterdesk-server/go.mod | 29 + betterdesk-server/go.sum | 85 + betterdesk-server/logging/logger.go | 148 + betterdesk-server/logging/logger_test.go | 94 + betterdesk-server/main.go | 380 + betterdesk-server/metrics/collector.go | 138 + betterdesk-server/metrics/collector_test.go | 64 + betterdesk-server/peer/map.go | 468 + betterdesk-server/peer/map_test.go | 457 + betterdesk-server/proto/message.pb.go | 9437 +++++++++++++++++ betterdesk-server/proto/rendezvous.pb.go | 2661 +++++ betterdesk-server/protos/message.proto | 842 ++ betterdesk-server/protos/rendezvous.proto | 238 + betterdesk-server/ratelimit/bandwidth.go | 264 + betterdesk-server/ratelimit/connlimiter.go | 60 + .../ratelimit/connlimiter_test.go | 82 + betterdesk-server/ratelimit/ip_limiter.go | 201 + .../ratelimit/ip_limiter_test.go | 156 + betterdesk-server/relay/server.go | 333 + betterdesk-server/relay/server_test.go | 178 + betterdesk-server/relay/ws.go | 154 + betterdesk-server/relay/ws_test.go | 110 + betterdesk-server/reload/handler.go | 52 + betterdesk-server/reload/handler_test.go | 75 + betterdesk-server/reload/signal_unix.go | 30 + betterdesk-server/reload/signal_windows.go | 14 + betterdesk-server/security/blocklist.go | 307 + betterdesk-server/security/blocklist_test.go | 188 + betterdesk-server/signal/handler.go | 1085 ++ betterdesk-server/signal/server.go | 736 ++ betterdesk-server/signal/ws.go | 278 + betterdesk-server/signal/ws_test.go | 229 + betterdesk-server/tools/migrate/main.go | 1733 +++ betterdesk-server/tools/setup-remote.sh | 84 + betterdesk.ps1 | 969 +- betterdesk.sh | 1345 ++- dev_modules/check_hbbs_online.py | 35 + dev_modules/check_id.py | 12 +- dev_modules/diagnose_pro_features.sh | 238 + dev_modules/patch_tcp_fix.py | 2 +- dev_modules/patch_tcp_idchange.py | 2 +- dev_modules/test_db_exports.js | 16 + docker-compose.yml | 118 +- docker-entrypoint.sh | 273 +- docs/BETTERDESK_CLIENT_ARCHITECTURE.md | 311 + docs/DOCKER_SUPPORT.md | 2 +- docs/ENTERPRISE_ROADMAP.md | 427 + docs/README.md | 4 +- hbbs-patch-v2/.gitignore | 45 - hbbs-patch-v2/BUILD.md | 388 - hbbs-patch-v2/CHANGES.md | 539 - hbbs-patch-v2/CHECKSUMS.md | 158 - hbbs-patch-v2/CONTRIBUTING.md | 261 - hbbs-patch-v2/Cargo.toml | 66 - hbbs-patch-v2/GETTING_STARTED.md | 97 - hbbs-patch-v2/INDEX.md | 260 - hbbs-patch-v2/INSTALLATION.md | 330 - hbbs-patch-v2/LICENSE | 33 - hbbs-patch-v2/QUICKSTART.md | 248 - hbbs-patch-v2/README.md | 345 - hbbs-patch-v2/SUMMARY.md | 323 - hbbs-patch-v2/TODO.md | 196 - hbbs-patch-v2/build.sh | 129 - hbbs-patch-v2/complete.sh | 107 - hbbs-patch-v2/hbbr-linux-x86_64 | Bin 3098072 -> 0 bytes hbbs-patch-v2/hbbr-windows-x86_64.exe | Bin 2881024 -> 0 bytes hbbs-patch-v2/hbbs-linux-x86_64 | Bin 9954936 -> 0 bytes hbbs-patch-v2/hbbs-windows-x86_64.exe | Bin 7654912 -> 0 bytes hbbs-patch-v2/src/database.rs | 330 - hbbs-patch-v2/src/http_api.rs | 492 - hbbs-patch-v2/src/main.rs | 75 - hbbs-patch-v2/src/peer.rs | 529 - hbbs-patch-v2/src/relay_server.rs | 696 -- hbbs-patch-v2/src/rendezvous_server.rs | 1362 --- hbbs-patch-v2/src/rendezvous_server_core.rs | 581 - scripts/legacy/docker-quickstart.sh | 2 +- web-nodejs/config/config.js | 15 +- web-nodejs/lang/de.json | 1054 ++ web-nodejs/lang/en.json | 602 +- web-nodejs/lang/es.json | 1054 ++ web-nodejs/lang/fr.json | 1054 ++ web-nodejs/lang/it.json | 1054 ++ web-nodejs/lang/nl.json | 1054 ++ web-nodejs/lang/pl.json | 602 +- web-nodejs/lang/pt.json | 1054 ++ web-nodejs/lang/zh.json | 1054 ++ web-nodejs/middleware/csrf.js | 48 +- web-nodejs/middleware/security.js | 1 + web-nodejs/middleware/wanSecurity.js | 138 +- web-nodejs/package.json | 6 + web-nodejs/public/css/device-detail.css | 986 ++ web-nodejs/public/css/pages.css | 298 + web-nodejs/public/css/registrations.css | 367 + web-nodejs/public/img/betterdesk.png | Bin 0 -> 2091667 bytes web-nodejs/public/img/betterdesk_icon.png | Bin 0 -> 2177755 bytes web-nodejs/public/js/app.js | 22 + web-nodejs/public/js/dashboard.js | 30 +- web-nodejs/public/js/deviceDetail.js | 978 ++ web-nodejs/public/js/devices.js | 41 +- web-nodejs/public/js/rdclient/client.js | 334 +- web-nodejs/public/js/rdclient/crypto.js | 33 +- web-nodejs/public/js/registrations.js | 309 + web-nodejs/public/js/settings.js | 344 +- web-nodejs/public/js/utils.js | 28 +- web-nodejs/routes/activity.routes.js | 243 + web-nodejs/routes/automation.routes.js | 483 + web-nodejs/routes/bd-api.routes.js | 326 + web-nodejs/routes/dashboard.routes.js | 84 +- web-nodejs/routes/dataguard.routes.js | 275 + web-nodejs/routes/devices.routes.js | 177 +- web-nodejs/routes/fileTransfer.routes.js | 181 + web-nodejs/routes/folders.routes.js | 6 +- web-nodejs/routes/i18n.routes.js | 5 +- web-nodejs/routes/index.js | 59 + web-nodejs/routes/inventory.routes.js | 255 + web-nodejs/routes/network.routes.js | 335 + web-nodejs/routes/registration.routes.js | 328 + web-nodejs/routes/remote.routes.js | 57 +- web-nodejs/routes/reports.routes.js | 193 + web-nodejs/routes/rustdesk-api.routes.js | 886 +- web-nodejs/routes/settings.routes.js | 196 +- web-nodejs/routes/tenants.routes.js | 285 + web-nodejs/routes/tickets.routes.js | 528 + web-nodejs/routes/users.routes.js | 9 + web-nodejs/server.js | 72 +- web-nodejs/services/alertRulesEngine.js | 137 + web-nodejs/services/authService.js | 24 +- web-nodejs/services/backupService.js | 337 + web-nodejs/services/bdRelay.js | 492 + web-nodejs/services/betterdeskApi.js | 413 + web-nodejs/services/brandingService.js | 47 +- web-nodejs/services/chatRelay.js | 297 + web-nodejs/services/database.js | 1127 +- web-nodejs/services/dbAdapter.js | 3003 ++++++ web-nodejs/services/emailService.js | 188 + web-nodejs/services/fileTransferService.js | 287 + web-nodejs/services/hbbsApi.js | 28 +- web-nodejs/services/i18nService.js | 32 + web-nodejs/services/lanDiscovery.js | 128 + web-nodejs/services/networkMonitor.js | 332 + web-nodejs/services/remoteRelay.js | 298 + web-nodejs/services/reportEngine.js | 326 + web-nodejs/services/serverBackend.js | 268 + web-nodejs/views/dashboard.ejs | 48 +- web-nodejs/views/devices.ejs | 4 +- web-nodejs/views/errors/500.ejs | 5 +- web-nodejs/views/partials/sidebar.ejs | 6 + web-nodejs/views/registrations.ejs | 88 + web-nodejs/views/remote-viewer.ejs | 504 + web-nodejs/views/settings.ejs | 192 + web/app.py | 1629 --- web/auth.py | 518 - web/betterdesk.service | 20 - web/client_generator_module.py | 443 - web/i18n.py | 493 - web/lang/en.json | 263 - web/lang/pl.json | 263 - web/requirements.txt | 8 - web/source_client_generator.py | 826 -- web/static/MATERIAL_ICONS.md | 67 - web/static/client_generator.css | 447 - web/static/client_generator.js | 555 - web/static/css/i18n.css | 166 - web/static/js/i18n.js | 331 - web/static/material-icons.woff2 | Bin 128352 -> 0 bytes web/static/script.js | 1070 -- web/static/style.css | 907 -- web/templates/client_generator.html | 2011 ---- web/templates/index.html | 1082 -- web/templates/login.html | 390 - 216 files changed, 63504 insertions(+), 21457 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/SECURITY_AUDIT_2026-03-01.md create mode 100644 .github/go-server-context.md create mode 100644 betterdesk-server/.gitignore create mode 100644 betterdesk-server/DEPLOY.md create mode 100644 betterdesk-server/admin/server.go create mode 100644 betterdesk-server/admin/server_test.go create mode 100644 betterdesk-server/api/auth_handlers.go create mode 100644 betterdesk-server/api/server.go create mode 100644 betterdesk-server/api/server_test.go create mode 100644 betterdesk-server/api/token_handlers.go create mode 100644 betterdesk-server/audit/logger.go create mode 100644 betterdesk-server/audit/logger_test.go create mode 100644 betterdesk-server/auth/jwt.go create mode 100644 betterdesk-server/auth/jwt_test.go create mode 100644 betterdesk-server/auth/roles.go create mode 100644 betterdesk-server/auth/totp.go create mode 100644 betterdesk-server/auth/totp_test.go create mode 100644 betterdesk-server/codec/framing.go create mode 100644 betterdesk-server/codec/framing_test.go create mode 100644 betterdesk-server/codec/ws.go create mode 100644 betterdesk-server/config/config.go create mode 100644 betterdesk-server/config/constants.go create mode 100644 betterdesk-server/config/tls.go create mode 100644 betterdesk-server/config/tls_test.go create mode 100644 betterdesk-server/crypto/addr_mangle.go create mode 100644 betterdesk-server/crypto/addr_mangle_test.go create mode 100644 betterdesk-server/crypto/keys.go create mode 100644 betterdesk-server/crypto/keys_test.go create mode 100644 betterdesk-server/crypto/secure.go create mode 100644 betterdesk-server/db/database.go create mode 100644 betterdesk-server/db/open.go create mode 100644 betterdesk-server/db/postgres.go create mode 100644 betterdesk-server/db/sqlite.go create mode 100644 betterdesk-server/db/sqlite_test.go create mode 100644 betterdesk-server/deploy.sh create mode 100644 betterdesk-server/events/bus.go create mode 100644 betterdesk-server/events/bus_test.go create mode 100644 betterdesk-server/go.mod create mode 100644 betterdesk-server/go.sum create mode 100644 betterdesk-server/logging/logger.go create mode 100644 betterdesk-server/logging/logger_test.go create mode 100644 betterdesk-server/main.go create mode 100644 betterdesk-server/metrics/collector.go create mode 100644 betterdesk-server/metrics/collector_test.go create mode 100644 betterdesk-server/peer/map.go create mode 100644 betterdesk-server/peer/map_test.go create mode 100644 betterdesk-server/proto/message.pb.go create mode 100644 betterdesk-server/proto/rendezvous.pb.go create mode 100644 betterdesk-server/protos/message.proto create mode 100644 betterdesk-server/protos/rendezvous.proto create mode 100644 betterdesk-server/ratelimit/bandwidth.go create mode 100644 betterdesk-server/ratelimit/connlimiter.go create mode 100644 betterdesk-server/ratelimit/connlimiter_test.go create mode 100644 betterdesk-server/ratelimit/ip_limiter.go create mode 100644 betterdesk-server/ratelimit/ip_limiter_test.go create mode 100644 betterdesk-server/relay/server.go create mode 100644 betterdesk-server/relay/server_test.go create mode 100644 betterdesk-server/relay/ws.go create mode 100644 betterdesk-server/relay/ws_test.go create mode 100644 betterdesk-server/reload/handler.go create mode 100644 betterdesk-server/reload/handler_test.go create mode 100644 betterdesk-server/reload/signal_unix.go create mode 100644 betterdesk-server/reload/signal_windows.go create mode 100644 betterdesk-server/security/blocklist.go create mode 100644 betterdesk-server/security/blocklist_test.go create mode 100644 betterdesk-server/signal/handler.go create mode 100644 betterdesk-server/signal/server.go create mode 100644 betterdesk-server/signal/ws.go create mode 100644 betterdesk-server/signal/ws_test.go create mode 100644 betterdesk-server/tools/migrate/main.go create mode 100644 betterdesk-server/tools/setup-remote.sh create mode 100644 dev_modules/check_hbbs_online.py create mode 100644 dev_modules/diagnose_pro_features.sh create mode 100644 dev_modules/test_db_exports.js create mode 100644 docs/BETTERDESK_CLIENT_ARCHITECTURE.md create mode 100644 docs/ENTERPRISE_ROADMAP.md delete mode 100644 hbbs-patch-v2/.gitignore delete mode 100644 hbbs-patch-v2/BUILD.md delete mode 100644 hbbs-patch-v2/CHANGES.md delete mode 100644 hbbs-patch-v2/CHECKSUMS.md delete mode 100644 hbbs-patch-v2/CONTRIBUTING.md delete mode 100644 hbbs-patch-v2/Cargo.toml delete mode 100644 hbbs-patch-v2/GETTING_STARTED.md delete mode 100644 hbbs-patch-v2/INDEX.md delete mode 100644 hbbs-patch-v2/INSTALLATION.md delete mode 100644 hbbs-patch-v2/LICENSE delete mode 100644 hbbs-patch-v2/QUICKSTART.md delete mode 100644 hbbs-patch-v2/README.md delete mode 100644 hbbs-patch-v2/SUMMARY.md delete mode 100644 hbbs-patch-v2/TODO.md delete mode 100644 hbbs-patch-v2/build.sh delete mode 100644 hbbs-patch-v2/complete.sh delete mode 100644 hbbs-patch-v2/hbbr-linux-x86_64 delete mode 100644 hbbs-patch-v2/hbbr-windows-x86_64.exe delete mode 100644 hbbs-patch-v2/hbbs-linux-x86_64 delete mode 100644 hbbs-patch-v2/hbbs-windows-x86_64.exe delete mode 100644 hbbs-patch-v2/src/database.rs delete mode 100644 hbbs-patch-v2/src/http_api.rs delete mode 100644 hbbs-patch-v2/src/main.rs delete mode 100644 hbbs-patch-v2/src/peer.rs delete mode 100644 hbbs-patch-v2/src/relay_server.rs delete mode 100644 hbbs-patch-v2/src/rendezvous_server.rs delete mode 100644 hbbs-patch-v2/src/rendezvous_server_core.rs create mode 100644 web-nodejs/lang/de.json create mode 100644 web-nodejs/lang/es.json create mode 100644 web-nodejs/lang/fr.json create mode 100644 web-nodejs/lang/it.json create mode 100644 web-nodejs/lang/nl.json create mode 100644 web-nodejs/lang/pt.json create mode 100644 web-nodejs/lang/zh.json create mode 100644 web-nodejs/public/css/device-detail.css create mode 100644 web-nodejs/public/css/registrations.css create mode 100644 web-nodejs/public/img/betterdesk.png create mode 100644 web-nodejs/public/img/betterdesk_icon.png create mode 100644 web-nodejs/public/js/deviceDetail.js create mode 100644 web-nodejs/public/js/registrations.js create mode 100644 web-nodejs/routes/activity.routes.js create mode 100644 web-nodejs/routes/automation.routes.js create mode 100644 web-nodejs/routes/bd-api.routes.js create mode 100644 web-nodejs/routes/dataguard.routes.js create mode 100644 web-nodejs/routes/fileTransfer.routes.js create mode 100644 web-nodejs/routes/inventory.routes.js create mode 100644 web-nodejs/routes/network.routes.js create mode 100644 web-nodejs/routes/registration.routes.js create mode 100644 web-nodejs/routes/reports.routes.js create mode 100644 web-nodejs/routes/tenants.routes.js create mode 100644 web-nodejs/routes/tickets.routes.js create mode 100644 web-nodejs/services/alertRulesEngine.js create mode 100644 web-nodejs/services/backupService.js create mode 100644 web-nodejs/services/bdRelay.js create mode 100644 web-nodejs/services/betterdeskApi.js create mode 100644 web-nodejs/services/chatRelay.js create mode 100644 web-nodejs/services/dbAdapter.js create mode 100644 web-nodejs/services/emailService.js create mode 100644 web-nodejs/services/fileTransferService.js create mode 100644 web-nodejs/services/lanDiscovery.js create mode 100644 web-nodejs/services/networkMonitor.js create mode 100644 web-nodejs/services/remoteRelay.js create mode 100644 web-nodejs/services/reportEngine.js create mode 100644 web-nodejs/services/serverBackend.js create mode 100644 web-nodejs/views/registrations.ejs create mode 100644 web-nodejs/views/remote-viewer.ejs delete mode 100644 web/app.py delete mode 100644 web/auth.py delete mode 100644 web/betterdesk.service delete mode 100644 web/client_generator_module.py delete mode 100644 web/i18n.py delete mode 100644 web/lang/en.json delete mode 100644 web/lang/pl.json delete mode 100644 web/requirements.txt delete mode 100644 web/source_client_generator.py delete mode 100644 web/static/MATERIAL_ICONS.md delete mode 100644 web/static/client_generator.css delete mode 100644 web/static/client_generator.js delete mode 100644 web/static/css/i18n.css delete mode 100644 web/static/js/i18n.js delete mode 100644 web/static/material-icons.woff2 delete mode 100644 web/static/script.js delete mode 100644 web/static/style.css delete mode 100644 web/templates/client_generator.html delete mode 100644 web/templates/index.html delete mode 100644 web/templates/login.html diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..7f2e0bc6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,45 @@ +# Auto detect text files and normalize line endings +* text=auto + +# Force LF for shell scripts and config files +*.sh text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.md text eol=lf +*.go text eol=lf +*.js text eol=lf +*.ejs text eol=lf +*.css text eol=lf +*.html text eol=lf +*.py text eol=lf +*.rs text eol=lf +*.toml text eol=lf +*.sql text eol=lf +*.proto text eol=lf +Dockerfile* text eol=lf +.gitignore text eol=lf +.gitattributes text eol=lf +.env.example text eol=lf + +# Force CRLF for Windows scripts +*.ps1 text eol=crlf +*.bat text eol=crlf +*.cmd text eol=crlf + +# Binary files — do not diff or normalize +*.exe binary +*.db binary +*.sqlite3 binary +*.png binary +*.jpg binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary + +# Linguist overrides (GitHub language detection) +*.ejs linguist-language=HTML +docs/** linguist-documentation +archive/** linguist-vendored diff --git a/.github/SECURITY_AUDIT_2026-03-01.md b/.github/SECURITY_AUDIT_2026-03-01.md new file mode 100644 index 00000000..77c5dd1f --- /dev/null +++ b/.github/SECURITY_AUDIT_2026-03-01.md @@ -0,0 +1,676 @@ +# BetterDesk Security Audit Report + +**Date:** 2026-03-01 +**Auditor:** GitHub Copilot (Claude Opus 4.5) +**Scope:** `betterdesk-server/` (Go), `web-nodejs/` (Node.js) + +--- + +## Executive Summary + +This audit identified **24 security findings** across the BetterDesk project: +- **Critical:** 2 ✅ FIXED +- **High:** 5 ✅ FIXED +- **Medium:** 10 ✅ 6 FIXED, 4 LOW RISK (accepted) +- **Low:** 7 (tracked for future work) + +Many security best practices are already in place (CSRF protection, session fixation prevention, timing-safe auth, rate limiting, SQL parameterization). The findings below represent remaining gaps or areas for improvement. + +--- + +## Remediation Status (2026-03-01) + +| ID | Severity | Description | Status | +|----|----------|-------------|--------| +| C-1 | Critical | Command Injection in pingHost() | ✅ Fixed: spawn() with args array + host validation | +| C-2 | Critical | Password Logging in main.go | ✅ Fixed: Write to secure file with 0600 perms | +| H-1 | High | SQL LIKE Injection | ✅ Fixed: escapeLikePattern() + ESCAPE clause | +| H-2 | High | Error Leakage in Go API | ✅ Fixed: writeInternalError() helper | +| H-3 | High | Error Leakage in Token Handlers | ✅ Fixed: Generic error messages + logging | +| H-4 | High | Path Traversal in i18n | ✅ Fixed: isValidLangCode() validation | +| H-5 | High | MD5 for Hash | ✅ Fixed: SHA256 | +| M-1 | Medium | XSS via innerHTML | ✅ Fixed: Utils.escapeHtml() + SVG sanitization | +| M-2 | Medium | parseInt NaN validation | ✅ Fixed: isNaN() checks added | +| M-3 | Medium | Weak Random in generateId | ✅ Fixed: crypto.randomUUID() | +| M-4 | Medium | Cookie httpOnly: false | ✅ Documented: Intentional for client-side i18n | +| M-5 | Medium | Trust Proxy Default | ✅ Fixed: Default changed to false | +| M-6 | Medium | Missing Content-Type Check | ✅ Fixed: requireJsonContentType middleware | + +--- + +## Critical Findings + +### C-1: Command Injection in Network Monitor (CRITICAL) + +**File:** [web-nodejs/services/networkMonitor.js](../web-nodejs/services/networkMonitor.js#L60-L64) +**Severity:** Critical +**Description:** The `pingHost` function passes user-controllable `host` parameter directly to shell command without sanitization. + +**Code:** +```javascript +// Line 60-62 +const cmd = isWin + ? `ping -n 1 -w ${timeoutMs} ${host}` + : `ping -c 1 -W ${timeoutSec} ${host}`; + +const start = Date.now(); +exec(cmd, { timeout: timeoutMs + 2000 }, (err, stdout) => { +``` + +**Impact:** An attacker who can control the `host` parameter can execute arbitrary system commands (e.g., `; rm -rf /` or `& calc.exe`). + +**Recommended Fix:** +```javascript +// Validate hostname/IP format before use +const validHostRegex = /^[a-zA-Z0-9][a-zA-Z0-9.-]{0,253}[a-zA-Z0-9]$/; +if (!validHostRegex.test(host) && !net.isIP(host)) { + return resolve({ success: false, rtt_ms: null, error: 'Invalid host format' }); +} +// Use spawn() with array arguments instead of exec() +const { spawn } = require('child_process'); +const args = isWin ? ['-n', '1', '-w', String(timeoutMs), host] : ['-c', '1', '-W', String(timeoutSec), host]; +const proc = spawn('ping', args); +``` + +--- + +### C-2: Initial Admin Password Logged to Console (CRITICAL) + +**File:** [betterdesk-server/main.go](../betterdesk-server/main.go#L165) +**Severity:** Critical +**Description:** When a random admin password is generated, it is printed to logs in plaintext. + +**Code:** +```go +// Line 163-165 +if cfg.InitAdminPass == "" { + log.Printf(" Password: %s", adminPass) +} else { +``` + +**Impact:** The password may be visible in: +- Docker logs (`docker logs`) +- systemd journal (`journalctl`) +- Log files if stdout is redirected +- CI/CD build logs + +**Recommended Fix:** +```go +// Write password to a secure file with restricted permissions instead +if cfg.InitAdminPass == "" { + passFile := filepath.Join(cfg.DataDir, ".init_password") + os.WriteFile(passFile, []byte(adminPass), 0600) + log.Printf(" Password written to: %s (delete after reading)", passFile) + log.Printf(" (password not shown in logs for security)") +} else { + log.Printf(" Password: *** (user-provided, not logged)") +} +``` + +--- + +## High Findings + +### H-1: SQL LIKE Pattern Injection in dbAdapter.js (HIGH) + +**File:** [web-nodejs/services/dbAdapter.js](../web-nodejs/services/dbAdapter.js#L629) +**Severity:** High +**Description:** The `getAllPeers` function in the new dbAdapter does NOT escape `%` and `_` wildcards in the search parameter, unlike `database.js` which does. + +**Code:** +```javascript +// Line 629 - dbAdapter.js (NO escape) +if (filters.search) { where += ' AND (id LIKE ? OR note LIKE ? OR "user" LIKE ?)'; const s = `%${filters.search}%`; params.push(s, s, s); } + +// Compare with database.js (CORRECT - with escape) +// Line 435 - database.js +const escaped = escapeLikePattern(filters.search); +sql += " AND (id LIKE ? ESCAPE '\\' OR user LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\')"; +``` + +**Impact:** User can inject `%` or `_` wildcards to match arbitrary patterns (information disclosure through pattern matching). + +**Recommended Fix:** +```javascript +if (filters.search) { + const escaped = filters.search.replace(/[%_\\]/g, '\\$&'); + where += " AND (id LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\' OR \"user\" LIKE ? ESCAPE '\\')"; + const s = `%${escaped}%`; + params.push(s, s, s); +} +``` + +Also apply to lines: 903, 1510, 2096, 2297, 2889. + +--- + +### H-2: Error Message Information Leakage in Go API (HIGH) + +**File:** [betterdesk-server/api/server.go](../betterdesk-server/api/server.go#L327) +**Severity:** High +**Description:** Internal error messages are exposed to API clients via `err.Error()`. + +**Code:** +```go +// Multiple locations including line 327, 359, 380, 403, 423, 494, 519, 559, 721, 738 +writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) +``` + +**Impact:** Internal implementation details, database errors, file paths, and system information may leak to attackers. + +**Recommended Fix:** +```go +func handleDbError(w http.ResponseWriter, err error, action string) { + // Log full error internally + log.Printf("[api] %s error: %v", action, err) + // Return generic message to client + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "Internal server error", + }) +} +``` + +--- + +### H-3: Error Leakage in token_handlers.go (HIGH) + +**File:** [betterdesk-server/api/token_handlers.go](../betterdesk-server/api/token_handlers.go#L87) +**Severity:** High +**Description:** Database and system errors are directly exposed via `http.Error(w, err.Error(), ...)`. + +**Code:** +```go +// Lines 87, 150, 175, 199, 229, 249, 258, 343, 391, 409, 465 +http.Error(w, err.Error(), http.StatusInternalServerError) +``` + +**Impact:** Same as H-2 - information leakage. + +**Recommended Fix:** Same pattern as H-2. + +--- + +### H-4: Path Traversal Risk in i18n Language Upload (HIGH) + +**File:** [web-nodejs/routes/i18n.routes.js](../web-nodejs/routes/i18n.routes.js#L134-L145) +**Severity:** High +**Description:** Language code derived from uploaded filename or body is used in file path without full validation. + +**Code:** +```javascript +// Line 137-146 +const meta = translations._meta; +const code = meta?.code || req.body.code || req.file.originalname.replace('.json', ''); + +if (!code || code.length < 2 || code.length > 5) { + return res.status(400).json({ + success: false, + error: 'Invalid language code' + }); +} + +const result = manager.saveLanguage(code, translations); +``` + +**Impact:** An attacker could potentially submit `../../../etc/passwd` as code (though length check provides some protection). The `saveLanguage` function uses `path.join(config.langDir, ${code}.json)` which may still be vulnerable. + +**Recommended Fix:** +```javascript +// Strict validation: only allow alphanumeric + dash, 2-5 chars +if (!/^[a-z]{2}(-[A-Z]{2})?$/.test(code)) { + return res.status(400).json({ + success: false, + error: 'Invalid language code format (use: xx or xx-XX)' + }); +} +// Also validate in saveLanguage(): +const safeName = path.basename(code); // Extra protection +const filePath = path.join(config.langDir, `${safeName}.json`); +``` + +--- + +### H-5: MD5 Used for Sysinfo Hash (HIGH) + +**File:** [web-nodejs/routes/rustdesk-api.routes.js](../web-nodejs/routes/rustdesk-api.routes.js#L323) +**Severity:** High +**Description:** MD5 is used for creating content hashes, which is cryptographically weak. + +**Code:** +```javascript +// Line 323 +const hash = require('crypto').createHash('md5') + .update(JSON.stringify(sysinfo.raw_json)) + .digest('hex') + .substring(0, 16); +``` + +**Impact:** While used only for cache invalidation (not security), MD5 is deprecated and could lead to collisions. Using a deprecated algorithm in security-critical software sets a bad precedent. + +**Recommended Fix:** +```javascript +const hash = require('crypto').createHash('sha256') + .update(JSON.stringify(sysinfo.raw_json)) + .digest('hex') + .substring(0, 32); +``` + +--- + +## Medium Findings + +### M-1: XSS Risk via innerHTML in Frontend JS (MEDIUM) + +**File:** [web-nodejs/public/js/users.js](../web-nodejs/public/js/users.js#L65) +**Severity:** Medium +**Description:** User data is rendered via template literals and innerHTML without consistent escaping. + +**Code:** +```javascript +// Line 65 - users.js +tableBody.innerHTML = users.map(user => ` + + ${user.username} + ... +``` + +**Impact:** If `user.username` contains ` diff --git a/web-nodejs/views/partials/sidebar.ejs b/web-nodejs/views/partials/sidebar.ejs index 3c7734a2..43b167df 100644 --- a/web-nodejs/views/partials/sidebar.ejs +++ b/web-nodejs/views/partials/sidebar.ejs @@ -29,6 +29,12 @@ devices <%= _('nav.devices') %> + + + how_to_reg + <%= _('nav.registrations') %> + + diff --git a/web-nodejs/views/registrations.ejs b/web-nodejs/views/registrations.ejs new file mode 100644 index 00000000..6693ee35 --- /dev/null +++ b/web-nodejs/views/registrations.ejs @@ -0,0 +1,88 @@ +<%- include('layouts/main', { + title: _('nav.registrations'), + pageStyles: ['registrations'], + pageScripts: ['registrations'], + currentPage: 'registrations', + breadcrumb: [{ label: _('nav.registrations') }], + body: ` +
+ + + + +
+
+
+ search + +
+
+ + + + +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + +
${_('registrations.col_device_id')}${_('registrations.col_hostname')}${_('registrations.col_platform')}${_('registrations.col_ip')}${_('registrations.col_version')}${_('registrations.col_status')}${_('registrations.col_requested')}${_('registrations.col_actions')}
+
+ how_to_reg +

${_('registrations.no_registrations')}

+
+
+
+ + + +
+ ` +}) %> diff --git a/web-nodejs/views/remote-viewer.ejs b/web-nodejs/views/remote-viewer.ejs new file mode 100644 index 00000000..12e34911 --- /dev/null +++ b/web-nodejs/views/remote-viewer.ejs @@ -0,0 +1,504 @@ +<%- include('layouts/viewer', { + title: (device && device.hostname) ? device.hostname : deviceId, + pageScripts: [], + body: ` + + +
+ + +
+
+
+ connected_tv +
+

Remote Desktop

+

` + (device && device.hostname ? device.hostname : '') + `

+

` + deviceId + `

+
+
+ Connecting to agent… +
+ +
+
+ + + + + +
+
+ connected_tv + ` + (device && device.hostname ? device.hostname : deviceId) + ` + + +
+
+ + + +
+
+ + +
+
+ Chat — ` + deviceId + ` + +
+
+
+ + +
+
+ +
+ + + + + +` +}) %> diff --git a/web-nodejs/views/settings.ejs b/web-nodejs/views/settings.ejs index 6203a20e..5374f503 100644 --- a/web-nodejs/views/settings.ejs +++ b/web-nodejs/views/settings.ejs @@ -16,10 +16,18 @@ tune ${_('settings.tab_general')} + + @@ -155,6 +163,92 @@ + +
+
+ +
+
+

+ dns + ${_('settings.backend_title')} +

+

${_('settings.backend_desc')}

+
+
+
+ + +
+ + +
+
+ cloud + ${_('settings.backend_connection')}: + - +
+
+ link + ${_('settings.backend_api_url')}: + - +
+
+ +
+ + +
+
+
+
+
+
@@ -443,5 +537,103 @@
+ + +
+
+ +
+
+

+ backup + ${_('backup.title')} +

+

${_('backup.desc')}

+
+
+
+
+ ${_('backup.stat_users')} + - +
+
+ ${_('backup.stat_settings')} + - +
+
+ ${_('backup.stat_folders')} + - +
+
+ ${_('backup.stat_groups')} + - +
+
+ ${_('backup.stat_strategies')} + - +
+
+ ${_('backup.stat_backend')} + - +
+
+
+ +
+
+
+ + +
+
+

+ restore + ${_('backup.restore_title')} +

+

${_('backup.restore_desc')}

+
+
+
+ + + + + + +
+
+ +
+ +
+
+
+
` }) %> diff --git a/web/app.py b/web/app.py deleted file mode 100644 index aa42f10c..00000000 --- a/web/app.py +++ /dev/null @@ -1,1629 +0,0 @@ -from flask import Flask, render_template, request, jsonify, redirect, url_for, g, send_file -import sqlite3 -import json -from datetime import datetime -import os -import requests -import re -from flask_wtf.csrf import CSRFProtect, generate_csrf -from flask_limiter import Limiter -from flask_limiter.util import get_remote_address -from werkzeug.utils import secure_filename - -# Import authentication module -from auth import ( - require_auth, require_role, optional_auth, - authenticate, create_session, verify_session, delete_session, - log_audit, cleanup_expired_sessions, - change_password, - ROLE_ADMIN, ROLE_OPERATOR, ROLE_VIEWER, - AuthError -) - -# Import client generator (optional - feature under development) -try: - from client_generator_module import generate_custom_client - CLIENT_GENERATOR_AVAILABLE = True -except ImportError: - CLIENT_GENERATOR_AVAILABLE = False - def generate_custom_client(*args, **kwargs): - return {'error': 'Client Generator module not available'} - -# Import source client generator (compiles from source) -try: - from source_client_generator import generate_from_source, get_build_status - SOURCE_GENERATOR_AVAILABLE = True -except ImportError: - SOURCE_GENERATOR_AVAILABLE = False - def generate_from_source(*args, **kwargs): - return {'success': False, 'error': 'Source Client Generator module not available'} - def get_build_status(*args, **kwargs): - return {'status': 'unavailable', 'message': 'Source Client Generator not available'} - -app = Flask(__name__) -app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY', os.urandom(32)) -app.config['WTF_CSRF_CHECK_DEFAULT'] = False # Manual CSRF for API - -# Load version for cache busting -VERSION_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'VERSION') -APP_VERSION = 'v1.5.0' # Default -try: - if os.path.exists(VERSION_FILE): - with open(VERSION_FILE, 'r') as f: - version_line = f.readline().strip() - if version_line: - APP_VERSION = version_line -except: - pass - -# Context processor to inject version into all templates -@app.context_processor -def inject_version(): - return {'app_version': APP_VERSION} - -# Cache control for static files -@app.after_request -def add_cache_headers(response): - """Add cache control headers to responses.""" - if request.path.startswith('/static/'): - # Static files: cache for 1 year if versioned, otherwise 5 minutes - if 'v=' in request.query_string.decode(): - response.cache_control.max_age = 31536000 # 1 year - response.cache_control.public = True - else: - response.cache_control.max_age = 300 # 5 minutes - elif request.path == '/' or request.path.endswith('.html'): - # HTML pages: no cache (always get fresh) - response.cache_control.no_cache = True - response.cache_control.no_store = True - response.cache_control.must_revalidate = True - response.headers['Pragma'] = 'no-cache' - response.headers['Expires'] = '0' - return response - -# Initialize CSRF protection -csrf = CSRFProtect() -csrf.init_app(app) - -# Initialize rate limiter -limiter = Limiter( - app=app, - key_func=get_remote_address, - default_limits=["1000 per hour", "100 per minute"], - storage_uri="memory://" -) - -# Initialize internationalization (i18n) system -try: - import i18n - i18n.init_app(app, csrf) # Pass csrf for CSRF exemption on upload/delete endpoints - I18N_AVAILABLE = True -except ImportError: - I18N_AVAILABLE = False - print("Warning: i18n module not available. Using default language.") - -# Configuration -DB_PATH = '/opt/rustdesk/db_v2.sqlite3' -PUB_KEY_PATH = '/opt/rustdesk/id_ed25519.pub' -API_KEY_PATH = '/opt/rustdesk/.api_key' -# HBBS API URL (optional - will fallback to database status if API unavailable) -HBBS_API_URL = 'http://localhost:21120/api' - -# Client Generator Configuration -UPLOAD_FOLDER = '/tmp/rustdesk_uploads' -ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg'} -MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB - -app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER -app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE - -# Create upload folder if not exists -os.makedirs(UPLOAD_FOLDER, exist_ok=True) - -# Load HBBS API key -def get_hbbs_api_key(): - """Load HBBS API key from file or environment variable.""" - # Try environment variable first - api_key = os.environ.get('HBBS_API_KEY') - if api_key: - return api_key - - # Try reading from file - try: - if os.path.exists(API_KEY_PATH): - with open(API_KEY_PATH, 'r') as f: - return f.read().strip() - except Exception as e: - print(f"Warning: Could not read API key from {API_KEY_PATH}: {e}") - - return None - -HBBS_API_KEY = get_hbbs_api_key() - -# Validation rules -MAX_NOTE_LENGTH = 500 -MAX_DEVICE_ID_LENGTH = 50 -DEVICE_ID_PATTERN = re.compile(r'^[a-zA-Z0-9_-]+$') - - -def validate_device_id(device_id): - """Validate device ID format and length.""" - if not device_id: - return False, "Device ID cannot be empty" - if len(device_id) > MAX_DEVICE_ID_LENGTH: - return False, f"Device ID too long (max {MAX_DEVICE_ID_LENGTH} characters)" - if not DEVICE_ID_PATTERN.match(device_id): - return False, "Device ID can only contain letters, numbers, underscores and hyphens" - return True, None - - -def validate_note(note): - """Validate note length.""" - if note and len(note) > MAX_NOTE_LENGTH: - return False, f"Note too long (max {MAX_NOTE_LENGTH} characters)" - return True, None - - -def validate_password_strength(password): - """Validate password strength - minimum 8 characters, letters and numbers.""" - if len(password) < 8: - return False, "Password must be at least 8 characters long" - if not re.search(r'[A-Za-z]', password): - return False, "Password must contain at least one letter" - if not re.search(r'[0-9]', password): - return False, "Password must contain at least one number" - return True, None - - -def sanitize_input(text): - """Basic sanitization of user input.""" - if not text: - return text - from markupsafe import escape - return str(escape(text)).strip() - - -def get_db_connection(): - """Create a read-write connection to the SQLite database.""" - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn - - -def get_public_key(): - """Read the RustDesk public key from file.""" - try: - if os.path.exists(PUB_KEY_PATH): - with open(PUB_KEY_PATH, 'r') as f: - key_content = f.read().strip() - return key_content - - rustdesk_dir = os.path.dirname(PUB_KEY_PATH) - if os.path.exists(rustdesk_dir): - pub_files = [f for f in os.listdir(rustdesk_dir) if f.endswith('.pub')] - if pub_files: - pub_file_path = os.path.join(rustdesk_dir, pub_files[0]) - with open(pub_file_path, 'r') as f: - key_content = f.read().strip() - return key_content - - return "" - except Exception as e: - return "" - - -# ============================================================================ -# AUTHENTICATION ROUTES -# ============================================================================ - -@app.route('/login') -def login_page(): - """Render login page""" - return render_template('login.html') - - -@app.route('/api/auth/login', methods=['POST']) -@limiter.limit("5 per minute") -@csrf.exempt # CSRF exempt for login, validated by credentials -def login(): - """Login endpoint""" - try: - data = request.get_json() - username = data.get('username', '').strip() - password = data.get('password', '') - - if not username or not password: - return jsonify({'success': False, 'error': 'Username and password required'}), 400 - - # Note: Password strength is only validated on creation/change, not login - # (to allow legacy accounts with weaker passwords to still login) - - # Authenticate user - user = authenticate(username, password) - - # Create session - token = create_session(user['id']) - - # Log login - log_audit(user['id'], 'login', None, f"Login from {request.remote_addr}", request.remote_addr) - - return jsonify({ - 'success': True, - 'token': token, - 'username': user['username'], - 'role': user['role'] - }) - - except AuthError as e: - return jsonify({'success': False, 'error': str(e)}), 401 - except Exception as e: - # Log the actual error for debugging - import traceback - print(f"Login error: {e}") - print(traceback.format_exc()) - return jsonify({'success': False, 'error': f'Login failed: {str(e)}'}), 500 - - -@app.route('/api/auth/logout', methods=['POST']) -@require_auth -def logout(): - """Logout endpoint""" - try: - token = request.headers.get('Authorization', '').replace('Bearer ', '') - - # Log logout - log_audit(g.user['user_id'], 'logout', None, 'User logged out', request.remote_addr) - - # Delete session - delete_session(token) - - return jsonify({'success': True, 'message': 'Logged out successfully'}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -@app.route('/api/auth/verify', methods=['GET']) -@require_auth -def verify_token(): - """Verify authentication token""" - return jsonify({ - 'success': True, - 'user': { - 'username': g.user['username'], - 'role': g.user['role'] - } - }) - - -@app.route('/health') -def health_check(): - """Health check endpoint for Docker and load balancers""" - try: - # Test database connection - conn = sqlite3.connect(DB_PATH) - cursor = conn.cursor() - cursor.execute("SELECT 1") - conn.close() - - return jsonify({ - 'status': 'healthy', - 'timestamp': datetime.now().isoformat(), - 'version': '1.5.0', - 'database': 'accessible' - }), 200 - except Exception as e: - return jsonify({ - 'status': 'unhealthy', - 'timestamp': datetime.now().isoformat(), - 'error': str(e) - }), 503 - - -# ============================================================================ -# MAIN ROUTES -# ============================================================================ - -@app.route('/') -def index(): - """Render the main dashboard page. Auth check done by JavaScript on client side.""" - return render_template('index.html') - - -@app.route('/client-generator') -def client_generator(): - """Render the client generator page. Auth check done by JavaScript on client side.""" - return render_template('client_generator.html') - - -@app.route('/api/devices', methods=['GET']) -@require_auth -@limiter.exempt # Authenticated users bypass rate limit -def get_devices(): - """Fetch all devices from the database with online status based on last_online.""" - try: - # Get server config for timeout settings - config = get_server_config() - peer_timeout_secs = config.get('peer_timeout_secs', 60) - warning_threshold = config.get('warning_threshold', 2) - critical_threshold = config.get('critical_threshold', 4) - heartbeat_interval = config.get('heartbeat_interval_secs', 5) - - # Get devices from database - conn = get_db_connection() - cursor = conn.cursor() - - # Check if last_online column exists - cursor.execute("PRAGMA table_info(peer)") - columns = [row[1] for row in cursor.fetchall()] - has_last_online = 'last_online' in columns - - if has_last_online: - cursor.execute(''' - SELECT - guid, id, uuid, pk, created_at, user, status, note, info, - is_banned, banned_at, banned_by, ban_reason, - previous_ids, id_changed_at, last_online - FROM peer - WHERE is_deleted = 0 - ORDER BY created_at DESC - ''') - else: - cursor.execute(''' - SELECT - guid, id, uuid, pk, created_at, user, status, note, info, - is_banned, banned_at, banned_by, ban_reason, - previous_ids, id_changed_at - FROM peer - WHERE is_deleted = 0 - ORDER BY created_at DESC - ''') - - devices = [] - now = datetime.now() - - for row in cursor.fetchall(): - device_id = row['id'] - - # Determine online status based on last_online timestamp - online = False - status_detail = 'offline' - last_online = None - - if has_last_online and row['last_online']: - last_online = row['last_online'] - try: - # Parse timestamp - handle both formats - if isinstance(last_online, str): - if 'T' in last_online: - last_online_dt = datetime.fromisoformat(last_online.replace('Z', '+00:00').replace('+00:00', '')) - else: - last_online_dt = datetime.strptime(last_online, '%Y-%m-%d %H:%M:%S') - else: - last_online_dt = last_online - - # Calculate time since last activity - seconds_since = (now - last_online_dt).total_seconds() - - if seconds_since <= peer_timeout_secs: - # Within timeout - check for degraded/critical states - missed_heartbeats = int(seconds_since / heartbeat_interval) - - if missed_heartbeats >= critical_threshold: - online = True - status_detail = 'critical' - elif missed_heartbeats >= warning_threshold: - online = True - status_detail = 'degraded' - else: - online = True - status_detail = 'online' - else: - status_detail = 'offline' - except Exception as e: - print(f"Warning: Could not parse last_online for {device_id}: {e}") - # Fallback to status field - online = row['status'] == 1 - status_detail = 'online' if online else 'offline' - else: - # No last_online - fallback to status field - online = row['status'] == 1 - status_detail = 'online' if online else 'offline' - - device = { - 'guid': row['guid'].hex() if row['guid'] else '', - 'id': device_id, - 'uuid': row['uuid'].hex() if row['uuid'] else '', - 'pk': row['pk'].hex() if row['pk'] else '', - 'created_at': row['created_at'], - 'user': row['user'].hex() if row['user'] else '', - 'status': row['status'], - 'online': online, - 'status_detail': status_detail, - 'last_online': last_online, - 'note': row['note'] or '', - 'info': row['info'] or '', - 'is_banned': row['is_banned'] == 1, - 'banned_at': row['banned_at'], - 'banned_by': row['banned_by'] or '', - 'ban_reason': row['ban_reason'] or '', - 'previous_ids': json.loads(row['previous_ids']) if row['previous_ids'] else [], - 'id_changed_at': row['id_changed_at'] or '' - } - devices.append(device) - - conn.close() - return jsonify({'success': True, 'devices': devices, 'config': config}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -@app.route('/api/device/', methods=['PUT']) -@require_auth -@require_role(ROLE_ADMIN, ROLE_OPERATOR) -def update_device(device_id): - """Update a device's note and/or ID.""" - try: - is_valid, error_msg = validate_device_id(device_id) - if not is_valid: - return jsonify({'success': False, 'error': error_msg}), 400 - - data = request.get_json() - if not data: - return jsonify({'success': False, 'error': 'No data provided'}), 400 - - conn = get_db_connection() - cursor = conn.cursor() - - updates = [] - params = [] - - if 'note' in data: - is_valid, error_msg = validate_note(data['note']) - if not is_valid: - conn.close() - return jsonify({'success': False, 'error': error_msg}), 400 - - sanitized_note = sanitize_input(data['note']) - updates.append('note = ?') - params.append(sanitized_note) - - if 'new_id' in data and data['new_id']: - is_valid, error_msg = validate_device_id(data['new_id']) - if not is_valid: - conn.close() - return jsonify({'success': False, 'error': error_msg}), 400 - - cursor.execute('SELECT id FROM peer WHERE id = ? AND is_deleted = 0', (data['new_id'],)) - if cursor.fetchone(): - conn.close() - return jsonify({'success': False, 'error': 'Device ID already exists'}), 409 - - # Get current previous_ids and add old_id to history - cursor.execute('SELECT previous_ids FROM peer WHERE id = ? AND is_deleted = 0', (device_id,)) - row = cursor.fetchone() - previous_ids = [] - if row and row[0]: - try: - previous_ids = json.loads(row[0]) if row[0] else [] - except: - previous_ids = [] - previous_ids.append(device_id) - - updates.append('id = ?') - params.append(data['new_id']) - updates.append('previous_ids = ?') - params.append(json.dumps(previous_ids)) - updates.append('id_changed_at = ?') - params.append(datetime.now().strftime('%Y-%m-%d %H:%M:%S')) - - if not updates: - conn.close() - return jsonify({'success': False, 'error': 'No fields to update'}), 400 - - updates.append('updated_at = ?') - params.append(int(datetime.now().timestamp() * 1000)) - params.append(device_id) - - query = f"UPDATE peer SET {', '.join(updates)} WHERE id = ? AND is_deleted = 0" - cursor.execute(query, params) - conn.commit() - affected = cursor.rowcount - conn.close() - - if affected == 0: - return jsonify({'success': False, 'error': 'Device not found'}), 404 - - # Log audit with selective data - audit_details = [] - if 'note' in data: - audit_details.append(f"note: {data.get('note', '')[:50]}...") # First 50 chars - if 'new_id' in data: - audit_details.append(f"new_id: {data['new_id']}") - log_audit(g.user['user_id'], 'update_device', device_id, - f"Updated: {', '.join(audit_details)}", request.remote_addr or 'unknown') - - return jsonify({'success': True, 'message': 'Device updated successfully'}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -@app.route('/api/device/', methods=['DELETE']) -@require_auth -@require_role(ROLE_ADMIN, ROLE_OPERATOR) -def delete_device(device_id): - """Soft delete a device from the database.""" - try: - conn = get_db_connection() - cursor = conn.cursor() - - deleted_at = int(datetime.now().timestamp() * 1000) - cursor.execute( - 'UPDATE peer SET is_deleted = 1, deleted_at = ? WHERE id = ? AND is_deleted = 0', - (deleted_at, device_id) - ) - conn.commit() - affected = cursor.rowcount - conn.close() - - if affected == 0: - return jsonify({'success': False, 'error': 'Device not found'}), 404 - - # Log audit - log_audit(g.user['user_id'], 'delete_device', device_id, - 'Device deleted', request.remote_addr or 'unknown') - - return jsonify({'success': True, 'message': 'Device deleted successfully'}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -@app.route('/api/stats', methods=['GET']) -@require_auth -@limiter.exempt # Authenticated users bypass rate limit -def get_stats(): - """Get statistics about the devices.""" - try: - online_count = 0 - try: - headers = {} - if HBBS_API_KEY: - headers['X-API-Key'] = HBBS_API_KEY - - response = requests.get(f'{HBBS_API_URL}/peers', headers=headers, timeout=2) - if response.status_code == 200: - api_data = response.json() - if api_data.get('success') and api_data.get('data'): - online_count = sum(1 for peer in api_data['data'] if peer.get('online')) - except Exception as e: - print(f"Warning: Could not connect to HBBS API for stats: {e}") - - conn = get_db_connection() - cursor = conn.cursor() - - cursor.execute('SELECT COUNT(*) as total FROM peer WHERE is_deleted = 0') - total = cursor.fetchone()['total'] - - cursor.execute('SELECT COUNT(*) as banned FROM peer WHERE is_banned = 1 AND is_deleted = 0') - banned = cursor.fetchone()['banned'] - - if online_count == 0: - cursor.execute('SELECT COUNT(*) as active FROM peer WHERE status = 1 AND is_deleted = 0') - online_count = cursor.fetchone()['active'] - - cursor.execute('SELECT COUNT(*) as with_notes FROM peer WHERE note IS NOT NULL AND note != "" AND is_deleted = 0') - with_notes = cursor.fetchone()['with_notes'] - - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': total, - 'active': online_count, - 'inactive': total - online_count, - 'with_notes': with_notes, - 'banned': banned - } - }) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -# Default server configuration -DEFAULT_SERVER_CONFIG = { - 'peer_timeout_secs': 60, - 'heartbeat_interval_secs': 5, - 'warning_threshold': 2, - 'critical_threshold': 4 -} - -def ensure_server_config_table(): - """Ensure server_config table exists.""" - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(''' - CREATE TABLE IF NOT EXISTS server_config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - ''') - conn.commit() - conn.close() - -def get_server_config(): - """Get server configuration from database.""" - ensure_server_config_table() - config = DEFAULT_SERVER_CONFIG.copy() - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute('SELECT key, value FROM server_config') - rows = cursor.fetchall() - conn.close() - for row in rows: - key = row['key'] - if key in config: - try: - config[key] = int(row['value']) - except ValueError: - config[key] = row['value'] - except Exception as e: - print(f"Warning: Could not load server config: {e}") - return config - -def save_server_config(config): - """Save server configuration to database.""" - ensure_server_config_table() - conn = get_db_connection() - cursor = conn.cursor() - for key, value in config.items(): - cursor.execute(''' - INSERT OR REPLACE INTO server_config (key, value, updated_at) - VALUES (?, ?, CURRENT_TIMESTAMP) - ''', (key, str(value))) - conn.commit() - conn.close() - - -@app.route('/api/server/config', methods=['GET']) -@require_auth -@limiter.exempt -def get_server_config_endpoint(): - """Get server configuration.""" - try: - config = get_server_config() - return jsonify({ - 'success': True, - 'config': config - }) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -@app.route('/api/server/config', methods=['POST']) -@require_auth -@require_role(ROLE_ADMIN) -def update_server_config_endpoint(): - """Update server configuration (admin only).""" - try: - data = request.get_json() - if not data: - return jsonify({'success': False, 'error': 'No data provided'}), 400 - - # Validate and sanitize config values - config = get_server_config() - - if 'peer_timeout_secs' in data: - val = int(data['peer_timeout_secs']) - if val < 10 or val > 300: - return jsonify({'success': False, 'error': 'peer_timeout_secs must be between 10 and 300'}), 400 - config['peer_timeout_secs'] = val - - if 'heartbeat_interval_secs' in data: - val = int(data['heartbeat_interval_secs']) - if val < 1 or val > 30: - return jsonify({'success': False, 'error': 'heartbeat_interval_secs must be between 1 and 30'}), 400 - config['heartbeat_interval_secs'] = val - - if 'warning_threshold' in data: - val = int(data['warning_threshold']) - if val < 1 or val > 10: - return jsonify({'success': False, 'error': 'warning_threshold must be between 1 and 10'}), 400 - config['warning_threshold'] = val - - if 'critical_threshold' in data: - val = int(data['critical_threshold']) - if val < 2 or val > 20: - return jsonify({'success': False, 'error': 'critical_threshold must be between 2 and 20'}), 400 - config['critical_threshold'] = val - - # Validate thresholds relationship - if config['warning_threshold'] >= config['critical_threshold']: - return jsonify({'success': False, 'error': 'warning_threshold must be less than critical_threshold'}), 400 - - save_server_config(config) - - # Log the change - user_id = g.user.get('id') if g.user else None - log_audit(user_id, 'config_change', None, f"Server config updated: {config}") - - return jsonify({ - 'success': True, - 'message': 'Configuration saved successfully', - 'config': config - }) - except ValueError as e: - return jsonify({'success': False, 'error': 'Invalid numeric value'}), 400 - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -@app.route('/api/device//ban', methods=['POST']) -@require_auth -@require_role(ROLE_ADMIN, ROLE_OPERATOR) -def ban_device(device_id): - """Ban a device.""" - try: - is_valid, error_msg = validate_device_id(device_id) - if not is_valid: - return jsonify({'success': False, 'error': error_msg}), 400 - - data = request.get_json() or {} - - ban_reason = sanitize_input(data.get('reason', '')) - if ban_reason and len(ban_reason) > 500: - return jsonify({'success': False, 'error': 'Ban reason too long'}), 400 - - banned_by = g.user['username'] - - conn = get_db_connection() - cursor = conn.cursor() - - cursor.execute('SELECT id, is_banned FROM peer WHERE id = ? AND is_deleted = 0', (device_id,)) - device = cursor.fetchone() - - if not device: - conn.close() - return jsonify({'success': False, 'error': 'Device not found'}), 404 - - if device['is_banned'] == 1: - conn.close() - return jsonify({'success': False, 'error': 'Device is already banned'}), 409 - - banned_at = int(datetime.now().timestamp() * 1000) - cursor.execute(''' - UPDATE peer - SET is_banned = 1, banned_at = ?, banned_by = ?, ban_reason = ?, updated_at = ? - WHERE id = ? AND is_deleted = 0 - ''', (banned_at, banned_by, ban_reason, banned_at, device_id)) - - conn.commit() - conn.close() - - # Log audit - log_audit(g.user['user_id'], 'ban_device', device_id, - f"Banned device. Reason: {ban_reason}", request.remote_addr) - - return jsonify({ - 'success': True, - 'message': f'Device {device_id} banned successfully', - 'banned_at': banned_at, - 'banned_by': banned_by - }) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -@app.route('/api/device//unban', methods=['POST']) -@require_auth -@require_role(ROLE_ADMIN, ROLE_OPERATOR) -def unban_device(device_id): - """Unban a device.""" - try: - is_valid, error_msg = validate_device_id(device_id) - if not is_valid: - return jsonify({'success': False, 'error': error_msg}), 400 - - conn = get_db_connection() - cursor = conn.cursor() - - cursor.execute('SELECT id, is_banned FROM peer WHERE id = ? AND is_deleted = 0', (device_id,)) - device = cursor.fetchone() - - if not device: - conn.close() - return jsonify({'success': False, 'error': 'Device not found'}), 404 - - if device['is_banned'] == 0: - conn.close() - return jsonify({'success': False, 'error': 'Device is not banned'}), 409 - - updated_at = int(datetime.now().timestamp() * 1000) - cursor.execute(''' - UPDATE peer - SET is_banned = 0, banned_at = NULL, banned_by = NULL, ban_reason = NULL, updated_at = ? - WHERE id = ? AND is_deleted = 0 - ''', (updated_at, device_id)) - - conn.commit() - conn.close() - - # Log audit - log_audit(g.user['user_id'], 'unban_device', device_id, - 'Device unbanned', request.remote_addr) - - return jsonify({ - 'success': True, - 'message': f'Device {device_id} unbanned successfully' - }) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -# ============================================================================ -# USER MANAGEMENT ROUTES (Admin only) -# ============================================================================ - -@app.route('/api/users', methods=['GET']) -@require_auth -@require_role(ROLE_ADMIN) -def list_all_users(): - """List all users (admin only)""" - try: - from auth import list_users - users = list_users() - - # Don't send password hashes - for user in users: - user.pop('password_hash', None) - - return jsonify({'success': True, 'users': users}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -@app.route('/api/users', methods=['POST']) -@require_auth -@require_role(ROLE_ADMIN) -def create_new_user(): - """Create new user (admin only)""" - try: - data = request.get_json() - username = sanitize_input(data.get('username', '')) - password = data.get('password', '') - role = data.get('role', ROLE_VIEWER) - - if not username or not password: - return jsonify({'success': False, 'error': 'Username and password required'}), 400 - - # Validate password strength - is_valid, error_msg = validate_password_strength(password) - if not is_valid: - return jsonify({'success': False, 'error': error_msg}), 400 - - from auth import create_user - user = create_user(username, password, role) - - # Log audit - log_audit(g.user['user_id'], 'create_user', None, - f"Created user: {username} with role: {role}", request.remote_addr) - - return jsonify({'success': True, 'message': 'User created successfully', 'user': user}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 400 - - -@app.route('/api/users/', methods=['PUT']) -@require_auth -@require_role(ROLE_ADMIN) -def update_user(user_id): - """Update user (admin only)""" - try: - data = request.get_json() - action = data.get('action') - - from auth import update_user_role, activate_user, deactivate_user, reset_password - - if action == 'change_role': - new_role = data.get('role') - if not new_role: - return jsonify({'success': False, 'error': 'Role required'}), 400 - - update_user_role(user_id, new_role) - log_audit(g.user['user_id'], 'update_user_role', None, - f"Changed role of user {user_id} to {new_role}", request.remote_addr) - return jsonify({'success': True, 'message': 'User role updated'}) - - elif action == 'activate': - activate_user(user_id) - log_audit(g.user['user_id'], 'activate_user', None, - f"Activated user {user_id}", request.remote_addr) - return jsonify({'success': True, 'message': 'User activated'}) - - elif action == 'deactivate': - deactivate_user(user_id) - log_audit(g.user['user_id'], 'deactivate_user', None, - f"Deactivated user {user_id}", request.remote_addr) - return jsonify({'success': True, 'message': 'User deactivated'}) - - elif action == 'reset_password': - new_password = data.get('password') - if not new_password: - return jsonify({'success': False, 'error': 'Password required'}), 400 - - # Validate password strength - is_valid, error_msg = validate_password_strength(new_password) - if not is_valid: - return jsonify({'success': False, 'error': error_msg}), 400 - - reset_password(user_id, new_password) - log_audit(g.user['user_id'], 'reset_password', None, - f"Reset password for user {user_id}", request.remote_addr) - return jsonify({'success': True, 'message': 'Password reset successfully'}) - - else: - return jsonify({'success': False, 'error': 'Invalid action'}), 400 - - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 400 - - -@app.route('/api/users/', methods=['DELETE']) -@require_auth -@require_role(ROLE_ADMIN) -def delete_user_account(user_id): - """Delete user (admin only)""" - try: - # Prevent self-deletion - if user_id == g.user['user_id']: - return jsonify({'success': False, 'error': 'Cannot delete your own account'}), 400 - - from auth import delete_user - delete_user(user_id) - - log_audit(g.user['user_id'], 'delete_user', None, - f"Deleted user {user_id}", request.remote_addr) - - return jsonify({'success': True, 'message': 'User deleted successfully'}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -# ============================================================================ -# PASSWORD CHANGE & KEY VERIFICATION -# ============================================================================ - -@app.route('/api/auth/change-password', methods=['POST']) -@require_auth -def change_user_password(): - """Change current user's password""" - try: - data = request.get_json() - old_password = data.get('old_password', '') - new_password = data.get('new_password', '') - - if not old_password or not new_password: - return jsonify({'success': False, 'error': 'Old and new password required'}), 400 - - # Validate password strength - is_valid, error_msg = validate_password_strength(new_password) - if not is_valid: - return jsonify({'success': False, 'error': error_msg}), 400 - - # Change password and get new token - new_token = change_password(g.user['user_id'], old_password, new_password) - - log_audit(g.user['user_id'], 'change_password', None, - 'User changed password', request.remote_addr) - - return jsonify({ - 'success': True, - 'message': 'Password changed successfully', - 'token': new_token - }) - except AuthError as e: - return jsonify({'success': False, 'error': str(e)}), 400 - except Exception as e: - return jsonify({'success': False, 'error': 'Password change failed'}), 500 - - -@app.route('/api/auth/verify-password', methods=['POST']) -@require_auth -def verify_password_endpoint(): - """Verify user's password (for accessing protected content like public key)""" - try: - data = request.get_json() - password = data.get('password', '') - - if not password: - return jsonify({'success': False, 'error': 'Password required'}), 400 - - # Verify user's password - from auth import verify_password as verify_pwd, get_auth_db - conn = get_auth_db() - cursor = conn.cursor() - cursor.execute('SELECT password_hash FROM users WHERE id = ?', (g.user['user_id'],)) - user = cursor.fetchone() - conn.close() - - if not user or not verify_pwd(password, user['password_hash']): - log_audit(g.user['user_id'], 'password_verify_failed', None, - 'Failed password verification', request.remote_addr) - return jsonify({'success': False, 'error': 'Invalid password'}), 401 - - log_audit(g.user['user_id'], 'password_verify_success', None, - 'Password verified successfully', request.remote_addr) - - return jsonify({'success': True, 'message': 'Password verified'}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -@app.route('/api/public-key', methods=['GET']) -@require_auth -def get_public_key_endpoint(): - """Get server public key (requires prior password verification in frontend)""" - try: - # Only admin and operator can view key - if g.user['role'] not in [ROLE_ADMIN, ROLE_OPERATOR]: - return jsonify({'success': False, 'error': 'Insufficient permissions'}), 403 - - public_key = get_public_key() - - log_audit(g.user['user_id'], 'view_public_key', None, - 'Accessed public key', request.remote_addr) - - return jsonify({'success': True, 'key': public_key}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -# ============================================================================ -# CLIENT GENERATOR ROUTES (v2 - improved) -# ============================================================================ - -import requests as http_requests # Avoid conflict with Flask request - - -@app.route('/api/generator/versions') -@require_auth -def api_generator_versions(): - """Get available RustDesk versions from GitHub""" - try: - response = http_requests.get( - 'https://api.github.com/repos/rustdesk/rustdesk/releases', - timeout=10, - headers={'Accept': 'application/vnd.github.v3+json'} - ) - response.raise_for_status() - releases = response.json() - - versions = [] - for release in releases[:15]: # Last 15 releases - tag = release.get('tag_name', '') - if tag: - versions.append({ - 'tag': tag.lstrip('v'), - 'name': release.get('name', tag), - 'published': release.get('published_at', ''), - 'prerelease': release.get('prerelease', False) - }) - - return jsonify({ - 'success': True, - 'versions': versions - }) - except Exception as e: - # Fallback versions (updated) - return jsonify({ - 'success': True, - 'versions': [ - {'tag': '1.4.5', 'name': 'v1.4.5', 'prerelease': False}, - {'tag': '1.4.4', 'name': 'v1.4.4', 'prerelease': False}, - {'tag': '1.3.7', 'name': 'v1.3.7', 'prerelease': False}, - {'tag': '1.3.6', 'name': 'v1.3.6', 'prerelease': False}, - {'tag': '1.3.5', 'name': 'v1.3.5', 'prerelease': False}, - {'tag': '1.3.2', 'name': 'v1.3.2', 'prerelease': False}, - ] - }) - - -@app.route('/api/generator/info') -@require_auth -def api_generator_info(): - """Get generator capabilities info""" - try: - return jsonify({ - 'success': True, - 'sourceCompilationAvailable': SOURCE_GENERATOR_AVAILABLE, - 'configInjectionAvailable': CLIENT_GENERATOR_AVAILABLE, - 'supportedPlatformsSource': ['linux-x64', 'linux-arm64', 'windows-x64', 'windows-x86'] if SOURCE_GENERATOR_AVAILABLE else [], - 'supportedPlatformsConfig': ['windows-x64', 'windows-x86', 'linux-x64', 'linux-arm64', 'macos-x64', 'macos-arm64', 'android'], - 'defaultVersion': '1.4.5', - 'recommendedMethod': 'source' if SOURCE_GENERATOR_AVAILABLE else 'config', - 'notes': { - 'source': 'Source compilation - full customization, may take 5-15 minutes', - 'config': 'Config injection - fast, limited customization' - } - }) - except Exception as e: - return jsonify({ - 'success': False, - 'error': str(e) - }), 500 - - -@app.route('/api/generator/build', methods=['POST']) -@require_auth -@csrf.exempt -def api_generator_build(): - """Generate a custom RustDesk client (JSON API)""" - try: - data = request.get_json() - if not data: - return jsonify({'success': False, 'error': 'No data provided'}), 400 - - # Validate required fields - if not data.get('serverHost'): - return jsonify({'success': False, 'error': 'Server address is required'}), 400 - if not data.get('serverKey'): - return jsonify({'success': False, 'error': 'Server public key is required'}), 400 - - # Log the action - log_audit(g.user['username'], 'generate_client', - f"Building client for {data.get('platform', 'unknown')}", request.remote_addr) - - # Check if source compilation is requested - compile_from_source = data.get('compileFromSource', False) - - # Get original platform name from request - original_platform = data.get('platform', 'windows-x64') - - # Map platform names (only for config injection method) - platform_map = { - 'windows-x64': 'windows-64', - 'windows-x86': 'windows-32', - 'linux-x64': 'linux', - 'linux-arm64': 'linux-arm64', - 'macos-x64': 'macos', - 'macos-arm64': 'macos-arm64', - 'android': 'android' - } - - # Use original platform for source compilation, mapped for config injection - if compile_from_source and SOURCE_GENERATOR_AVAILABLE: - platform_for_generator = original_platform # source generator expects windows-x64, linux-x64 etc. - else: - platform_for_generator = platform_map.get(original_platform, 'windows-64') - - # Build config for generator module - config_data = { - 'platform': platform_for_generator, - 'version': data.get('version', '1.4.5'), - 'config_name': data.get('clientName', 'Custom-RustDesk'), - - # Server config - 'server_host': data.get('serverHost', ''), - 'server_key': data.get('serverKey', ''), - 'server_api': data.get('apiServer', ''), - 'rendezvous_server': data.get('rendezvousServer', ''), - - # Branding / Customization - 'app_name': data.get('appName', ''), - 'logo_base64': data.get('logoBase64', ''), - 'logo_url': data.get('logoUrl', ''), - 'custom_text': data.get('customText', ''), - 'icon_base64': data.get('iconBase64', ''), - - # Security - 'password_approve_mode': data.get('approvalMode', 'both'), - 'permanent_password': data.get('permanentPassword', ''), - 'deny_lan_discovery': data.get('denyLanDiscovery', False), - 'enable_direct_ip': data.get('enableDirectIP', False), - - # Display - 'theme': data.get('theme', 'system'), - 'view_mode': data.get('viewMode', 'adaptive'), - 'remove_wallpaper': data.get('removeWallpaper', False), - 'show_quality_monitor': data.get('showQualityMonitor', False), - - # Permissions - 'perm_keyboard': data.get('permissions', {}).get('keyboard', True), - 'perm_clipboard': data.get('permissions', {}).get('clipboard', True), - 'perm_file_transfer': data.get('permissions', {}).get('fileTransfer', True), - 'perm_audio': data.get('permissions', {}).get('audio', True), - 'perm_tcp_tunnel': data.get('permissions', {}).get('tcpTunnel', False), - 'perm_remote_restart': data.get('permissions', {}).get('restart', False), - 'perm_recording': data.get('permissions', {}).get('recording', False), - 'perm_block_input': data.get('permissions', {}).get('blockInput', False), - - # Advanced - 'default_settings': data.get('defaultSettings', ''), - 'override_settings': data.get('overrideSettings', ''), - } - - # Choose generator based on compile mode - app.logger.info(f"Build request: compile_from_source={compile_from_source}, SOURCE_GENERATOR_AVAILABLE={SOURCE_GENERATOR_AVAILABLE}") - if compile_from_source and SOURCE_GENERATOR_AVAILABLE: - # Use source compilation - app.logger.info(f"Using SOURCE compilation for {data.get('platform', 'unknown')}") - log_audit(g.user['username'], 'compile_client', - f"Starting source compilation for {data.get('platform', 'unknown')}", request.remote_addr) - result = generate_from_source(config_data) - else: - # Use legacy config-injection method - app.logger.info(f"Using CONFIG INJECTION for {data.get('platform', 'unknown')}") - result = generate_custom_client(config_data) - - if result.get('success'): - import os - filename = result.get('filename', 'client') - file_path = result.get('client_path', '') - file_size = 'N/A' - - if file_path and os.path.exists(file_path): - size_bytes = os.path.getsize(file_path) - if size_bytes > 1024 * 1024: - file_size = f"{size_bytes / (1024 * 1024):.1f} MB" - else: - file_size = f"{size_bytes / 1024:.1f} KB" - - # Generate build ID from filename - build_id = result.get('build_id', filename.replace('.', '_')) - - # Determine download URL based on generator type - if compile_from_source and SOURCE_GENERATOR_AVAILABLE: - download_url = f'/api/download-client/{filename}' - else: - download_url = f'/api/generator/download/{filename}' - - return jsonify({ - 'success': True, - 'filename': filename, - 'buildId': build_id, - 'size': file_size, - 'downloadUrl': download_url, - 'compiledFromSource': compile_from_source and SOURCE_GENERATOR_AVAILABLE, - 'message': result.get('message', 'Build completed successfully') - }) - else: - return jsonify({ - 'success': False, - 'error': result.get('error', 'Generation failed') - }), 500 - - except Exception as e: - import traceback - traceback.print_exc() - return jsonify({ - 'success': False, - 'error': str(e) - }), 500 - - -@app.route('/api/generator/download/') -@require_auth -def api_generator_download(filename): - """Download a generated client""" - try: - from werkzeug.utils import secure_filename as sf - filename = sf(filename) - - file_path = os.path.join('/tmp/rustdesk_builds', filename) - - if not os.path.exists(file_path): - return jsonify({'success': False, 'error': 'File not found'}), 404 - - log_audit(g.user['username'], 'download_client', - f'Downloaded {filename}', request.remote_addr) - - return send_file( - file_path, - as_attachment=True, - download_name=filename - ) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -# ============================================================================ -# CLIENT GENERATOR ROUTES (Legacy - kept for compatibility) -# ============================================================================ - -def allowed_file(filename): - """Check if file extension is allowed""" - return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS - - -@app.route('/api/generate-client', methods=['POST']) -@require_auth -@csrf.exempt -def api_generate_client(): - """Generate a custom RustDesk client""" - try: - # Log the action - log_audit(g.user['username'], 'generate_client', - f'Generating custom RustDesk client', request.remote_addr) - - # Collect form data - config_data = { - 'platform': request.form.get('platform', 'windows-64'), - 'version': request.form.get('version', '1.4.5'), - 'fix_api_delay': request.form.get('fix_api_delay') == 'true', - - # General - 'config_name': request.form.get('config_name', 'custom-rustdesk'), - 'custom_app_name': request.form.get('custom_app_name', ''), - 'connection_type': request.form.get('connection_type', 'bidirectional'), - 'disable_installation': request.form.get('disable_installation', 'no'), - 'disable_settings': request.form.get('disable_settings', 'no'), - 'android_app_id': request.form.get('android_app_id', ''), - - # Server - 'server_host': request.form.get('server_host', ''), - 'server_key': request.form.get('server_key', ''), - 'server_api': request.form.get('server_api', ''), - 'custom_url_links': request.form.get('custom_url_links', ''), - 'custom_url_download': request.form.get('custom_url_download', ''), - 'company_copyright': request.form.get('company_copyright', ''), - - # Security - 'password_approve_mode': request.form.get('password_approve_mode', 'both'), - 'permanent_password': request.form.get('permanent_password', ''), - 'deny_lan_discovery': request.form.get('deny_lan_discovery') == 'true', - 'enable_direct_ip': request.form.get('enable_direct_ip') == 'true', - 'auto_close_inactive': request.form.get('auto_close_inactive') == 'true', - 'allow_hide_window': request.form.get('allow_hide_window') == 'true', - - # Visual - 'theme': request.form.get('theme', 'follow'), - 'theme_override': request.form.get('theme_override', 'default'), - - # Permissions - 'permissions_mode': request.form.get('permissions_mode', 'default'), - 'permission_type': request.form.get('permission_type', 'custom'), - 'perm_keyboard': request.form.get('perm_keyboard') == 'true', - 'perm_clipboard': request.form.get('perm_clipboard') == 'true', - 'perm_file_transfer': request.form.get('perm_file_transfer') == 'true', - 'perm_audio': request.form.get('perm_audio') == 'true', - 'perm_tcp_tunnel': request.form.get('perm_tcp_tunnel') == 'true', - 'perm_remote_restart': request.form.get('perm_remote_restart') == 'true', - 'perm_recording': request.form.get('perm_recording') == 'true', - 'perm_block_input': request.form.get('perm_block_input') == 'true', - 'perm_remote_config': request.form.get('perm_remote_config') == 'true', - 'perm_printer': request.form.get('perm_printer') == 'true', - 'perm_camera': request.form.get('perm_camera') == 'true', - 'perm_terminal': request.form.get('perm_terminal') == 'true', - - # Code changes - 'code_monitor_cycle': request.form.get('code_monitor_cycle') == 'true', - 'code_offline_x': request.form.get('code_offline_x') == 'true', - 'code_remove_version_notif': request.form.get('code_remove_version_notif') == 'true', - - # Other - 'remove_wallpaper': request.form.get('remove_wallpaper') == 'true', - 'default_settings': request.form.get('default_settings', ''), - 'override_settings': request.form.get('override_settings', ''), - } - - # Handle file uploads - if 'custom_icon' in request.files: - icon_file = request.files['custom_icon'] - if icon_file and icon_file.filename and allowed_file(icon_file.filename): - filename = secure_filename(icon_file.filename) - icon_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{config_data['config_name']}_icon_{filename}") - icon_file.save(icon_path) - config_data['custom_icon_path'] = icon_path - - if 'custom_logo' in request.files: - logo_file = request.files['custom_logo'] - if logo_file and logo_file.filename and allowed_file(logo_file.filename): - filename = secure_filename(logo_file.filename) - logo_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{config_data['config_name']}_logo_{filename}") - logo_file.save(logo_path) - config_data['custom_logo_path'] = logo_path - - # Generate the client - result = generate_custom_client(config_data) - - if result['success']: - # Create download URL - filename = result['filename'] - download_url = f'/api/download-client/{filename}' - - return jsonify({ - 'success': True, - 'download_url': download_url, - 'filename': filename - }) - else: - return jsonify({ - 'success': False, - 'error': result.get('error', 'Unknown error occurred') - }), 500 - - except Exception as e: - return jsonify({ - 'success': False, - 'error': str(e) - }), 500 - - -@app.route('/api/download-client/') -@require_auth -def download_client(filename): - """Download a generated client""" - try: - # Sanitize filename - filename = secure_filename(filename) - - # Try source generator builds directory first - source_build_path = os.path.expanduser(f'~/rustdesk-build/builds/{filename}') - - # Fallback to legacy client generator path - legacy_path = os.path.join('/tmp/rustdesk_builds', filename) - - # Check which file exists - if os.path.exists(source_build_path): - file_path = source_build_path - elif os.path.exists(legacy_path): - file_path = legacy_path - else: - return jsonify({'success': False, 'error': 'File not found'}), 404 - - # Log the download - log_audit(g.user['username'], 'download_client', - f'Downloaded client: {filename}', request.remote_addr) - - # Send file - return send_file(file_path, as_attachment=True, download_name=filename) - - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -@app.route('/api/compile-client', methods=['POST']) -@require_auth -@csrf.exempt -def api_compile_client(): - """Compile a custom RustDesk client from source""" - try: - if not SOURCE_GENERATOR_AVAILABLE: - return jsonify({ - 'success': False, - 'error': 'Source compilation not available on this server' - }), 503 - - # Log the action - log_audit(g.user['username'], 'compile_client', - f'Starting source compilation', request.remote_addr) - - # Collect form data - config_data = { - 'platform': request.form.get('platform', 'linux'), - 'version': request.form.get('version', '1.4.5'), - - # General - 'config_name': request.form.get('config_name', 'custom-rustdesk'), - 'app_name': request.form.get('app_name', ''), - 'custom_text': request.form.get('custom_text', ''), - - # Server - 'server_host': request.form.get('server_host', ''), - 'server_key': request.form.get('server_key', ''), - 'server_api': request.form.get('server_api', ''), - - # Branding - 'logo_base64': request.form.get('logo_base64', ''), - 'icon_base64': request.form.get('icon_base64', ''), - } - - # Compile the client from source - result = generate_from_source(config_data) - - if result['success']: - # Create download URL - filename = result['filename'] - download_url = f'/api/download-client/{filename}' - - return jsonify({ - 'success': True, - 'build_id': result.get('build_id', ''), - 'download_url': download_url, - 'filename': filename, - 'message': result.get('message', 'Compilation completed successfully') - }) - else: - return jsonify({ - 'success': False, - 'build_id': result.get('build_id', ''), - 'error': result.get('error', 'Unknown compilation error') - }), 500 - - except Exception as e: - return jsonify({ - 'success': False, - 'error': str(e) - }), 500 - - -@app.route('/api/build-status/') -@require_auth -def api_build_status(build_id): - """Get status of a specific build""" - try: - if not SOURCE_GENERATOR_AVAILABLE: - return jsonify({ - 'status': 'unavailable', - 'message': 'Source compilation not available' - }) - - status = get_build_status(build_id) - return jsonify(status) - - except Exception as e: - return jsonify({ - 'status': 'error', - 'message': str(e) - }), 500 - - -@app.route('/api/server-info') -@require_auth -def api_server_info(): - """Get server information for client generator""" - try: - info = { - 'source_compilation_available': SOURCE_GENERATOR_AVAILABLE, - 'client_generator_available': CLIENT_GENERATOR_AVAILABLE, - 'supported_platforms': ['linux', 'linux-arm64', 'windows-64', 'windows-32'] if SOURCE_GENERATOR_AVAILABLE else [], - 'default_version': '1.4.5' - } - return jsonify(info) - except Exception as e: - return jsonify({'error': str(e)}), 500 - - -# Cleanup expired sessions periodically -@app.before_request -def before_request(): - """Run before each request""" - # Cleanup expired sessions (every 100th request to avoid overhead) - import random - if random.randint(1, 100) == 1: - try: - cleanup_expired_sessions() - except: - pass - - -@app.after_request -def add_security_headers(response): - """Add security headers to all responses""" - # Content Security Policy - response.headers['Content-Security-Policy'] = ( - "default-src 'self'; " - "script-src 'self' 'unsafe-inline'; " - "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; " - "font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; " - "img-src 'self' data:; " - "connect-src 'self'" - ) - # Additional security headers - response.headers['X-Content-Type-Options'] = 'nosniff' - response.headers['X-Frame-Options'] = 'SAMEORIGIN' - response.headers['X-XSS-Protection'] = '1; mode=block' - response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' - return response - - -# Note: Authentication is handled by @require_auth decorator on each endpoint -# No global before_request check needed - let JavaScript on pages handle redirects - - -if __name__ == '__main__': - DEBUG = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true' - HOST = os.environ.get('FLASK_HOST', '0.0.0.0') - PORT = int(os.environ.get('FLASK_PORT', 5000)) - app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/web/auth.py b/web/auth.py deleted file mode 100644 index 09e17147..00000000 --- a/web/auth.py +++ /dev/null @@ -1,518 +0,0 @@ -""" -Authentication and Authorization Module for BetterDesk Console -""" - -import sqlite3 -import secrets -import bcrypt -import functools -import os -from datetime import datetime, timedelta -from flask import request, jsonify, g -from typing import Optional - - -# Database path -DB_PATH = '/opt/rustdesk/db_v2.sqlite3' - -# Session expiry (24 hours) -SESSION_EXPIRY_HOURS = 24 - -# Roles -ROLE_ADMIN = 'admin' -ROLE_OPERATOR = 'operator' -ROLE_VIEWER = 'viewer' - -ROLES_HIERARCHY = { - ROLE_ADMIN: 3, # Full access - ROLE_OPERATOR: 2, # Can ban/unban, edit devices - ROLE_VIEWER: 1 # Read-only -} - - -class AuthError(Exception): - """Custom exception for authentication errors""" - pass - - -def get_auth_db(): - """Get database connection for auth operations""" - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn - - -def init_auth_tables(): - """Initialize authentication tables if they don't exist. - Called automatically when the module is imported. - """ - conn = get_auth_db() - cursor = conn.cursor() - - # Check if users table exists - cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'") - if not cursor.fetchone(): - print("🔧 Creating auth tables (first run)...") - - # Create users table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username VARCHAR(50) UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - role VARCHAR(20) NOT NULL DEFAULT 'viewer', - created_at DATETIME NOT NULL, - last_login DATETIME, - is_active BOOLEAN NOT NULL DEFAULT 1, - CHECK (role IN ('admin', 'operator', 'viewer')) - ) - ''') - - # Create sessions table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS sessions ( - token VARCHAR(64) PRIMARY KEY, - user_id INTEGER NOT NULL, - created_at DATETIME NOT NULL, - expires_at DATETIME NOT NULL, - last_activity DATETIME NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - ''') - - # Create audit_log table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, - action VARCHAR(50) NOT NULL, - device_id VARCHAR(100), - details TEXT, - ip_address VARCHAR(45), - timestamp DATETIME NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL - ) - ''') - - # Create default admin user - default_password = secrets.token_urlsafe(12) - password_hash = bcrypt.hashpw(default_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - - cursor.execute(''' - INSERT INTO users (username, password_hash, role, created_at, is_active) - VALUES (?, ?, 'admin', ?, 1) - ''', ('admin', password_hash, datetime.now())) - - conn.commit() - - print("=" * 50) - print("🔐 DEFAULT ADMIN CREDENTIALS") - print("=" * 50) - print(f" Username: admin") - print(f" Password: {default_password}") - print("=" * 50) - print("⚠️ CHANGE THIS PASSWORD AFTER FIRST LOGIN!") - print("=" * 50) - - # Save credentials to file - try: - creds_file = os.path.join(os.path.dirname(DB_PATH), 'admin_credentials.txt') - with open(creds_file, 'w') as f: - f.write(f"BetterDesk Console - Default Admin Credentials\n") - f.write(f"Generated: {datetime.now()}\n\n") - f.write(f"Username: admin\n") - f.write(f"Password: {default_password}\n\n") - f.write(f"⚠️ CHANGE THIS PASSWORD AFTER FIRST LOGIN!\n") - os.chmod(creds_file, 0o600) - print(f"📄 Credentials saved to: {creds_file}") - except Exception as e: - print(f"⚠️ Could not save credentials file: {e}") - - conn.close() - - -# Initialize tables on module import -try: - init_auth_tables() -except Exception as e: - print(f"⚠️ Auth tables initialization failed: {e}") - - -def hash_password(password: str) -> str: - """Hash password using bcrypt""" - salt = bcrypt.gensalt() - return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') - - -def verify_password(password: str, password_hash: str) -> bool: - """Verify password against hash""" - return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8')) - - -def generate_session_token() -> str: - """Generate secure session token""" - return secrets.token_urlsafe(32) - - -def create_user(username: str, password: str, role: str = ROLE_VIEWER) -> dict: - """Create a new user""" - if role not in ROLES_HIERARCHY: - raise AuthError(f"Invalid role: {role}") - - password_hash = hash_password(password) - conn = get_auth_db() - cursor = conn.cursor() - - try: - cursor.execute(''' - INSERT INTO users (username, password_hash, role, created_at, is_active) - VALUES (?, ?, ?, ?, 1) - ''', (username, password_hash, role, datetime.now())) - - conn.commit() - user_id = cursor.lastrowid - - return { - 'id': user_id, - 'username': username, - 'role': role - } - except sqlite3.IntegrityError: - raise AuthError("Username already exists") - finally: - conn.close() - - -def authenticate(username: str, password: str) -> dict: - """Authenticate user and return user data""" - conn = get_auth_db() - cursor = conn.cursor() - - cursor.execute(''' - SELECT id, username, password_hash, role, is_active - FROM users - WHERE username = ? - ''', (username,)) - - user = cursor.fetchone() - conn.close() - - if not user: - raise AuthError("Invalid username or password") - - if not user['is_active']: - raise AuthError("Account is disabled") - - if not verify_password(password, user['password_hash']): - raise AuthError("Invalid username or password") - - return { - 'id': user['id'], - 'username': user['username'], - 'role': user['role'] - } - - -def create_session(user_id: int) -> str: - """Create a new session and return token""" - token = generate_session_token() - expires_at = datetime.now() + timedelta(hours=SESSION_EXPIRY_HOURS) - - conn = get_auth_db() - cursor = conn.cursor() - - cursor.execute(''' - INSERT INTO sessions (token, user_id, created_at, expires_at, last_activity) - VALUES (?, ?, ?, ?, ?) - ''', (token, user_id, datetime.now(), expires_at, datetime.now())) - - # Update last login - cursor.execute(''' - UPDATE users SET last_login = ? WHERE id = ? - ''', (datetime.now(), user_id)) - - conn.commit() - conn.close() - - return token - - -def verify_session(token: str) -> dict: - """Verify session token and return user data""" - if not token: - raise AuthError("No authentication token provided") - - conn = get_auth_db() - cursor = conn.cursor() - - cursor.execute(''' - SELECT s.user_id, s.expires_at, u.username, u.role, u.is_active - FROM sessions s - JOIN users u ON s.user_id = u.id - WHERE s.token = ? - ''', (token,)) - - session = cursor.fetchone() - - if not session: - conn.close() - raise AuthError("Invalid session token") - - # Check if session expired - expires_at = datetime.fromisoformat(session['expires_at']) - if datetime.now() > expires_at: - # Delete expired session - cursor.execute('DELETE FROM sessions WHERE token = ?', (token,)) - conn.commit() - conn.close() - raise AuthError("Session expired") - - # Check if user is active - if not session['is_active']: - conn.close() - raise AuthError("Account is disabled") - - # Update last activity - cursor.execute(''' - UPDATE sessions SET last_activity = ? WHERE token = ? - ''', (datetime.now(), token)) - - conn.commit() - conn.close() - - return { - 'user_id': session['user_id'], - 'username': session['username'], - 'role': session['role'] - } - - -def delete_session(token: str): - """Delete session (logout)""" - conn = get_auth_db() - cursor = conn.cursor() - cursor.execute('DELETE FROM sessions WHERE token = ?', (token,)) - conn.commit() - conn.close() - - -def cleanup_expired_sessions(): - """Remove expired sessions from database""" - conn = get_auth_db() - cursor = conn.cursor() - cursor.execute('DELETE FROM sessions WHERE expires_at < ?', (datetime.now(),)) - deleted = cursor.rowcount - conn.commit() - conn.close() - return deleted - - -def log_audit(user_id: int, action: str, device_id: Optional[str] = None, - details: Optional[str] = None, ip_address: Optional[str] = None): - """Log user action for audit trail""" - conn = get_auth_db() - cursor = conn.cursor() - - cursor.execute(''' - INSERT INTO audit_log (user_id, action, device_id, details, ip_address, timestamp) - VALUES (?, ?, ?, ?, ?, ?) - ''', (user_id, action, device_id, details, ip_address or 'unknown', datetime.now())) - - conn.commit() - conn.close() - - -def get_user_by_id(user_id: int) -> Optional[dict]: - """Get user data by ID""" - conn = get_auth_db() - cursor = conn.cursor() - - cursor.execute(''' - SELECT id, username, role, created_at, last_login, is_active - FROM users WHERE id = ? - ''', (user_id,)) - - user = cursor.fetchone() - conn.close() - - if not user: - return None - - return dict(user) - - -def list_users() -> list: - """List all users (admin only)""" - conn = get_auth_db() - cursor = conn.cursor() - - cursor.execute(''' - SELECT id, username, role, created_at, last_login, is_active - FROM users - ORDER BY created_at DESC - ''') - - users = [dict(row) for row in cursor.fetchall()] - conn.close() - - return users - - -def update_user_role(user_id: int, new_role: str): - """Update user role (admin only)""" - if new_role not in ROLES_HIERARCHY: - raise AuthError(f"Invalid role: {new_role}") - - conn = get_auth_db() - cursor = conn.cursor() - - cursor.execute('UPDATE users SET role = ? WHERE id = ?', (new_role, user_id)) - conn.commit() - conn.close() - - -def deactivate_user(user_id: int): - """Deactivate user account""" - conn = get_auth_db() - cursor = conn.cursor() - - cursor.execute('UPDATE users SET is_active = 0 WHERE id = ?', (user_id,)) - # Also delete all sessions for this user - cursor.execute('DELETE FROM sessions WHERE user_id = ?', (user_id,)) - - conn.commit() - conn.close() - - -def activate_user(user_id: int): - """Activate user account""" - conn = get_auth_db() - cursor = conn.cursor() - cursor.execute('UPDATE users SET is_active = 1 WHERE id = ?', (user_id,)) - conn.commit() - conn.close() - - -def delete_user(user_id: int): - """Delete user account (admin only)""" - conn = get_auth_db() - cursor = conn.cursor() - - # Delete all sessions first - cursor.execute('DELETE FROM sessions WHERE user_id = ?', (user_id,)) - # Delete user - cursor.execute('DELETE FROM users WHERE id = ?', (user_id,)) - - conn.commit() - conn.close() - - -def change_password(user_id: int, old_password: str, new_password: str) -> str: - """Change user password and return new session token""" - conn = get_auth_db() - cursor = conn.cursor() - - cursor.execute('SELECT password_hash FROM users WHERE id = ?', (user_id,)) - user = cursor.fetchone() - - if not user: - conn.close() - raise AuthError("User not found") - - if not verify_password(old_password, user['password_hash']): - conn.close() - raise AuthError("Current password is incorrect") - - new_hash = hash_password(new_password) - cursor.execute('UPDATE users SET password_hash = ? WHERE id = ?', (new_hash, user_id)) - - # Invalidate all sessions - cursor.execute('DELETE FROM sessions WHERE user_id = ?', (user_id,)) - - conn.commit() - conn.close() - - # Create new session - return create_session(user_id) - - -def reset_password(user_id: int, new_password: str): - """Reset user password (admin only)""" - new_hash = hash_password(new_password) - - conn = get_auth_db() - cursor = conn.cursor() - - cursor.execute('UPDATE users SET password_hash = ? WHERE id = ?', (new_hash, user_id)) - # Invalidate all sessions for this user - cursor.execute('DELETE FROM sessions WHERE user_id = ?', (user_id,)) - - conn.commit() - conn.close() - - -# Flask decorators - -def require_auth(f): - """Decorator to require authentication""" - @functools.wraps(f) - def decorated_function(*args, **kwargs): - token = request.headers.get('Authorization') - - if token and token.startswith('Bearer '): - token = token[7:] # Remove 'Bearer ' prefix - - if not token: - return jsonify({'success': False, 'error': 'No authorization token provided'}), 401 - - try: - user_data = verify_session(token) - g.user = user_data # Store in Flask's g object - return f(*args, **kwargs) - except AuthError as e: - return jsonify({'success': False, 'error': str(e)}), 401 - - return decorated_function - - -def require_role(*allowed_roles): - """Decorator to require specific role(s)""" - def decorator(f): - @functools.wraps(f) - def decorated_function(*args, **kwargs): - if not hasattr(g, 'user'): - return jsonify({'success': False, 'error': 'Authentication required'}), 401 - - user_role = g.user['role'] - - if user_role not in allowed_roles: - return jsonify({ - 'success': False, - 'error': f'Insufficient permissions. Required: {", ".join(allowed_roles)}' - }), 403 - - return f(*args, **kwargs) - - return decorated_function - return decorator - - -def optional_auth(f): - """Decorator for optional authentication (doesn't fail if not authenticated)""" - @functools.wraps(f) - def decorated_function(*args, **kwargs): - token = request.headers.get('Authorization') - - if token and token.startswith('Bearer '): - token = token[7:] - try: - user_data = verify_session(token) - g.user = user_data - except AuthError: - g.user = None - else: - g.user = None - - return f(*args, **kwargs) - - return decorated_function diff --git a/web/betterdesk.service b/web/betterdesk.service deleted file mode 100644 index fc4a56c0..00000000 --- a/web/betterdesk.service +++ /dev/null @@ -1,20 +0,0 @@ -[Unit] -Description=BetterDesk Web Console v2.1.1 -Documentation=https://github.com/UNITRONIX/Rustdesk-FreeConsole -After=network.target rustdesksignal.service - -[Service] -Type=simple -User=root -WorkingDirectory=/opt/BetterDeskConsole -Environment="FLASK_HOST=0.0.0.0" -Environment="FLASK_PORT=5000" -Environment="FLASK_DEBUG=False" -Environment="RUSTDESK_PATH=/opt/rustdesk" -Environment="API_PORT=21120" -ExecStart=/opt/BetterDeskConsole/venv/bin/python /opt/BetterDeskConsole/app.py -Restart=always -RestartSec=10 - -[Install] -WantedBy=multi-user.target diff --git a/web/client_generator_module.py b/web/client_generator_module.py deleted file mode 100644 index 51d08dfb..00000000 --- a/web/client_generator_module.py +++ /dev/null @@ -1,443 +0,0 @@ -""" -RustDesk Client Generator Module -Generates custom RustDesk clients with specified configurations -""" - -import os -import json -import tempfile -import shutil -import subprocess -from datetime import datetime -import uuid -import requests -from pathlib import Path -import logging - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -class ClientGenerator: - """Handles the generation of custom RustDesk clients""" - - # RustDesk GitHub releases URL - GITHUB_RELEASES = "https://api.github.com/repos/rustdesk/rustdesk/releases" - - # Platform name normalization (UI names -> internal names) - PLATFORM_ALIASES = { - 'windows-x64': 'windows-64', - 'windows-x86': 'windows-32', - 'linux-x64': 'linux', - 'macos-x64': 'macos', - } - - # Platform mappings - real filenames from GitHub releases - # Format: platform -> (primary pattern, fallback patterns) - PLATFORM_FILES = { - 'windows-64': 'rustdesk-{version}-x86_64.exe', - 'windows-32': 'rustdesk-{version}-x86-sciter.exe', - 'linux': 'rustdesk-{version}-x86_64.AppImage', - 'linux-arm64': 'rustdesk-{version}-aarch64.AppImage', - 'android': 'rustdesk-{version}-universal-signed.apk', - 'macos': 'rustdesk-{version}-x86_64.dmg', - 'macos-arm64': 'rustdesk-{version}-aarch64.dmg', - } - - # Alternative filename patterns for different versions - PLATFORM_ALTERNATIVES = { - 'windows-64': ['rustdesk-{version}.exe', 'rustdesk-{version}-x86_64-windows.exe'], - 'windows-32': ['rustdesk-{version}-x86.exe'], - 'linux': ['rustdesk-{version}.AppImage', 'rustdesk-{version}-x86_64-linux.AppImage'], - 'linux-arm64': ['rustdesk-{version}-arm64.AppImage'], - 'android': ['rustdesk-{version}.apk', 'rustdesk-{version}-arm64-v8a.apk'], - 'macos': ['rustdesk-{version}.dmg', 'rustdesk-{version}-x86_64-macos.dmg'], - 'macos-arm64': ['rustdesk-{version}-arm64.dmg'], - } - - def __init__(self, output_dir='/tmp/rustdesk_builds'): - """Initialize the generator with output directory""" - self.output_dir = output_dir - os.makedirs(output_dir, exist_ok=True) - - def normalize_platform(self, platform): - """Normalize platform name from UI to internal format""" - return self.PLATFORM_ALIASES.get(platform, platform) - - def get_download_url(self, version, platform): - """Get download URL for specific version and platform""" - platform = self.normalize_platform(platform) - try: - logger.info(f"Fetching releases from GitHub for version {version}, platform {platform}") - # Get releases from GitHub API - response = requests.get(self.GITHUB_RELEASES, timeout=10) - response.raise_for_status() - releases = response.json() - - logger.info(f"Found {len(releases)} releases") - - # Normalize version (remove 'v' prefix if present) - clean_version = version.lstrip('v') - - # Find the matching version - for release in releases: - tag = release.get('tag_name', '').lstrip('v') - logger.debug(f"Checking release: {tag}") - - if tag == clean_version: - logger.info(f"Found matching release: {release.get('tag_name')}") - - # Get the filename pattern for this platform - filename_pattern = self.PLATFORM_FILES.get(platform, '') - if not filename_pattern: - logger.error(f"No filename pattern for platform: {platform}") - return None - - # Build expected filename with clean version - expected_filename = filename_pattern.format(version=clean_version) - logger.info(f"Looking for file: {expected_filename}") - - # List all available assets for debugging - available_assets = [asset.get('name', '') for asset in release.get('assets', [])] - logger.info(f"Available assets: {', '.join(available_assets[:5])}...") - - # Build list of patterns to try (primary + alternatives) - patterns_to_try = [expected_filename] - - # Add alternative patterns if available - alt_patterns = self.PLATFORM_ALTERNATIVES.get(platform, []) - for alt_pattern in alt_patterns: - patterns_to_try.append(alt_pattern.format(version=clean_version)) - - logger.info(f"Patterns to try: {patterns_to_try}") - - # Search for matching asset - for asset in release.get('assets', []): - asset_name = asset.get('name', '') - - # Try each pattern - for pattern in patterns_to_try: - # Exact match first - if asset_name == pattern: - logger.info(f"Found exact match: {asset_name}") - download_url = asset.get('browser_download_url') - logger.info(f"Download URL: {download_url}") - return download_url - - # Fallback: partial match for any pattern - for asset in release.get('assets', []): - asset_name = asset.get('name', '') - - for pattern in patterns_to_try: - # Partial match as fallback - base_parts = pattern.replace(clean_version, '*').split('*') - if len(base_parts) >= 2: - starts_with = base_parts[0] - ends_with = base_parts[-1] if base_parts[-1] else '' - - if asset_name.startswith(starts_with) and asset_name.endswith(ends_with): - logger.info(f"Found partial match: {asset_name}") - download_url = asset.get('browser_download_url') - logger.info(f"Download URL: {download_url}") - return download_url - - logger.error(f"No matching asset found for version {version}, platform {platform}") - return None - - except Exception as e: - logger.error(f"Error getting download URL: {e}", exc_info=True) - return None - - def download_client(self, version, platform): - """Download the base RustDesk client""" - download_url = self.get_download_url(version, platform) - - if not download_url: - logger.error(f"Could not find download URL for version {version} platform {platform}") - raise Exception(f"Could not find download URL for version {version} platform {platform}") - - logger.info(f"Downloading from: {download_url}") - - # Download the file - temp_file = os.path.join(self.output_dir, f"rustdesk_base_{uuid.uuid4()}.tmp") - - try: - response = requests.get(download_url, stream=True, timeout=60) - response.raise_for_status() - - total_size = int(response.headers.get('content-length', 0)) - logger.info(f"Downloading {total_size} bytes to {temp_file}") - - downloaded = 0 - with open(temp_file, 'wb') as f: - for chunk in response.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - downloaded += len(chunk) - - logger.info(f"Download complete: {downloaded} bytes") - return temp_file - - except Exception as e: - logger.error(f"Failed to download client: {e}", exc_info=True) - if os.path.exists(temp_file): - os.remove(temp_file) - raise Exception(f"Failed to download client: {e}") - - def create_config_file(self, config_data): - """Create RustDesk configuration file - using SNAKE_CASE format required by RustDesk""" - config = {} - - # Server configuration - RustDesk uses snake_case! - if config_data.get('server_host'): - config['relay_server'] = config_data['server_host'] - config['rendezvous_server'] = config_data['server_host'] - - if config_data.get('server_key'): - config['key'] = config_data['server_key'] - - if config_data.get('server_api'): - config['api_server'] = config_data['server_api'] - - # Branding / Customization - snake_case - if config_data.get('app_name'): - config['app_name'] = config_data['app_name'] - - if config_data.get('logo_base64'): - # Extract just the base64 data (remove data:image/xxx;base64, prefix) - logo_data = config_data['logo_base64'] - if ',' in logo_data: - logo_data = logo_data.split(',', 1)[1] - config['logo'] = logo_data - elif config_data.get('logo_url'): - # For URL, we'd need to download and encode - for now just note it - config['logo_url'] = config_data['logo_url'] - - if config_data.get('custom_text'): - config['custom_text'] = config_data['custom_text'] - - # Connection settings - snake_case - if config_data.get('connection_type'): - conn_type = config_data['connection_type'] - if conn_type == 'incoming': - config['direct_server'] = 'N' - elif conn_type == 'outgoing': - config['enable_direct_server'] = 'Y' - - # Security settings - snake_case - if config_data.get('permanent_password'): - config['password'] = config_data['permanent_password'] - - if config_data.get('password_approve_mode'): - config['approve_mode'] = config_data['password_approve_mode'] - - if config_data.get('deny_lan_discovery'): - config['enable_lan_discovery'] = 'N' - - if config_data.get('enable_direct_ip'): - config['direct_ip_access'] = 'Y' - - # Visual settings - if config_data.get('theme'): - config['theme'] = config_data['theme'] - - # Permissions - snake_case - permissions = {} - if config_data.get('perm_keyboard') == False: - permissions['keyboard'] = False - if config_data.get('perm_clipboard') == False: - permissions['clipboard'] = False - if config_data.get('perm_file_transfer') == False: - permissions['file_transfer'] = False - if config_data.get('perm_audio') == False: - permissions['audio'] = False - - if permissions: - config['permissions'] = permissions - - # Default settings (merge with provided default_settings) - if config_data.get('default_settings'): - try: - default_settings = json.loads(config_data['default_settings']) - config.update(default_settings) - except json.JSONDecodeError: - pass - - # Override settings (merge with provided override_settings) - if config_data.get('override_settings'): - try: - override_settings = json.loads(config_data['override_settings']) - config['override'] = override_settings - except json.JSONDecodeError: - pass - - return config - - def modify_client(self, base_client_path, config_data, platform): - """Modify the client with custom configuration""" - - # Create configuration - config = self.create_config_file(config_data) - - # Create a temporary directory for modification - work_dir = os.path.join(self.output_dir, f"work_{uuid.uuid4()}") - os.makedirs(work_dir, exist_ok=True) - - try: - # Copy base client - client_name = config_data.get('config_name', 'custom-rustdesk') - - # Determine output filename based on platform - if platform.startswith('windows'): - output_filename = f"{client_name}.exe" - elif platform.startswith('linux'): - output_filename = f"{client_name}.AppImage" - elif platform.startswith('android'): - output_filename = f"{client_name}.apk" - elif platform.startswith('macos'): - output_filename = f"{client_name}.dmg" - else: - output_filename = f"{client_name}.bin" - - output_path = os.path.join(self.output_dir, output_filename) - - # For Windows executables, embed configuration using RustDesk's method - if platform.startswith('windows'): - shutil.copy2(base_client_path, output_path) - - # RustDesk custom client config embedding - # The config is appended to the end of the exe with a special marker - # Format: CONFIG_JSON + EXE_SUFFIX (which RustDesk looks for) - - config_json = json.dumps(config) - config_bytes = config_json.encode('utf-8') - - # RustDesk looks for config embedded with specific markers - # Method 1: Append config with marker "<<>>" - marker = b'<<>>' - - with open(output_path, 'ab') as f: - f.write(marker) - f.write(config_bytes) - f.write(marker) - - logger.info(f"Embedded config into {output_path}") - logger.info(f"Config: {config_json}") - - # Also create a companion rustdesk2.toml file for testing/backup - config_file = os.path.join(work_dir, 'rustdesk2.toml') - with open(config_file, 'w') as f: - # Write TOML format - for key, value in config.items(): - if isinstance(value, dict): - f.write(f"\n[{key}]\n") - for k, v in value.items(): - if isinstance(v, str): - f.write(f'{k} = "{v}"\n') - else: - f.write(f'{k} = {str(v).lower()}\n') - else: - if isinstance(value, str): - f.write(f'{key} = "{value}"\n') - else: - f.write(f'{key} = {str(value).lower()}\n') - - # Create a ZIP with both files - import zipfile - zip_path = output_path.replace('.exe', '.zip') - with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: - zf.write(output_path, os.path.basename(output_path)) - zf.write(config_file, 'rustdesk2.toml') - - # Remove the exe and rename zip - os.remove(output_path) - output_path = zip_path - output_filename = os.path.basename(zip_path) - - else: - # For other platforms, just copy for now - shutil.copy2(base_client_path, output_path) - - # Save metadata - metadata = { - 'platform': platform, - 'version': config_data.get('version', '1.4.5'), - 'config_name': client_name, - 'created_at': datetime.now().isoformat(), - 'config': config - } - - metadata_path = output_path + '.json' - with open(metadata_path, 'w') as f: - json.dump(metadata, f, indent=2) - - return output_path, metadata_path - - except Exception as e: - raise Exception(f"Failed to modify client: {e}") - finally: - # Cleanup work directory - if os.path.exists(work_dir): - shutil.rmtree(work_dir, ignore_errors=True) - - def generate(self, config_data): - """Main method to generate a custom client""" - - platform = config_data.get('platform', 'windows-64') - platform = self.normalize_platform(platform) # Normalize to internal format - version = config_data.get('version', '1.4.5') - - try: - # Download base client - base_client = self.download_client(version, platform) - - # Modify client with configuration - output_path, metadata_path = self.modify_client(base_client, config_data, platform) - - # Cleanup base client - if os.path.exists(base_client): - os.remove(base_client) - - return { - 'success': True, - 'client_path': output_path, - 'metadata_path': metadata_path, - 'filename': os.path.basename(output_path) - } - - except Exception as e: - return { - 'success': False, - 'error': str(e) - } - - def cleanup_old_files(self, max_age_hours=24): - """Clean up old generated files""" - try: - import time - current_time = time.time() - - for filename in os.listdir(self.output_dir): - filepath = os.path.join(self.output_dir, filename) - - if os.path.isfile(filepath): - file_age = current_time - os.path.getmtime(filepath) - if file_age > (max_age_hours * 3600): - os.remove(filepath) - - except Exception as e: - print(f"Error cleaning up old files: {e}") - - -def generate_custom_client(config_data): - """Helper function to generate a custom client""" - generator = ClientGenerator() - - # Cleanup old files first - generator.cleanup_old_files() - - # Generate new client - result = generator.generate(config_data) - - return result diff --git a/web/i18n.py b/web/i18n.py deleted file mode 100644 index 05959464..00000000 --- a/web/i18n.py +++ /dev/null @@ -1,493 +0,0 @@ -""" -BetterDesk Console - Internationalization (i18n) System -======================================================== - -A JSON-based translation system that allows the community to easily add new languages. - -Usage: ------- -1. Create a new JSON file in web/lang/ directory (e.g., 'de.json' for German) -2. Copy the structure from 'en.json' and translate all values -3. Add the language to SUPPORTED_LANGUAGES dict below -4. The language will automatically appear in the language selector - -In templates: - {{ _('dashboard.title') }} - {{ _('devices.online_count', count=5) }} - -In Python: - from i18n import get_translator - _ = get_translator('pl') - print(_('dashboard.title')) -""" - -import json -import os -from functools import lru_cache -from typing import Dict, Any, Optional - -# Directory containing language files -LANG_DIR = os.path.join(os.path.dirname(__file__), 'lang') - -# Default language -DEFAULT_LANGUAGE = 'en' - -# Supported languages with their display names and flags -# Add new languages here after creating the JSON file -SUPPORTED_LANGUAGES = { - 'en': {'name': 'English', 'native': 'English', 'flag': '🇬🇧', 'rtl': False}, - 'pl': {'name': 'Polish', 'native': 'Polski', 'flag': '🇵🇱', 'rtl': False}, - # Community contributions - add new languages below: - # 'de': {'name': 'German', 'native': 'Deutsch', 'flag': '🇩🇪', 'rtl': False}, - # 'fr': {'name': 'French', 'native': 'Français', 'flag': '🇫🇷', 'rtl': False}, - # 'es': {'name': 'Spanish', 'native': 'Español', 'flag': '🇪🇸', 'rtl': False}, - # 'it': {'name': 'Italian', 'native': 'Italiano', 'flag': '🇮🇹', 'rtl': False}, - # 'pt': {'name': 'Portuguese', 'native': 'Português', 'flag': '🇵🇹', 'rtl': False}, - # 'ru': {'name': 'Russian', 'native': 'Русский', 'flag': '🇷🇺', 'rtl': False}, - # 'zh': {'name': 'Chinese', 'native': '中文', 'flag': '🇨🇳', 'rtl': False}, - # 'ja': {'name': 'Japanese', 'native': '日本語', 'flag': '🇯🇵', 'rtl': False}, - # 'ko': {'name': 'Korean', 'native': '한국어', 'flag': '🇰🇷', 'rtl': False}, - # 'ar': {'name': 'Arabic', 'native': 'العربية', 'flag': '🇸🇦', 'rtl': True}, - # 'he': {'name': 'Hebrew', 'native': 'עברית', 'flag': '🇮🇱', 'rtl': True}, - # 'tr': {'name': 'Turkish', 'native': 'Türkçe', 'flag': '🇹🇷', 'rtl': False}, - # 'nl': {'name': 'Dutch', 'native': 'Nederlands', 'flag': '🇳🇱', 'rtl': False}, - # 'uk': {'name': 'Ukrainian', 'native': 'Українська', 'flag': '🇺🇦', 'rtl': False}, - # 'cs': {'name': 'Czech', 'native': 'Čeština', 'flag': '🇨🇿', 'rtl': False}, - # 'sv': {'name': 'Swedish', 'native': 'Svenska', 'flag': '🇸🇪', 'rtl': False}, -} - - -class TranslationManager: - """Manages loading and accessing translations.""" - - def __init__(self): - self._translations: Dict[str, Dict[str, Any]] = {} - self._load_all_languages() - - def _load_all_languages(self): - """Load all available language files.""" - if not os.path.exists(LANG_DIR): - os.makedirs(LANG_DIR, exist_ok=True) - return - - for lang_code in SUPPORTED_LANGUAGES.keys(): - self._load_language(lang_code) - - def _load_language(self, lang_code: str) -> bool: - """Load a specific language file.""" - file_path = os.path.join(LANG_DIR, f'{lang_code}.json') - - if not os.path.exists(file_path): - print(f"Warning: Language file not found: {file_path}") - return False - - try: - with open(file_path, 'r', encoding='utf-8') as f: - self._translations[lang_code] = json.load(f) - return True - except json.JSONDecodeError as e: - print(f"Error parsing language file {file_path}: {e}") - return False - except Exception as e: - print(f"Error loading language file {file_path}: {e}") - return False - - def reload_language(self, lang_code: str) -> bool: - """Reload a specific language file (useful for development).""" - return self._load_language(lang_code) - - def reload_all(self): - """Reload all language files.""" - self._translations = {} - self._load_all_languages() - - def get_translation(self, lang_code: str, key: str, **kwargs) -> str: - """ - Get a translation for the given key. - - Args: - lang_code: Language code (e.g., 'en', 'pl') - key: Dot-separated key (e.g., 'dashboard.title', 'devices.online_count') - **kwargs: Variables for string interpolation - - Returns: - Translated string or the key itself if not found - """ - # Fallback chain: requested language -> default language -> key - translations = self._translations.get(lang_code) - - if not translations: - translations = self._translations.get(DEFAULT_LANGUAGE, {}) - - # Navigate nested keys - value = translations - for part in key.split('.'): - if isinstance(value, dict): - value = value.get(part) - else: - value = None - break - - # Fallback to default language if key not found - if value is None and lang_code != DEFAULT_LANGUAGE: - default_translations = self._translations.get(DEFAULT_LANGUAGE, {}) - value = default_translations - for part in key.split('.'): - if isinstance(value, dict): - value = value.get(part) - else: - value = None - break - - # Return key if no translation found - if value is None: - return key - - # String interpolation for variables - if kwargs and isinstance(value, str): - try: - value = value.format(**kwargs) - except KeyError: - pass - - return value - - def get_available_languages(self) -> Dict[str, Dict[str, Any]]: - """Get list of available languages with their metadata.""" - available = {} - for lang_code, meta in SUPPORTED_LANGUAGES.items(): - file_path = os.path.join(LANG_DIR, f'{lang_code}.json') - if os.path.exists(file_path) or lang_code == DEFAULT_LANGUAGE: - available[lang_code] = meta - return available - - def get_all_keys(self, lang_code: str = None) -> list: - """Get all translation keys (useful for validation).""" - lang_code = lang_code or DEFAULT_LANGUAGE - translations = self._translations.get(lang_code, {}) - - def extract_keys(d, prefix=''): - keys = [] - for k, v in d.items(): - full_key = f"{prefix}.{k}" if prefix else k - if isinstance(v, dict): - keys.extend(extract_keys(v, full_key)) - else: - keys.append(full_key) - return keys - - return extract_keys(translations) - - def validate_language(self, lang_code: str) -> Dict[str, list]: - """ - Validate a language file against the default language. - - Returns: - Dict with 'missing' and 'extra' keys - """ - default_keys = set(self.get_all_keys(DEFAULT_LANGUAGE)) - lang_keys = set(self.get_all_keys(lang_code)) - - return { - 'missing': sorted(default_keys - lang_keys), - 'extra': sorted(lang_keys - default_keys) - } - - -# Global translation manager instance -_manager = TranslationManager() - - -def get_translator(lang_code: str = None): - """ - Get a translator function for the given language. - - Usage: - _ = get_translator('pl') - text = _('dashboard.title') - """ - lang = lang_code or DEFAULT_LANGUAGE - - def translate(key: str, **kwargs) -> str: - return _manager.get_translation(lang, key, **kwargs) - - return translate - - -def translate(lang_code: str, key: str, **kwargs) -> str: - """Direct translation function.""" - return _manager.get_translation(lang_code, key, **kwargs) - - -def get_available_languages() -> Dict[str, Dict[str, Any]]: - """Get all available languages.""" - return _manager.get_available_languages() - - -def reload_translations(): - """Reload all translation files.""" - _manager.reload_all() - - -def validate_language(lang_code: str) -> Dict[str, list]: - """Validate a language file against the default.""" - return _manager.validate_language(lang_code) - - -# Flask integration helpers -def init_app(app, csrf=None): - """ - Initialize i18n for a Flask application. - - Usage in app.py: - from i18n import init_app - init_app(app) - # Or with CSRF protection: - init_app(app, csrf) - """ - from flask import request, session, g - - @app.before_request - def set_language(): - """Set the current language for the request.""" - # Priority: URL param > Cookie > Accept-Language header > Default - lang = request.args.get('lang') - - if not lang: - lang = request.cookies.get('betterdesk_lang') - - if not lang: - # Parse Accept-Language header - accept_lang = request.headers.get('Accept-Language', '') - for part in accept_lang.split(','): - lang_part = part.split(';')[0].strip().split('-')[0] - if lang_part in SUPPORTED_LANGUAGES: - lang = lang_part - break - - if not lang or lang not in SUPPORTED_LANGUAGES: - lang = DEFAULT_LANGUAGE - - g.lang = lang - g.is_rtl = SUPPORTED_LANGUAGES.get(lang, {}).get('rtl', False) - - @app.context_processor - def inject_i18n(): - """Inject translation function and language info into templates.""" - lang = getattr(g, 'lang', DEFAULT_LANGUAGE) - - def _(key: str, **kwargs) -> str: - return _manager.get_translation(lang, key, **kwargs) - - return { - '_': _, - 'current_lang': lang, - 'available_languages': get_available_languages(), - 'is_rtl': getattr(g, 'is_rtl', False), - } - - @app.route('/api/i18n/languages') - def api_languages(): - """API endpoint to get available languages.""" - from flask import jsonify - return jsonify({ - 'success': True, - 'languages': get_available_languages(), - 'current': getattr(g, 'lang', DEFAULT_LANGUAGE) - }) - - @app.route('/api/i18n/translations/') - def api_translations(lang_code): - """API endpoint to get all translations for a language (for JS).""" - from flask import jsonify - - if lang_code not in SUPPORTED_LANGUAGES: - return jsonify({'success': False, 'error': 'Language not supported'}), 404 - - translations = _manager._translations.get(lang_code, {}) - return jsonify({ - 'success': True, - 'lang': lang_code, - 'translations': translations - }) - - @app.route('/api/i18n/set/', methods=['POST']) - def api_set_language(lang_code): - """API endpoint to set user's preferred language.""" - from flask import jsonify, make_response - - if lang_code not in SUPPORTED_LANGUAGES: - return jsonify({'success': False, 'error': 'Language not supported'}), 400 - - response = make_response(jsonify({ - 'success': True, - 'lang': lang_code, - 'message': f'Language set to {SUPPORTED_LANGUAGES[lang_code]["name"]}' - })) - - # Set cookie for 1 year - response.set_cookie( - 'betterdesk_lang', - lang_code, - max_age=365*24*60*60, - httponly=True, - samesite='Lax' - ) - - return response - - @app.route('/api/i18n/upload', methods=['POST']) - def api_upload_language(): - """ - API endpoint to upload a custom language pack. - - Expects multipart/form-data with: - - file: JSON file with translations - - Or JSON body with: - - lang_code: Language code (e.g., 'de') - - translations: Translation dictionary - - meta: Optional metadata (name, native, flag, rtl) - """ - from flask import jsonify, request - import json - - try: - # Handle file upload - if request.files and 'file' in request.files: - file = request.files['file'] - if file.filename == '': - return jsonify({'success': False, 'error': 'No file selected'}), 400 - - if not file.filename.endswith('.json'): - return jsonify({'success': False, 'error': 'File must be a JSON file'}), 400 - - # Read and parse JSON - try: - content = file.read().decode('utf-8') - translations = json.loads(content) - except json.JSONDecodeError as e: - return jsonify({'success': False, 'error': f'Invalid JSON: {str(e)}'}), 400 - except UnicodeDecodeError: - return jsonify({'success': False, 'error': 'File must be UTF-8 encoded'}), 400 - - # Extract language code from filename or meta - lang_code = file.filename.replace('.json', '').lower() - if '_meta' in translations and 'code' in translations['_meta']: - lang_code = translations['_meta']['code'].lower() - - # Handle JSON body - elif request.content_type and 'json' in request.content_type: - try: - data = request.get_json(force=True) - except Exception as e: - return jsonify({'success': False, 'error': f'Invalid JSON body: {str(e)}'}), 400 - - lang_code = data.get('lang_code', '').lower() - translations = data.get('translations', {}) - - if not lang_code: - return jsonify({'success': False, 'error': 'lang_code is required'}), 400 - if not translations: - return jsonify({'success': False, 'error': 'translations is required'}), 400 - else: - return jsonify({'success': False, 'error': 'Upload a JSON file or provide JSON body'}), 400 - - # Validate language code - if not lang_code or len(lang_code) < 2 or len(lang_code) > 5: - return jsonify({'success': False, 'error': 'Invalid language code (2-5 characters required)'}), 400 - - if not lang_code.isalpha(): - return jsonify({'success': False, 'error': 'Language code must contain only letters'}), 400 - - # Ensure lang directory exists - os.makedirs(LANG_DIR, exist_ok=True) - - # Save the language file - file_path = os.path.join(LANG_DIR, f'{lang_code}.json') - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(translations, f, ensure_ascii=False, indent=2) - - # Extract metadata for SUPPORTED_LANGUAGES - meta = translations.get('_meta', {}) - lang_name = meta.get('language', lang_code.upper()) - native_name = meta.get('native_name', lang_name) - - # Update SUPPORTED_LANGUAGES dynamically - global SUPPORTED_LANGUAGES - if lang_code not in SUPPORTED_LANGUAGES: - SUPPORTED_LANGUAGES[lang_code] = { - 'name': lang_name, - 'native': native_name, - 'flag': get_flag_for_language(lang_code), - 'rtl': meta.get('rtl', False) - } - - # Reload the language - _manager.reload_language(lang_code) - - # Validate against default language - validation = _manager.validate_language(lang_code) - - return jsonify({ - 'success': True, - 'lang_code': lang_code, - 'message': f'Language pack "{lang_name}" uploaded successfully', - 'file_path': file_path, - 'validation': validation, - 'note': 'Refresh the page to see the new language' - }) - - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - @app.route('/api/i18n/delete/', methods=['DELETE']) - def api_delete_language(lang_code): - """API endpoint to delete a custom language pack (cannot delete en/pl).""" - from flask import jsonify - - # Protect core languages - if lang_code in ['en', 'pl']: - return jsonify({'success': False, 'error': 'Cannot delete core language packs'}), 403 - - file_path = os.path.join(LANG_DIR, f'{lang_code}.json') - - if not os.path.exists(file_path): - return jsonify({'success': False, 'error': 'Language pack not found'}), 404 - - try: - os.remove(file_path) - - # Remove from SUPPORTED_LANGUAGES - global SUPPORTED_LANGUAGES - if lang_code in SUPPORTED_LANGUAGES: - del SUPPORTED_LANGUAGES[lang_code] - - # Reload translations - _manager.reload_all() - - return jsonify({ - 'success': True, - 'message': f'Language pack "{lang_code}" deleted' - }) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - # Exempt upload and delete endpoints from CSRF protection - if csrf: - csrf.exempt(api_upload_language) - csrf.exempt(api_delete_language) - - -def get_flag_for_language(lang_code: str) -> str: - """Get a flag emoji for common language codes.""" - flags = { - 'en': '🇬🇧', 'pl': '🇵🇱', 'de': '🇩🇪', 'fr': '🇫🇷', 'es': '🇪🇸', - 'it': '🇮🇹', 'pt': '🇵🇹', 'ru': '🇷🇺', 'zh': '🇨🇳', 'ja': '🇯🇵', - 'ko': '🇰🇷', 'ar': '🇸🇦', 'he': '🇮🇱', 'tr': '🇹🇷', 'nl': '🇳🇱', - 'uk': '🇺🇦', 'cs': '🇨🇿', 'sv': '🇸🇪', 'da': '🇩🇰', 'fi': '🇫🇮', - 'no': '🇳🇴', 'el': '🇬🇷', 'hu': '🇭🇺', 'ro': '🇷🇴', 'bg': '🇧🇬', - 'hr': '🇭🇷', 'sk': '🇸🇰', 'sl': '🇸🇮', 'et': '🇪🇪', 'lv': '🇱🇻', - 'lt': '🇱🇹', 'vi': '🇻🇳', 'th': '🇹🇭', 'id': '🇮🇩', 'ms': '🇲🇾' - } - return flags.get(lang_code, '🌐') diff --git a/web/lang/en.json b/web/lang/en.json deleted file mode 100644 index f534853f..00000000 --- a/web/lang/en.json +++ /dev/null @@ -1,263 +0,0 @@ -{ - "_meta": { - "language": "English", - "code": "en", - "version": "1.0.0", - "author": "BetterDesk Team", - "last_updated": "2026-02-11" - }, - "common": { - "app_name": "BetterDesk Console", - "loading": "Loading...", - "save": "Save", - "cancel": "Cancel", - "delete": "Delete", - "edit": "Edit", - "add": "Add", - "close": "Close", - "confirm": "Confirm", - "yes": "Yes", - "no": "No", - "ok": "OK", - "error": "Error", - "success": "Success", - "warning": "Warning", - "info": "Info", - "search": "Search", - "refresh": "Refresh", - "actions": "Actions", - "status": "Status", - "created": "Created", - "updated": "Updated", - "back": "Back", - "next": "Next", - "previous": "Previous", - "submit": "Submit", - "reset": "Reset", - "copy": "Copy", - "copy_to_clipboard": "Copy to Clipboard", - "copied": "Copied!", - "download": "Download", - "upload": "Upload", - "select": "Select", - "none": "None", - "all": "All", - "total": "Total", - "active": "Active", - "inactive": "Inactive", - "enabled": "Enabled", - "disabled": "Disabled", - "online": "Online", - "offline": "Offline", - "unknown": "Unknown" - }, - "auth": { - "login": "Login", - "logout": "Logout", - "username": "Username", - "password": "Password", - "current_password": "Current Password", - "new_password": "New Password", - "confirm_password": "Confirm Password", - "remember_me": "Remember me", - "forgot_password": "Forgot password?", - "login_title": "Login - BetterDesk Console", - "login_subtitle": "RustDesk Management Dashboard", - "login_button": "Sign In", - "sign_in": "Sign In", - "logging_in": "Signing in...", - "login_failed": "Login failed", - "invalid_credentials": "Invalid username or password", - "session_expired": "Session expired. Please login again.", - "logout_success": "Logged out successfully", - "change_password": "Change Password", - "password_changed": "Password changed successfully", - "password_mismatch": "Passwords do not match", - "password_requirements": "Minimum 8 characters, must contain letters and numbers", - "password_too_weak": "Password is too weak", - "enter_username": "Enter your username", - "enter_password": "Enter your password" - }, - "sidebar": { - "dashboard": "Dashboard", - "client_generator": "Client Generator", - "public_key": "Public Key", - "settings": "Settings", - "users": "User Management", - "about": "About", - "language": "Language", - "logout": "Logout" - }, - "dashboard": { - "title": "Device Dashboard", - "device_management": "Device Management", - "total_devices": "Total Devices", - "active_devices": "Active", - "inactive_devices": "Inactive", - "banned_devices": "Banned", - "with_notes": "With Notes", - "connected_devices": "Connected Devices", - "search_devices": "Search devices...", - "no_devices": "No devices found", - "loading_devices": "Loading devices...", - "refresh_devices": "Refresh", - "last_updated": "Last updated", - "auto_refresh": "Auto-refresh", - "degraded": "Degraded", - "critical": "Critical" - }, - "devices": { - "id": "ID", - "note": "Note", - "status": "Status", - "created": "Created", - "last_online": "Last Online", - "actions": "Actions", - "add_note": "Add Note", - "edit_note": "Edit Note", - "save_note": "Save Note", - "delete_device": "Delete Device", - "ban_device": "Ban Device", - "unban_device": "Unban Device", - "change_id": "Change ID", - "view_details": "View Details", - "copy_id": "Copy ID", - "confirm_delete": "Are you sure you want to delete this device?", - "confirm_ban": "Are you sure you want to ban this device?", - "confirm_unban": "Are you sure you want to unban this device?", - "device_deleted": "Device deleted successfully", - "device_banned": "Device banned successfully", - "device_unbanned": "Device unbanned successfully", - "note_saved": "Note saved successfully", - "note_placeholder": "Enter note for this device...", - "no_note": "No note", - "online_count": "{count} devices online", - "offline_count": "{count} devices offline", - "id_changed": "Device ID changed successfully", - "new_id": "New ID", - "old_id": "Old ID", - "id_requirements": "6-16 characters, letters, numbers, hyphens, underscores only", - "id_already_exists": "This ID is already in use", - "banned": "Banned", - "banned_at": "Banned at", - "banned_by": "Banned by", - "ban_reason": "Ban reason", - "enter_ban_reason": "Enter reason for banning..." - }, - "public_key": { - "title": "Server Public Key", - "protected_content": "Protected Content", - "enter_password": "Enter your password to view the server public key", - "password_placeholder": "Enter your password", - "unlock_key": "Unlock Key", - "lock_key": "Lock Key", - "rustdesk_public_key": "RustDesk Public Key", - "key_copied": "Public key copied to clipboard", - "key_not_found": "Public key not found", - "invalid_password": "Invalid password" - }, - "settings": { - "title": "Settings", - "server_settings": "Server Settings", - "account_settings": "Account Settings", - "language_settings": "Language Settings", - "peer_timeout": "Peer Offline Timeout (seconds)", - "peer_timeout_description": "Time before a device is marked as offline (10-300 seconds)", - "heartbeat_interval": "Status Check Interval (seconds)", - "heartbeat_interval_description": "How often to check device status (1-30 seconds)", - "warning_threshold": "Warning Threshold (missed heartbeats)", - "warning_threshold_description": "Number of missed heartbeats before \"Degraded\" status", - "critical_threshold": "Critical Threshold (missed heartbeats)", - "critical_threshold_description": "Number of missed heartbeats before \"Critical\" status", - "save_server_settings": "Save Server Settings", - "reload_settings": "Reload", - "settings_saved": "Settings saved successfully", - "select_language": "Select Language", - "language_changed": "Language changed successfully", - "language_description": "Choose your preferred language for the interface", - "language": "Language Settings", - "language_saved": "Your language preference is saved automatically", - "upload_language": "Upload Custom Language Pack", - "choose_file": "Choose JSON File", - "upload_hint": "Upload a JSON language file. See documentation for format.", - "installed_languages": "Installed Languages", - "delete_language": "Delete Language Pack", - "upload_success": "Language pack uploaded successfully", - "upload_failed": "Failed to upload language pack" - }, - "users": { - "title": "User Management", - "add_user": "Add User", - "edit_user": "Edit User", - "delete_user": "Delete User", - "username": "Username", - "role": "Role", - "last_login": "Last Login", - "status": "Status", - "actions": "Actions", - "role_admin": "Admin", - "role_operator": "Operator", - "role_viewer": "Viewer", - "status_active": "Active", - "status_inactive": "Inactive", - "never_logged_in": "Never", - "confirm_delete_user": "Are you sure you want to delete this user?", - "user_created": "User created successfully", - "user_updated": "User updated successfully", - "user_deleted": "User deleted successfully", - "cannot_delete_self": "You cannot delete your own account", - "cannot_delete_last_admin": "Cannot delete the last admin user" - }, - "about": { - "title": "About BetterDesk Console", - "project_info": "Project Information", - "version": "Version", - "description": "Advanced management interface for RustDesk Server. A powerful, secure web console for managing RustDesk remote desktop server. Features include device management, user authentication with role-based access control, ban enforcement with fail-closed security policy, and comprehensive audit logging.", - "source_code": "Source Code", - "github_description": "This project is open source and available on GitHub:", - "contributions_welcome": "Issues, feature requests, and contributions are welcome!", - "open_source_components": "Open Source Components", - "built_with": "This project is built with the following open source software:", - "license": "License" - }, - "client_generator": { - "title": "Client Generator", - "description": "Generate customized RustDesk client with your server configuration", - "server_config": "Server Configuration", - "server_address": "Server Address", - "server_key": "Server Key", - "generate": "Generate Client", - "generating": "Generating...", - "download_client": "Download Client", - "client_ready": "Client is ready for download" - }, - "errors": { - "generic": "An error occurred. Please try again.", - "network": "Network error. Please check your connection.", - "unauthorized": "Unauthorized. Please login again.", - "forbidden": "You don't have permission to perform this action.", - "not_found": "Resource not found.", - "server_error": "Server error. Please try again later.", - "validation": "Please check your input and try again.", - "timeout": "Request timed out. Please try again.", - "rate_limit": "Too many requests. Please wait a moment." - }, - "time": { - "just_now": "Just now", - "seconds_ago": "{count} seconds ago", - "minute_ago": "1 minute ago", - "minutes_ago": "{count} minutes ago", - "hour_ago": "1 hour ago", - "hours_ago": "{count} hours ago", - "day_ago": "1 day ago", - "days_ago": "{count} days ago", - "never": "Never" - }, - "notifications": { - "device_connected": "Device {id} connected", - "device_disconnected": "Device {id} disconnected", - "device_banned": "Device {id} has been banned", - "new_device": "New device registered: {id}", - "settings_updated": "Settings have been updated" - } -} diff --git a/web/lang/pl.json b/web/lang/pl.json deleted file mode 100644 index 29ea29b6..00000000 --- a/web/lang/pl.json +++ /dev/null @@ -1,263 +0,0 @@ -{ - "_meta": { - "language": "Polish", - "code": "pl", - "version": "1.0.0", - "author": "BetterDesk Team", - "last_updated": "2026-02-11" - }, - "common": { - "app_name": "BetterDesk Console", - "loading": "Ładowanie...", - "save": "Zapisz", - "cancel": "Anuluj", - "delete": "Usuń", - "edit": "Edytuj", - "add": "Dodaj", - "close": "Zamknij", - "confirm": "Potwierdź", - "yes": "Tak", - "no": "Nie", - "ok": "OK", - "error": "Błąd", - "success": "Sukces", - "warning": "Ostrzeżenie", - "info": "Informacja", - "search": "Szukaj", - "refresh": "Odśwież", - "actions": "Akcje", - "status": "Status", - "created": "Utworzono", - "updated": "Zaktualizowano", - "back": "Wróć", - "next": "Dalej", - "previous": "Wstecz", - "submit": "Wyślij", - "reset": "Resetuj", - "copy": "Kopiuj", - "copy_to_clipboard": "Kopiuj do schowka", - "copied": "Skopiowano!", - "download": "Pobierz", - "upload": "Wyślij", - "select": "Wybierz", - "none": "Brak", - "all": "Wszystkie", - "total": "Razem", - "active": "Aktywne", - "inactive": "Nieaktywne", - "enabled": "Włączony", - "disabled": "Wyłączony", - "online": "Online", - "offline": "Offline", - "unknown": "Nieznany" - }, - "auth": { - "login": "Zaloguj", - "logout": "Wyloguj", - "username": "Nazwa użytkownika", - "password": "Hasło", - "current_password": "Aktualne hasło", - "new_password": "Nowe hasło", - "confirm_password": "Potwierdź hasło", - "remember_me": "Zapamiętaj mnie", - "forgot_password": "Zapomniałeś hasła?", - "login_title": "Logowanie - BetterDesk Console", - "login_subtitle": "Panel zarządzania RustDesk", - "login_button": "Zaloguj się", - "sign_in": "Zaloguj się", - "logging_in": "Logowanie...", - "login_failed": "Logowanie nieudane", - "invalid_credentials": "Nieprawidłowa nazwa użytkownika lub hasło", - "session_expired": "Sesja wygasła. Zaloguj się ponownie.", - "logout_success": "Wylogowano pomyślnie", - "change_password": "Zmień hasło", - "password_changed": "Hasło zostało zmienione", - "password_mismatch": "Hasła nie są zgodne", - "password_requirements": "Minimum 8 znaków, musi zawierać litery i cyfry", - "password_too_weak": "Hasło jest zbyt słabe", - "enter_username": "Wprowadź nazwę użytkownika", - "enter_password": "Wprowadź hasło" - }, - "sidebar": { - "dashboard": "Panel główny", - "client_generator": "Generator klienta", - "public_key": "Klucz publiczny", - "settings": "Ustawienia", - "users": "Zarządzanie użytkownikami", - "about": "O programie", - "language": "Język", - "logout": "Wyloguj" - }, - "dashboard": { - "title": "Panel urządzeń", - "device_management": "Zarządzanie urządzeniami", - "total_devices": "Wszystkie urządzenia", - "active_devices": "Aktywne", - "inactive_devices": "Nieaktywne", - "banned_devices": "Zablokowane", - "with_notes": "Z notatkami", - "connected_devices": "Podłączone urządzenia", - "search_devices": "Szukaj urządzeń...", - "no_devices": "Nie znaleziono urządzeń", - "loading_devices": "Ładowanie urządzeń...", - "refresh_devices": "Odśwież", - "last_updated": "Ostatnia aktualizacja", - "auto_refresh": "Auto-odświeżanie", - "degraded": "Zdegradowany", - "critical": "Krytyczny" - }, - "devices": { - "id": "ID", - "note": "Notatka", - "status": "Status", - "created": "Utworzono", - "last_online": "Ostatnio online", - "actions": "Akcje", - "add_note": "Dodaj notatkę", - "edit_note": "Edytuj notatkę", - "save_note": "Zapisz notatkę", - "delete_device": "Usuń urządzenie", - "ban_device": "Zablokuj urządzenie", - "unban_device": "Odblokuj urządzenie", - "change_id": "Zmień ID", - "view_details": "Zobacz szczegóły", - "copy_id": "Kopiuj ID", - "confirm_delete": "Czy na pewno chcesz usunąć to urządzenie?", - "confirm_ban": "Czy na pewno chcesz zablokować to urządzenie?", - "confirm_unban": "Czy na pewno chcesz odblokować to urządzenie?", - "device_deleted": "Urządzenie zostało usunięte", - "device_banned": "Urządzenie zostało zablokowane", - "device_unbanned": "Urządzenie zostało odblokowane", - "note_saved": "Notatka zapisana", - "note_placeholder": "Wpisz notatkę dla tego urządzenia...", - "no_note": "Brak notatki", - "online_count": "{count} urządzeń online", - "offline_count": "{count} urządzeń offline", - "id_changed": "ID urządzenia zostało zmienione", - "new_id": "Nowe ID", - "old_id": "Stare ID", - "id_requirements": "6-16 znaków, tylko litery, cyfry, myślniki i podkreślenia", - "id_already_exists": "To ID jest już używane", - "banned": "Zablokowany", - "banned_at": "Zablokowano", - "banned_by": "Zablokował", - "ban_reason": "Powód blokady", - "enter_ban_reason": "Wpisz powód blokady..." - }, - "public_key": { - "title": "Klucz publiczny serwera", - "protected_content": "Zawartość chroniona", - "enter_password": "Wprowadź hasło, aby zobaczyć klucz publiczny serwera", - "password_placeholder": "Wprowadź hasło", - "unlock_key": "Odblokuj klucz", - "lock_key": "Zablokuj klucz", - "rustdesk_public_key": "Klucz publiczny RustDesk", - "key_copied": "Klucz publiczny skopiowany do schowka", - "key_not_found": "Nie znaleziono klucza publicznego", - "invalid_password": "Nieprawidłowe hasło" - }, - "settings": { - "title": "Ustawienia", - "server_settings": "Ustawienia serwera", - "account_settings": "Ustawienia konta", - "language_settings": "Ustawienia języka", - "peer_timeout": "Czas do oznaczenia jako offline (sekundy)", - "peer_timeout_description": "Czas po którym urządzenie jest oznaczane jako offline (10-300 sekund)", - "heartbeat_interval": "Interwał sprawdzania statusu (sekundy)", - "heartbeat_interval_description": "Jak często sprawdzać status urządzeń (1-30 sekund)", - "warning_threshold": "Próg ostrzeżenia (pominięte heartbeaty)", - "warning_threshold_description": "Liczba pominiętych heartbeatów przed statusem \"Zdegradowany\"", - "critical_threshold": "Próg krytyczny (pominięte heartbeaty)", - "critical_threshold_description": "Liczba pominiętych heartbeatów przed statusem \"Krytyczny\"", - "save_server_settings": "Zapisz ustawienia serwera", - "reload_settings": "Załaduj ponownie", - "settings_saved": "Ustawienia zapisane", - "select_language": "Wybierz język", - "language_changed": "Język został zmieniony", - "language_description": "Wybierz preferowany język interfejsu", - "language": "Ustawienia języka", - "language_saved": "Twoje preferencje językowe są zapisywane automatycznie", - "upload_language": "Prześlij własny pakiet językowy", - "choose_file": "Wybierz plik JSON", - "upload_hint": "Prześlij plik JSON z tłumaczeniami. Format opisany w dokumentacji.", - "installed_languages": "Zainstalowane języki", - "delete_language": "Usuń pakiet językowy", - "upload_success": "Pakiet językowy przesłany pomyślnie", - "upload_failed": "Nie udało się przesłać pakietu językowego" - }, - "users": { - "title": "Zarządzanie użytkownikami", - "add_user": "Dodaj użytkownika", - "edit_user": "Edytuj użytkownika", - "delete_user": "Usuń użytkownika", - "username": "Nazwa użytkownika", - "role": "Rola", - "last_login": "Ostatnie logowanie", - "status": "Status", - "actions": "Akcje", - "role_admin": "Administrator", - "role_operator": "Operator", - "role_viewer": "Przeglądający", - "status_active": "Aktywny", - "status_inactive": "Nieaktywny", - "never_logged_in": "Nigdy", - "confirm_delete_user": "Czy na pewno chcesz usunąć tego użytkownika?", - "user_created": "Użytkownik utworzony", - "user_updated": "Użytkownik zaktualizowany", - "user_deleted": "Użytkownik usunięty", - "cannot_delete_self": "Nie możesz usunąć własnego konta", - "cannot_delete_last_admin": "Nie można usunąć ostatniego administratora" - }, - "about": { - "title": "O BetterDesk Console", - "project_info": "Informacje o projekcie", - "version": "Wersja", - "description": "Zaawansowany interfejs zarządzania serwerem RustDesk. Potężna, bezpieczna konsola webowa do zarządzania serwerem zdalnego pulpitu RustDesk. Funkcje obejmują zarządzanie urządzeniami, uwierzytelnianie użytkowników z kontrolą dostępu opartą na rolach, egzekwowanie blokad z polityką fail-closed oraz kompleksowe logowanie audytu.", - "source_code": "Kod źródłowy", - "github_description": "Ten projekt jest open source i dostępny na GitHub:", - "contributions_welcome": "Zgłoszenia problemów, propozycje funkcji i wkład są mile widziane!", - "open_source_components": "Komponenty Open Source", - "built_with": "Ten projekt został zbudowany z użyciem następującego oprogramowania open source:", - "license": "Licencja" - }, - "client_generator": { - "title": "Generator klienta", - "description": "Wygeneruj dostosowanego klienta RustDesk z konfiguracją Twojego serwera", - "server_config": "Konfiguracja serwera", - "server_address": "Adres serwera", - "server_key": "Klucz serwera", - "generate": "Generuj klienta", - "generating": "Generowanie...", - "download_client": "Pobierz klienta", - "client_ready": "Klient jest gotowy do pobrania" - }, - "errors": { - "generic": "Wystąpił błąd. Spróbuj ponownie.", - "network": "Błąd sieci. Sprawdź połączenie.", - "unauthorized": "Brak autoryzacji. Zaloguj się ponownie.", - "forbidden": "Nie masz uprawnień do wykonania tej akcji.", - "not_found": "Nie znaleziono zasobu.", - "server_error": "Błąd serwera. Spróbuj później.", - "validation": "Sprawdź dane i spróbuj ponownie.", - "timeout": "Przekroczono limit czasu. Spróbuj ponownie.", - "rate_limit": "Zbyt wiele żądań. Poczekaj chwilę." - }, - "time": { - "just_now": "Przed chwilą", - "seconds_ago": "{count} sekund temu", - "minute_ago": "1 minutę temu", - "minutes_ago": "{count} minut temu", - "hour_ago": "1 godzinę temu", - "hours_ago": "{count} godzin temu", - "day_ago": "1 dzień temu", - "days_ago": "{count} dni temu", - "never": "Nigdy" - }, - "notifications": { - "device_connected": "Urządzenie {id} połączone", - "device_disconnected": "Urządzenie {id} rozłączone", - "device_banned": "Urządzenie {id} zostało zablokowane", - "new_device": "Nowe urządzenie zarejestrowane: {id}", - "settings_updated": "Ustawienia zostały zaktualizowane" - } -} diff --git a/web/requirements.txt b/web/requirements.txt deleted file mode 100644 index a8554072..00000000 --- a/web/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -Flask==3.0.0 -Flask-WTF==1.2.1 -Flask-Limiter==3.5.0 -requests==2.31.0 -bcrypt==4.1.2 -markupsafe==2.1.3 -Werkzeug==3.0.1 -Pillow==10.1.0 diff --git a/web/source_client_generator.py b/web/source_client_generator.py deleted file mode 100644 index 72431a08..00000000 --- a/web/source_client_generator.py +++ /dev/null @@ -1,826 +0,0 @@ -""" -RustDesk Source Client Generator Module -Generates custom RustDesk clients by modifying source code and compiling - -SUPPORTED PLATFORMS (Source Compilation from Linux server): -- Linux x64: Full support (native compilation) -- Linux ARM64: Cross-compilation with aarch64-linux-gnu - -REQUIRES SPECIAL SETUP (not recommended for cross-compilation): -- Windows x64: Cross-compilation requires vcpkg with Windows-targeted dependencies - (libvpx, libyuv, opus, aom, OpenSSL) - extremely complex setup - RECOMMENDATION: Use 'Config Injection' method or build on native Windows - -NOT SUPPORTED (use Config Injection instead): -- macOS: Requires macOS SDK and Apple hardware -- Android: Requires Android NDK and complex setup -- Windows x86 (32-bit): Limited demand - -For Windows/macOS clients, use 'Config Injection' method which modifies -pre-built official RustDesk binaries instead of compiling from source. -""" - -import os -import json -import shutil -import subprocess -import re -import base64 -from datetime import datetime -import uuid -from pathlib import Path -import logging -import threading -import time - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -class BuildStatus: - """Tracks build status and progress""" - PENDING = "pending" - PREPARING = "preparing" - MODIFYING = "modifying" - COMPILING = "compiling" - PACKAGING = "packaging" - COMPLETED = "completed" - FAILED = "failed" - - -# Platform name aliases (UI names -> internal names) -PLATFORM_ALIASES = { - 'windows-64': 'windows-x64', - 'windows-32': 'windows-x86', - 'linux': 'linux-x64', - 'macos': 'macos-x64', -} - -# Supported platforms for source compilation (from Linux server) -# Windows removed - cross-compilation requires vcpkg with Windows-targeted dependencies -# For Windows clients, use 'Config Injection' method instead -SUPPORTED_SOURCE_PLATFORMS = ['linux-x64', 'linux-arm64'] - -# Platforms that require special setup (warning will be shown but allowed) -EXPERIMENTAL_PLATFORMS = ['windows-x64'] - -# Platform to Rust target mapping -PLATFORM_TARGET_MAP = { - 'linux-x64': 'x86_64-unknown-linux-gnu', - 'linux': 'x86_64-unknown-linux-gnu', - 'linux-arm64': 'aarch64-unknown-linux-gnu', - 'windows-64': 'x86_64-pc-windows-gnu', - 'windows-x64': 'x86_64-pc-windows-gnu', - # Not supported: - 'windows-32': 'i686-pc-windows-gnu', - 'windows-x86': 'i686-pc-windows-gnu', -} - -# Binary file extensions by platform -BINARY_EXTENSIONS = { - 'linux-x64': '', - 'linux': '', - 'linux-arm64': '', - 'windows-64': '.exe', - 'windows-x64': '.exe', - 'windows-32': '.exe', - 'windows-x86': '.exe', -} - -# Cross-compilation linkers configuration -CROSS_LINKERS = { - 'linux-arm64': 'aarch64-linux-gnu-gcc', - 'windows-x64': 'x86_64-w64-mingw32-gcc', - 'windows-64': 'x86_64-w64-mingw32-gcc', -} - -# Environment variables for cross-compilation -CROSS_ENV = { - 'linux-arm64': { - 'CC': 'aarch64-linux-gnu-gcc', - 'CXX': 'aarch64-linux-gnu-g++', - 'AR': 'aarch64-linux-gnu-ar', - 'CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER': 'aarch64-linux-gnu-gcc', - }, - 'windows-x64': { - 'CC': 'x86_64-w64-mingw32-gcc', - 'CXX': 'x86_64-w64-mingw32-g++', - 'AR': 'x86_64-w64-mingw32-ar', - 'CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER': 'x86_64-w64-mingw32-gcc', - }, - 'windows-64': { - 'CC': 'x86_64-w64-mingw32-gcc', - 'CXX': 'x86_64-w64-mingw32-g++', - 'AR': 'x86_64-w64-mingw32-ar', - 'CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER': 'x86_64-w64-mingw32-gcc', - }, -} - - -def normalize_platform(platform): - """Normalize platform name from UI to internal format""" - return PLATFORM_ALIASES.get(platform, platform) - - -def get_supported_platforms(): - """Return list of platforms supported for source compilation""" - return SUPPORTED_SOURCE_PLATFORMS.copy() - - -def is_platform_supported(platform): - """Check if platform is supported for source compilation (with alias support)""" - normalized = normalize_platform(platform) - return normalized in SUPPORTED_SOURCE_PLATFORMS - - -class SourceClientGenerator: - """Handles the generation of custom RustDesk clients from source""" - - # Path to the cloned RustDesk source - SOURCE_DIR = os.path.expanduser("~/rustdesk-build/rustdesk-source") - - # Build output directory - BUILD_DIR = os.path.expanduser("~/rustdesk-build/builds") - - # RustDesk GitHub repository - RUSTDESK_REPO = "https://github.com/rustdesk/rustdesk.git" - - # Default version to clone - DEFAULT_VERSION = "1.4.5" - - # Key source files to modify - CONFIG_RS = "libs/hbb_common/src/config.rs" - CARGO_TOML = "Cargo.toml" - LOGO_SVG = "res/logo.svg" - LOGO_PNG = "res/logo.png" - - def __init__(self): - """Initialize the generator""" - os.makedirs(self.BUILD_DIR, exist_ok=True) - - # Build status tracking - self._builds = {} - self._lock = threading.Lock() - - def _get_build_work_dir(self, build_id): - """Get working directory for a specific build""" - return os.path.join(self.BUILD_DIR, f"build_{build_id}") - - def _update_status(self, build_id, status, message=None, progress=0): - """Update build status""" - with self._lock: - if build_id not in self._builds: - self._builds[build_id] = {} - - self._builds[build_id].update({ - 'status': status, - 'message': message, - 'progress': progress, - 'updated_at': datetime.now().isoformat() - }) - - def get_build_status(self, build_id): - """Get current status of a build""" - with self._lock: - return self._builds.get(build_id, { - 'status': BuildStatus.PENDING, - 'message': 'Build not found' - }) - - def _clone_source(self, version="1.4.5"): - """Clone RustDesk source from GitHub""" - logger.info(f"Cloning RustDesk source v{version} from {self.RUSTDESK_REPO}") - - # Create parent directory - parent_dir = os.path.dirname(self.SOURCE_DIR) - os.makedirs(parent_dir, exist_ok=True) - - # Clone with specific version/tag - cmd = [ - "git", "clone", - "--branch", version, - "--depth", "1", - self.RUSTDESK_REPO, - self.SOURCE_DIR - ] - - logger.info(f"Running: {' '.join(cmd)}") - - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=600 # 10 minutes timeout for clone - ) - - if result.returncode != 0: - raise Exception(f"Failed to clone RustDesk source: {result.stderr}") - - logger.info("Clone completed, initializing submodules...") - - # Initialize submodules - cmd_submodule = ["git", "submodule", "update", "--init", "--recursive"] - - result = subprocess.run( - cmd_submodule, - cwd=self.SOURCE_DIR, - capture_output=True, - text=True, - timeout=600 - ) - - if result.returncode != 0: - logger.warning(f"Submodule initialization warning: {result.stderr}") - - logger.info("Source cloned and submodules initialized successfully") - return True - - def _check_source_version(self, required_version): - """Check if cloned source matches required version""" - try: - # Try to get current tag/branch - result = subprocess.run( - ["git", "describe", "--tags", "--exact-match"], - cwd=self.SOURCE_DIR, - capture_output=True, - text=True, - timeout=30 - ) - - if result.returncode == 0: - current_version = result.stdout.strip().lstrip('v') - return current_version == required_version.lstrip('v') - - # Fallback: check branch - result = subprocess.run( - ["git", "branch", "--show-current"], - cwd=self.SOURCE_DIR, - capture_output=True, - text=True, - timeout=30 - ) - - if result.returncode == 0: - current_branch = result.stdout.strip() - return current_branch == required_version or current_branch == f"v{required_version}" - - return False - except Exception as e: - logger.warning(f"Could not check source version: {e}") - return True # Assume it's okay if we can't check - - def ensure_source_exists(self, version="1.4.5"): - """Ensure source code exists, clone if necessary""" - - # Check if source directory exists and has required files - cargo_toml = os.path.join(self.SOURCE_DIR, "Cargo.toml") - - if os.path.exists(cargo_toml): - logger.info(f"Source already exists at {self.SOURCE_DIR}") - - # Check if version matches (optional - could re-clone if different) - if not self._check_source_version(version): - logger.warning(f"Source version mismatch, but continuing with existing source") - - # Ensure submodules are initialized - config_rs = os.path.join(self.SOURCE_DIR, self.CONFIG_RS) - if not os.path.exists(config_rs): - logger.info("Submodules not initialized, initializing now...") - subprocess.run( - ["git", "submodule", "update", "--init", "--recursive"], - cwd=self.SOURCE_DIR, - capture_output=True, - timeout=600 - ) - - return True - - # Source doesn't exist, need to clone - logger.info(f"Source not found at {self.SOURCE_DIR}, cloning...") - return self._clone_source(version) - - def prepare_source(self, build_id, version="1.4.5"): - """Copy source to working directory, auto-download if needed""" - self._update_status(build_id, BuildStatus.PREPARING, "Preparing source files...", 5) - - work_dir = self._get_build_work_dir(build_id) - - # Auto-download source if not exists - if not os.path.exists(os.path.join(self.SOURCE_DIR, "Cargo.toml")): - self._update_status(build_id, BuildStatus.PREPARING, "Downloading RustDesk source (first build only)...", 2) - try: - self.ensure_source_exists(version) - except Exception as e: - raise Exception(f"Failed to download RustDesk source: {e}") - - # Verify source exists now - if not os.path.exists(self.SOURCE_DIR): - raise Exception(f"RustDesk source not found at {self.SOURCE_DIR}") - - # Clean up previous work directory if exists - if os.path.exists(work_dir): - shutil.rmtree(work_dir) - - # Copy source to work directory - logger.info(f"Copying source from {self.SOURCE_DIR} to {work_dir}") - shutil.copytree(self.SOURCE_DIR, work_dir, symlinks=True) - - self._update_status(build_id, BuildStatus.PREPARING, "Source files copied", 10) - - return work_dir - - def modify_server_config(self, work_dir, server_host, server_key): - """Modify server configuration in source""" - config_path = os.path.join(work_dir, self.CONFIG_RS) - - if not os.path.exists(config_path): - logger.warning(f"Config file not found: {config_path}") - return False - - logger.info(f"Modifying server config in {config_path}") - - with open(config_path, 'r', encoding='utf-8') as f: - content = f.read() - - modified = False - - # Modify RENDEZVOUS_SERVERS - if server_host: - # Pattern: pub const RENDEZVOUS_SERVERS: &[&str] = &["rs-ny.rustdesk.com"]; - pattern = r'(pub\s+const\s+RENDEZVOUS_SERVERS:\s*&\[&str\]\s*=\s*&\[)[^\]]+(\];)' - replacement = f'\\1"{server_host}"\\2' - new_content, count = re.subn(pattern, replacement, content) - if count > 0: - content = new_content - modified = True - logger.info(f"Modified RENDEZVOUS_SERVERS to {server_host}") - - # Modify RS_PUB_KEY - if server_key: - # Pattern: pub const RS_PUB_KEY: &str = "..."; - pattern = r'(pub\s+const\s+RS_PUB_KEY:\s*&str\s*=\s*")[^"]*(")' - replacement = f'\\1{server_key}\\2' - new_content, count = re.subn(pattern, replacement, content) - if count > 0: - content = new_content - modified = True - logger.info(f"Modified RS_PUB_KEY") - - if modified: - with open(config_path, 'w', encoding='utf-8') as f: - f.write(content) - - return modified - - def modify_app_name(self, work_dir, app_name, app_description=None): - """Modify application name in Cargo.toml""" - cargo_path = os.path.join(work_dir, self.CARGO_TOML) - - if not os.path.exists(cargo_path): - logger.warning(f"Cargo.toml not found: {cargo_path}") - return False - - logger.info(f"Modifying app name in {cargo_path}") - - with open(cargo_path, 'r', encoding='utf-8') as f: - content = f.read() - - modified = False - - # Modify package name - if app_name: - # Sanitize app name for package naming (lowercase, no spaces) - safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', app_name.lower()) - - # Modify package name (first occurrence) - pattern = r'(name\s*=\s*")(rustdesk)(")' - new_content, count = re.subn(pattern, f'\\g<1>{safe_name}\\3', content, count=1) - if count > 0: - content = new_content - modified = True - logger.info(f"Modified package name to {safe_name}") - - # Modify default-run to match the new name - pattern_default_run = r'(default-run\s*=\s*")(rustdesk)(")' - new_content, count = re.subn(pattern_default_run, f'\\g<1>{safe_name}\\3', content, count=1) - if count > 0: - content = new_content - logger.info(f"Modified default-run to {safe_name}") - - # Also need to add a [[bin]] entry for our new app name - # Check if rustdesk bin exists and add our custom one - if f'[[bin]]\nname = "{safe_name}"' not in content: - # Add custom binary entry after [lib] section - bin_entry = f'\n[[bin]]\nname = "{safe_name}"\npath = "src/main.rs"\n' - - # Insert after [lib] section - lib_match = re.search(r'(\[lib\].*?crate-type\s*=\s*\[[^\]]+\])', content, re.DOTALL) - if lib_match: - insert_pos = lib_match.end() - content = content[:insert_pos] + bin_entry + content[insert_pos:] - logger.info(f"Added [[bin]] entry for {safe_name}") - - # Modify description - if app_description: - pattern = r'(description\s*=\s*")[^"]*(")' - replacement = f'\\1{app_description}\\2' - new_content, count = re.subn(pattern, replacement, content, count=1) - if count > 0: - content = new_content - modified = True - logger.info(f"Modified description to {app_description}") - - if modified: - with open(cargo_path, 'w', encoding='utf-8') as f: - f.write(content) - - return modified - - def modify_logo(self, work_dir, logo_base64=None, logo_svg_content=None): - """Replace logo files with custom logo""" - modified = False - - # Handle SVG logo - if logo_svg_content: - svg_path = os.path.join(work_dir, self.LOGO_SVG) - if os.path.exists(svg_path): - logger.info(f"Replacing SVG logo at {svg_path}") - with open(svg_path, 'w', encoding='utf-8') as f: - f.write(logo_svg_content) - modified = True - - # Handle base64 encoded image (convert to PNG) - if logo_base64: - png_path = os.path.join(work_dir, self.LOGO_PNG) - - # Remove data URI prefix if present - if ',' in logo_base64: - logo_base64 = logo_base64.split(',', 1)[1] - - try: - logo_data = base64.b64decode(logo_base64) - - logger.info(f"Writing PNG logo to {png_path}") - with open(png_path, 'wb') as f: - f.write(logo_data) - modified = True - - except Exception as e: - logger.error(f"Failed to decode/write logo: {e}") - - return modified - - def modify_icon(self, work_dir, icon_base64=None): - """Replace Windows icon with custom icon""" - if not icon_base64: - return False - - icon_path = os.path.join(work_dir, "res", "icon.ico") - - # Remove data URI prefix if present - if ',' in icon_base64: - icon_base64 = icon_base64.split(',', 1)[1] - - try: - icon_data = base64.b64decode(icon_base64) - - logger.info(f"Writing custom icon to {icon_path}") - with open(icon_path, 'wb') as f: - f.write(icon_data) - return True - - except Exception as e: - logger.error(f"Failed to decode/write icon: {e}") - return False - - def run_compile(self, work_dir, target="x86_64-unknown-linux-gnu", release=True, platform=None): - """Run cargo build compilation with cross-compilation support""" - cargo_path = os.path.expanduser("~/.cargo/bin/cargo") - - if not os.path.exists(cargo_path): - # Try system cargo - cargo_path = shutil.which("cargo") - if not cargo_path: - raise Exception("Cargo not found. Install Rust: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh") - - cmd = [cargo_path, "build"] - - if release: - cmd.append("--release") - - # Add target if specified - if target: - cmd.extend(["--target", target]) - - # For Windows, build without default features to avoid GUI dependencies - if platform and 'windows' in platform: - cmd.extend(["--no-default-features", "--features", "cli"]) - - logger.info(f"Running compilation: {' '.join(cmd)}") - - # Set up environment - env = os.environ.copy() - env["PATH"] = os.path.expanduser("~/.cargo/bin") + ":" + env.get("PATH", "") - - # Add cross-compilation environment variables - if platform and platform in CROSS_ENV: - for key, value in CROSS_ENV[platform].items(): - env[key] = value - logger.info(f"Setting {key}={value} for cross-compilation") - - # Write cargo config for cross-compilation linker - if platform and platform in CROSS_LINKERS: - cargo_config_dir = os.path.join(work_dir, ".cargo") - os.makedirs(cargo_config_dir, exist_ok=True) - cargo_config_path = os.path.join(cargo_config_dir, "config.toml") - - linker = CROSS_LINKERS[platform] - target_name = PLATFORM_TARGET_MAP.get(platform, target) - - config_content = f""" -[target.{target_name}] -linker = "{linker}" -""" - with open(cargo_config_path, 'w') as f: - f.write(config_content) - logger.info(f"Created cargo config for {target_name} with linker {linker}") - - process = subprocess.Popen( - cmd, - cwd=work_dir, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - env=env, - universal_newlines=True - ) - - output_lines = [] - for line in process.stdout: - output_lines.append(line) - logger.info(f"[cargo] {line.rstrip()}") - - return_code = process.wait() - - if return_code != 0: - raise Exception(f"Compilation failed (exit code: {return_code})\n{''.join(output_lines[-50:])}") - - return True - - def get_compiled_binary(self, work_dir, target="x86_64-unknown-linux-gnu", release=True, app_name="rustdesk"): - """Get path to compiled binary""" - profile = "release" if release else "debug" - - # Sanitize app name (same as in modify_app_name) - safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', app_name.lower()) if app_name else "rustdesk" - - if target: - binary_path = os.path.join(work_dir, "target", target, profile, safe_name) - else: - binary_path = os.path.join(work_dir, "target", profile, safe_name) - - # Add .exe extension for Windows targets - if "windows" in (target or ""): - binary_path += ".exe" - - if os.path.exists(binary_path): - return binary_path - - # Fallback: look for rustdesk binary if custom name not found - fallback_path = binary_path.replace(safe_name, "rustdesk") - if os.path.exists(fallback_path): - return fallback_path - - # List what we have in target directory - target_dir = os.path.join(work_dir, "target", target or "", profile) - if os.path.exists(target_dir): - files = os.listdir(target_dir) - logger.info(f"Files in {target_dir}: {files[:20]}") - - # Find any executable - for f in files: - fpath = os.path.join(target_dir, f) - if os.path.isfile(fpath) and os.access(fpath, os.X_OK): - if not f.endswith('.d') and not f.endswith('.so') and 'lib' not in f: - return fpath - - raise Exception(f"Compiled binary not found: {binary_path}") - - def generate(self, config_data): - """Main method to generate a custom client from source""" - - build_id = str(uuid.uuid4())[:8] - - try: - # Extract configuration - server_host = config_data.get('server_host', '') - server_key = config_data.get('server_key', '') - app_name = config_data.get('app_name', '') - custom_text = config_data.get('custom_text', '') - logo_base64 = config_data.get('logo_base64', '') - icon_base64 = config_data.get('icon_base64', '') - platform = config_data.get('platform', 'linux-x64') - version = config_data.get('version', '1.4.5') - - # Normalize platform name using global function - platform_normalized = normalize_platform(platform) - - logger.info(f"Build request: platform={platform}, normalized={platform_normalized}") - - # Check if platform is supported for source compilation - if not is_platform_supported(platform_normalized): - # Provide specific error messages for different platforms - if 'windows' in platform_normalized.lower(): - raise Exception( - f"Windows cross-compilation from Linux is not supported. " - f"Building RustDesk for Windows requires vcpkg with Windows-targeted " - f"dependencies (OpenSSL, libvpx, libyuv, opus, aom) which are complex to set up. " - f"RECOMMENDED: Use 'Config Injection' method instead, which modifies " - f"pre-built official RustDesk binaries without recompilation." - ) - elif 'macos' in platform_normalized.lower(): - raise Exception( - f"macOS compilation requires Apple hardware and macOS SDK. " - f"RECOMMENDED: Use 'Config Injection' method instead." - ) - else: - supported_list = ', '.join(SUPPORTED_SOURCE_PLATFORMS) - raise Exception( - f"Platform '{platform}' is not supported for source compilation. " - f"Supported platforms: {supported_list}. " - f"For other platforms, use 'Config Injection' method." - ) - - # Get target for platform - target = PLATFORM_TARGET_MAP.get(platform_normalized, PLATFORM_TARGET_MAP.get(platform)) - if not target: - target = 'x86_64-unknown-linux-gnu' - - logger.info(f"Using Rust target: {target}") - - # Step 1: Prepare source - self._update_status(build_id, BuildStatus.PREPARING, "Copying source files...", 5) - work_dir = self.prepare_source(build_id, version) - - # Step 2: Modify source files - self._update_status(build_id, BuildStatus.MODIFYING, "Modifying source code...", 15) - - # Modify server config - if server_host or server_key: - self.modify_server_config(work_dir, server_host, server_key) - self._update_status(build_id, BuildStatus.MODIFYING, "Server configuration updated", 20) - - # Modify app name - if app_name: - self.modify_app_name(work_dir, app_name, custom_text or f"{app_name} Remote Desktop") - self._update_status(build_id, BuildStatus.MODIFYING, f"App name changed to {app_name}", 25) - - # Modify logo - if logo_base64: - self.modify_logo(work_dir, logo_base64=logo_base64) - self._update_status(build_id, BuildStatus.MODIFYING, "Logo updated", 30) - - # Modify icon - if icon_base64: - self.modify_icon(work_dir, icon_base64=icon_base64) - self._update_status(build_id, BuildStatus.MODIFYING, "Icon updated", 35) - - # Step 3: Compile - self._update_status(build_id, BuildStatus.COMPILING, "Compiling (may take 5-15 minutes)...", 40) - - logger.info(f"Starting compilation for target: {target}, platform: {platform_normalized}") - self.run_compile(work_dir, target=target, release=True, platform=platform_normalized) - - self._update_status(build_id, BuildStatus.COMPILING, "Compilation completed", 90) - - # Step 4: Get compiled binary - self._update_status(build_id, BuildStatus.PACKAGING, "Packaging...", 95) - - binary_path = self.get_compiled_binary( - work_dir, - target=target, - release=True, - app_name=app_name or "rustdesk" - ) - - # Copy to output directory - output_name = config_data.get('config_name', app_name or 'custom-rustdesk') - output_name = re.sub(r'[^a-zA-Z0-9_-]', '_', output_name) - - if "windows" in platform: - output_filename = f"{output_name}.exe" - else: - output_filename = output_name - - output_path = os.path.join(self.BUILD_DIR, output_filename) - shutil.copy2(binary_path, output_path) - - # Make executable on Linux - if "linux" in platform: - os.chmod(output_path, 0o755) - - # Save metadata - metadata = { - 'build_id': build_id, - 'platform': platform, - 'target': target, - 'version': version, - 'app_name': app_name, - 'server_host': server_host, - 'created_at': datetime.now().isoformat(), - 'output_file': output_filename - } - - metadata_path = output_path + '.json' - with open(metadata_path, 'w') as f: - json.dump(metadata, f, indent=2) - - self._update_status(build_id, BuildStatus.COMPLETED, f"Completed: {output_filename}", 100) - - # Cleanup work directory (optional - keep for debugging) - # shutil.rmtree(work_dir, ignore_errors=True) - - return { - 'success': True, - 'build_id': build_id, - 'client_path': output_path, - 'metadata_path': metadata_path, - 'filename': output_filename, - 'message': f'Client compiled successfully: {output_filename}' - } - - except Exception as e: - logger.error(f"Build failed: {e}", exc_info=True) - self._update_status(build_id, BuildStatus.FAILED, str(e), 0) - - return { - 'success': False, - 'build_id': build_id, - 'error': str(e) - } - - def cleanup_old_builds(self, max_age_hours=24): - """Clean up old build files""" - try: - current_time = time.time() - - for item in os.listdir(self.BUILD_DIR): - item_path = os.path.join(self.BUILD_DIR, item) - - item_age = current_time - os.path.getmtime(item_path) - if item_age > (max_age_hours * 3600): - if os.path.isdir(item_path): - shutil.rmtree(item_path, ignore_errors=True) - else: - os.remove(item_path) - logger.info(f"Cleaned up old build: {item}") - - except Exception as e: - logger.error(f"Error cleaning up old builds: {e}") - - -# Singleton instance -_generator_instance = None - - -def get_generator(): - """Get or create generator instance""" - global _generator_instance - if _generator_instance is None: - _generator_instance = SourceClientGenerator() - return _generator_instance - - -def generate_from_source(config_data): - """Helper function to generate a custom client from source""" - generator = get_generator() - - # Cleanup old builds first - generator.cleanup_old_builds() - - # Generate new client - result = generator.generate(config_data) - - return result - - -def get_build_status(build_id): - """Get status of a specific build""" - generator = get_generator() - return generator.get_build_status(build_id) - - -# Test configuration -if __name__ == "__main__": - test_config = { - 'server_host': '127.0.0.1', - 'server_key': 'TestKey123==', - # Change server_host to your actual server IP before running - 'app_name': 'MyRemoteApp', - 'platform': 'linux', - 'version': '1.4.5' - } - - print("Starting source compilation test...") - result = generate_from_source(test_config) - print(f"Result: {json.dumps(result, indent=2)}") diff --git a/web/static/MATERIAL_ICONS.md b/web/static/MATERIAL_ICONS.md deleted file mode 100644 index d06ad90b..00000000 --- a/web/static/MATERIAL_ICONS.md +++ /dev/null @@ -1,67 +0,0 @@ -# Material Icons Font - Download Instructions - -The `material-icons.woff2` file is required for the web console to display icons properly. - -## Automatic Installation - -The `install.sh` script will automatically download this file during installation. - -## Manual Download - -If you need to download it manually: - -```bash -cd web/static -curl -o material-icons.woff2 'https://fonts.gstatic.com/s/materialicons/v140/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2' -``` - -**File Details:** -- Name: `material-icons.woff2` -- Size: ~126 KB -- Format: WOFF2 (Web Open Font Format 2) -- License: Apache License 2.0 -- Source: Google Fonts - -## Verification - -After downloading, verify the file: - -```bash -ls -lh material-icons.woff2 -# Should show: ~126K file size -``` - -## Usage - -The font is referenced in `style.css`: - -```css -@font-face { - font-family: 'Material Icons'; - font-style: normal; - font-weight: 400; - src: url('material-icons.woff2') format('woff2'); -} -``` - -## Why Not Included in Git? - -The font file is a binary file (~126KB) and is typically not committed to Git repositories. Instead, it's downloaded during installation to: -- Keep repository size small -- Ensure latest version is used -- Comply with Git best practices - -## Alternative: CDN Version - -If you prefer using a CDN instead of hosting locally, modify `style.css`: - -```css -@import url('https://fonts.googleapis.com/icon?family=Material+Icons'); -``` - -However, this requires internet connectivity, while the local version works offline. - -## License - -Material Icons are licensed under the Apache License 2.0. -See: https://github.com/google/material-design-icons/blob/master/LICENSE diff --git a/web/static/client_generator.css b/web/static/client_generator.css deleted file mode 100644 index aa5aeaab..00000000 --- a/web/static/client_generator.css +++ /dev/null @@ -1,447 +0,0 @@ -/* Client Generator Styles */ - -.generator-container { - max-width: 1600px; - margin: 0 auto; - padding: 1rem; -} - -.generator-section { - margin-bottom: 1.5rem; - padding: 1.5rem; - border-radius: 12px; -} - -.section-title { - font-size: 1.25rem; - font-weight: 600; - color: var(--text-primary); - margin-bottom: 1.5rem; - display: flex; - align-items: center; - gap: 0.75rem; -} - -.section-title i { - color: var(--primary-color); - font-size: 1.5rem; -} - -/* Platform Selection */ -.platform-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 1rem; - margin-bottom: 1.5rem; -} - -.platform-card { - background: rgba(40, 40, 40, 0.6); - border: 2px solid var(--glass-border); - border-radius: 8px; - padding: 1.5rem 1rem; - text-align: center; - cursor: pointer; - transition: all 0.3s ease; - position: relative; -} - -.platform-card:hover { - background: rgba(55, 55, 55, 0.7); - border-color: var(--primary-color); - transform: translateY(-2px); -} - -.platform-card.selected { - background: rgba(107, 114, 128, 0.3); - border-color: var(--primary-color); - box-shadow: 0 0 20px rgba(107, 114, 128, 0.4); -} - -.platform-icon { - font-size: 3rem; - color: var(--text-secondary); - margin-bottom: 0.75rem; - position: relative; - display: inline-block; -} - -.platform-card.selected .platform-icon { - color: var(--primary-color); -} - -.platform-badge { - position: absolute; - top: -5px; - right: -10px; - background: var(--primary-color); - color: white; - font-size: 0.7rem; - padding: 2px 6px; - border-radius: 4px; - font-weight: 600; -} - -.platform-name { - display: block; - font-size: 0.9rem; - color: var(--text-primary); - font-weight: 500; -} - -/* Two Column Layout */ -.two-column-layout { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1.5rem; - margin-bottom: 1.5rem; -} - -@media (max-width: 1200px) { - .two-column-layout { - grid-template-columns: 1fr; - } -} - -/* Form Elements */ -.form-group { - margin-bottom: 1.5rem; -} - -.form-group label { - display: block; - color: var(--text-secondary); - font-size: 0.9rem; - margin-bottom: 0.5rem; - font-weight: 500; -} - -.form-control { - width: 100%; - padding: 0.75rem; - background: rgba(40, 40, 40, 0.6); - border: 1px solid var(--glass-border); - border-radius: 6px; - color: var(--text-primary); - font-size: 0.95rem; - transition: all 0.3s ease; -} - -.form-control:focus { - outline: none; - border-color: var(--primary-color); - background: rgba(50, 50, 50, 0.7); - box-shadow: 0 0 0 3px rgba(107, 114, 128, 0.1); -} - -.form-control::placeholder { - color: var(--text-secondary); - opacity: 0.6; -} - -textarea.form-control { - resize: vertical; - min-height: 80px; - font-family: 'Courier New', monospace; -} - -select.form-control { - cursor: pointer; -} - -.form-text { - display: block; - margin-top: 0.5rem; - font-size: 0.85rem; - color: var(--text-secondary); - font-style: italic; -} - -/* Checkboxes */ -.checkbox-label { - display: flex; - align-items: center; - gap: 0.75rem; - cursor: pointer; - padding: 0.5rem; - border-radius: 6px; - transition: background 0.2s ease; -} - -.checkbox-label:hover { - background: rgba(55, 65, 81, 0.3); -} - -.checkbox-label input[type="checkbox"] { - width: 18px; - height: 18px; - cursor: pointer; - accent-color: var(--primary-color); -} - -.checkbox-label span { - color: var(--text-primary); - font-size: 0.95rem; -} - -/* Radio Buttons */ -.radio-group { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.radio-label { - display: flex; - align-items: center; - gap: 0.75rem; - cursor: pointer; - padding: 0.5rem; - border-radius: 6px; - transition: background 0.2s ease; -} - -.radio-label:hover { - background: rgba(55, 65, 81, 0.3); -} - -.radio-label input[type="radio"] { - width: 18px; - height: 18px; - cursor: pointer; - accent-color: var(--primary-color); -} - -.radio-label span { - color: var(--text-primary); - font-size: 0.95rem; -} - -/* Permissions Grid */ -.permissions-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 0.5rem; -} - -/* File Input */ -.file-input-wrapper { - display: flex; - align-items: center; - gap: 1rem; -} - -.file-input { - display: none; -} - -.file-btn { - padding: 0.6rem 1.2rem; - white-space: nowrap; -} - -.file-name { - color: var(--text-secondary); - font-size: 0.9rem; - font-style: italic; -} - -/* Generate Button */ -.generate-button-container { - text-align: center; - margin: 2rem 0; -} - -.btn-large { - padding: 1rem 3rem; - font-size: 1.1rem; - font-weight: 600; - border-radius: 8px; - transition: all 0.3s ease; -} - -.btn-large:hover { - transform: translateY(-2px); - box-shadow: 0 8px 20px rgba(107, 114, 128, 0.4); -} - -.btn-large i { - margin-right: 0.75rem; - font-size: 1.2rem; -} - -/* Generation Status */ -.generation-status { - margin-top: 2rem; - padding: 1.5rem; - background: rgba(40, 40, 40, 0.6); - border: 1px solid var(--glass-border); - border-radius: 8px; -} - -.progress-bar { - width: 100%; - height: 30px; - background: rgba(30, 30, 30, 0.8); - border-radius: 15px; - overflow: hidden; - margin-bottom: 1rem; - border: 1px solid var(--glass-border); -} - -.progress-fill { - height: 100%; - background: linear-gradient(90deg, var(--primary-color), var(--info-color)); - border-radius: 15px; - width: 0%; - transition: width 0.5s ease; - display: flex; - align-items: center; - justify-content: center; - color: white; - font-weight: 600; - font-size: 0.85rem; -} - -.status-message { - text-align: center; - color: var(--text-primary); - font-size: 1rem; - font-weight: 500; -} - -.status-message.success { - color: var(--success-color); -} - -.status-message.error { - color: var(--danger-color); -} - -.status-message i { - margin-right: 0.5rem; -} - -/* Download Link */ -.download-link { - display: inline-block; - margin-top: 1rem; - padding: 0.75rem 1.5rem; - background: var(--success-color); - color: white; - text-decoration: none; - border-radius: 6px; - font-weight: 600; - transition: all 0.3s ease; -} - -.download-link:hover { - background: #0ea472; - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4); -} - -.download-link i { - margin-right: 0.5rem; -} - -/* Responsive Design */ -@media (max-width: 768px) { - .platform-grid { - grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); - } - - .platform-icon { - font-size: 2rem; - } - - .permissions-grid { - grid-template-columns: 1fr; - } - - .file-input-wrapper { - flex-direction: column; - align-items: stretch; - } - - .btn-large { - width: 100%; - padding: 1rem; - } -} - -/* Animation for generation */ -@keyframes pulse { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } -} - -.generating { - animation: pulse 1.5s ease-in-out infinite; -} - -/* Auto-fill buttons */ -.auto-fill-buttons { - display: flex; - gap: 0.75rem; - margin-bottom: 1.5rem; - flex-wrap: wrap; -} - -.auto-fill-buttons .btn { - flex: 1; - min-width: 150px; -} - -/* Input group with addon button */ -.input-group { - display: flex; - gap: 0.5rem; - align-items: flex-start; -} - -.input-group .form-control { - flex: 1; -} - -.btn-input-addon { - padding: 0.75rem 1rem; - background: var(--primary-color); - border: 1px solid var(--glass-border); - border-radius: 6px; - color: white; - cursor: pointer; - transition: all 0.3s ease; - min-width: 45px; - height: 47px; -} - -.btn-input-addon-tall { - padding: 0.75rem 1rem; - background: var(--primary-color); - border: 1px solid var(--glass-border); - border-radius: 6px; - color: white; - cursor: pointer; - transition: all 0.3s ease; - min-width: 45px; - height: 92px; - display: flex; - align-items: center; - justify-content: center; -} - -.btn-input-addon:hover, -.btn-input-addon-tall:hover { - background: var(--secondary-color); - transform: translateY(-1px); -} - -.btn-input-addon i, -.btn-input-addon-tall i { - font-size: 1rem; -} diff --git a/web/static/client_generator.js b/web/static/client_generator.js deleted file mode 100644 index 9cbd1116..00000000 --- a/web/static/client_generator.js +++ /dev/null @@ -1,555 +0,0 @@ -/* Client Generator JavaScript */ - -// State -let selectedPlatform = 'windows-64'; -let detectedPublicIP = null; - -// Initialize -document.addEventListener('DOMContentLoaded', function() { - initializePlatformSelection(); - initializeFileInputs(); - checkAuthentication(); -}); - -function checkAuthentication() { - const token = localStorage.getItem('authToken'); - const username = localStorage.getItem('username'); - const role = localStorage.getItem('role'); - - if (!token) { - window.location.href = '/login'; - return; - } - - // Setup user info in sidebar - document.getElementById('sidebarUsername').textContent = username || 'User'; - document.getElementById('sidebarRole').textContent = role || 'viewer'; - - // Show admin-only sections - if (role === 'admin') { - document.querySelectorAll('.admin-only').forEach(el => { - el.style.display = ''; - }); - } - - // Verify token - fetch('/api/auth/verify', { - headers: { - 'Authorization': `Bearer ${token}` - } - }) - .then(response => { - if (!response.ok) { - localStorage.removeItem('authToken'); - localStorage.removeItem('username'); - localStorage.removeItem('role'); - window.location.href = '/login'; - } - }) - .catch(() => { - window.location.href = '/login'; - }); -} - -function initializePlatformSelection() { - const platformCards = document.querySelectorAll('.platform-card'); - - platformCards.forEach(card => { - card.addEventListener('click', function() { - // Remove selected class from all cards - platformCards.forEach(c => c.classList.remove('selected')); - - // Add selected class to clicked card - this.classList.add('selected'); - - // Update selected platform - selectedPlatform = this.dataset.platform; - }); - }); - - // Select first platform by default - if (platformCards.length > 0) { - platformCards[0].classList.add('selected'); - } -} - -function initializeFileInputs() { - const iconInput = document.getElementById('customIcon'); - const logoInput = document.getElementById('customLogo'); - - if (iconInput) { - iconInput.addEventListener('change', function() { - const fileName = this.files.length > 0 ? this.files[0].name : 'No file selected'; - document.getElementById('iconFileName').textContent = fileName; - }); - } - - if (logoInput) { - logoInput.addEventListener('change', function() { - const fileName = this.files.length > 0 ? this.files[0].name : 'No file selected'; - document.getElementById('logoFileName').textContent = fileName; - }); - } -} - -function collectFormData() { - // Collect all form data - const formData = new FormData(); - - // Platform - formData.append('platform', selectedPlatform); - formData.append('version', document.getElementById('rustdeskVersion').value); - formData.append('fix_api_delay', document.getElementById('fixApiDelay').checked); - - // General - formData.append('config_name', document.getElementById('configName').value); - formData.append('custom_app_name', document.getElementById('customAppName').value); - formData.append('connection_type', document.querySelector('input[name="connectionType"]:checked').value); - formData.append('disable_installation', document.getElementById('disableInstallation').value); - formData.append('disable_settings', document.getElementById('disableSettings').value); - formData.append('android_app_id', document.getElementById('androidAppId').value); - - // Custom Server - formData.append('server_host', document.getElementById('serverHost').value); - formData.append('server_key', document.getElementById('serverKey').value); - formData.append('server_api', document.getElementById('serverApi').value); - formData.append('custom_url_links', document.getElementById('customUrlLinks').value); - formData.append('custom_url_download', document.getElementById('customUrlDownload').value); - formData.append('company_copyright', document.getElementById('companyCopyright').value); - - // Security - formData.append('password_approve_mode', document.getElementById('passwordApproveMode').value); - formData.append('permanent_password', document.getElementById('permanentPassword').value); - formData.append('deny_lan_discovery', document.getElementById('denyLanDiscovery').checked); - formData.append('enable_direct_ip', document.getElementById('enableDirectIp').checked); - formData.append('auto_close_inactive', document.getElementById('autoCloseInactive').checked); - formData.append('allow_hide_window', document.getElementById('allowHideWindow').checked); - - // Visual - const iconFile = document.getElementById('customIcon').files[0]; - if (iconFile) { - formData.append('custom_icon', iconFile); - } - - const logoFile = document.getElementById('customLogo').files[0]; - if (logoFile) { - formData.append('custom_logo', logoFile); - } - - formData.append('theme', document.getElementById('theme').value); - formData.append('theme_override', document.getElementById('themeOverride').value); - - // Permissions - formData.append('permissions_mode', document.getElementById('permissionsMode').value); - formData.append('permission_type', document.getElementById('permissionType').value); - formData.append('perm_keyboard', document.getElementById('permKeyboard').checked); - formData.append('perm_clipboard', document.getElementById('permClipboard').checked); - formData.append('perm_file_transfer', document.getElementById('permFileTransfer').checked); - formData.append('perm_audio', document.getElementById('permAudio').checked); - formData.append('perm_tcp_tunnel', document.getElementById('permTcpTunnel').checked); - formData.append('perm_remote_restart', document.getElementById('permRemoteRestart').checked); - formData.append('perm_recording', document.getElementById('permRecording').checked); - formData.append('perm_block_input', document.getElementById('permBlockInput').checked); - formData.append('perm_remote_config', document.getElementById('permRemoteConfig').checked); - formData.append('perm_printer', document.getElementById('permPrinter').checked); - formData.append('perm_camera', document.getElementById('permCamera').checked); - formData.append('perm_terminal', document.getElementById('permTerminal').checked); - - // Code Changes - formData.append('code_monitor_cycle', document.getElementById('codeMonitorCycle').checked); - formData.append('code_offline_x', document.getElementById('codeOfflineX').checked); - formData.append('code_remove_version_notif', document.getElementById('codeRemoveVersionNotif').checked); - - // Other - formData.append('remove_wallpaper', document.getElementById('removeWallpaper').checked); - formData.append('default_settings', document.getElementById('defaultSettings').value); - formData.append('override_settings', document.getElementById('overrideSettings').value); - - return formData; -} - -function validateForm() { - const configName = document.getElementById('configName').value; - - // Validate config name - if (configName && !/^[a-zA-Z0-9_-]+$/.test(configName)) { - showError('Configuration name can only contain letters, numbers, underscores and hyphens'); - return false; - } - - // Validate JSON fields - const defaultSettings = document.getElementById('defaultSettings').value; - const overrideSettings = document.getElementById('overrideSettings').value; - - if (defaultSettings) { - try { - JSON.parse(defaultSettings); - } catch (e) { - showError('Default settings must be valid JSON'); - return false; - } - } - - if (overrideSettings) { - try { - JSON.parse(overrideSettings); - } catch (e) { - showError('Override settings must be valid JSON'); - return false; - } - } - - return true; -} - -async function generateClient() { - // Validate form - if (!validateForm()) { - return; - } - - // Get form data - const formData = collectFormData(); - - // Show status - const statusDiv = document.getElementById('generationStatus'); - const statusMessage = document.getElementById('statusMessage'); - const progressFill = document.getElementById('progressFill'); - const generateBtn = document.getElementById('generateBtn'); - - statusDiv.style.display = 'block'; - generateBtn.disabled = true; - generateBtn.classList.add('generating'); - - // Update progress - updateProgress(10, 'Preparing configuration...'); - - try { - const token = localStorage.getItem('authToken'); - - // Send request - const response = await fetch('/api/generate-client', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}` - }, - body: formData - }); - - updateProgress(50, 'Processing...'); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to generate client'); - } - - const result = await response.json(); - - updateProgress(90, 'Finalizing...'); - - if (result.success) { - updateProgress(100, 'Client generated successfully!'); - statusMessage.classList.add('success'); - statusMessage.innerHTML = ` - Client generated successfully! -
- - `; - } else { - throw new Error(result.error || 'Unknown error'); - } - - } catch (error) { - console.error('Generation error:', error); - updateProgress(0, ''); - statusMessage.classList.add('error'); - statusMessage.innerHTML = ` Error: ${error.message}`; - } finally { - generateBtn.disabled = false; - generateBtn.classList.remove('generating'); - } -} - -function updateProgress(percent, message) { - const progressFill = document.getElementById('progressFill'); - const statusMessage = document.getElementById('statusMessage'); - - progressFill.style.width = percent + '%'; - progressFill.textContent = percent + '%'; - statusMessage.textContent = message; - statusMessage.className = 'status-message'; -} - -function showError(message) { - alert('Error: ' + message); -} - -async function downloadGeneratedClient(filename) { - try { - showInfo('Downloading client...'); - - const token = localStorage.getItem('authToken'); - - const response = await fetch(`/api/download-client/${filename}`, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (!response.ok) { - throw new Error('Failed to download client'); - } - - // Get the blob - const blob = await response.blob(); - - // Create download link - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.style.display = 'none'; - a.href = url; - a.download = filename; - - document.body.appendChild(a); - a.click(); - - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - - showSuccess('Client downloaded successfully!'); - - } catch (error) { - console.error('Download error:', error); - showError('Failed to download client: ' + error.message); - } -} - -function logout() { - if (!confirm('Are you sure you want to logout?')) { - return; - } - - const token = localStorage.getItem('authToken'); - - // Call logout API - fetch('/api/auth/logout', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}` - } - }).catch(error => { - console.error('Logout error:', error); - }); - - // Clear local storage - localStorage.removeItem('authToken'); - localStorage.removeItem('username'); - localStorage.removeItem('role'); - - // Redirect to login - window.location.href = '/login'; -} - -// Mobile menu setup -document.addEventListener('DOMContentLoaded', function() { - const mobileToggle = document.getElementById('mobileMenuToggle'); - const sidebar = document.getElementById('sidebar'); - - if (mobileToggle) { - mobileToggle.addEventListener('click', function() { - sidebar.classList.toggle('mobile-open'); - - if (sidebar.classList.contains('mobile-open')) { - createOverlay(); - } else { - removeOverlay(); - } - }); - } -}); - -function createOverlay() { - const existing = document.querySelector('.sidebar-overlay'); - if (existing) return; - - const overlay = document.createElement('div'); - overlay.className = 'sidebar-overlay'; - overlay.addEventListener('click', closeMobileMenu); - document.body.appendChild(overlay); -} - -function removeOverlay() { - const overlay = document.querySelector('.sidebar-overlay'); - if (overlay) { - overlay.remove(); - } -} - -function closeMobileMenu() { - const sidebar = document.getElementById('sidebar'); - sidebar.classList.remove('mobile-open'); - removeOverlay(); -} - -// Auto-fill functions -async function detectPublicIP() { - try { - showInfo('Detecting public IP address...'); - - // Try multiple services for reliability - const services = [ - 'https://api.ipify.org?format=json', - 'https://api.my-ip.io/ip.json', - 'https://ipapi.co/json/' - ]; - - for (const service of services) { - try { - const response = await fetch(service, { timeout: 5000 }); - const data = await response.json(); - - // Different services return IP in different formats - const ip = data.ip || data.IP || data.query; - - if (ip) { - detectedPublicIP = ip; - showSuccess(`Public IP detected: ${ip}`); - return ip; - } - } catch (err) { - console.log(`Service ${service} failed:`, err); - continue; - } - } - - showError('Could not detect public IP. Please enter manually.'); - return null; - - } catch (error) { - console.error('Error detecting public IP:', error); - showError('Failed to detect public IP'); - return null; - } -} - -async function loadPublicKey() { - try { - showInfo('Loading public key from server...'); - - const token = localStorage.getItem('authToken'); - const response = await fetch('/api/public-key', { - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (!response.ok) { - throw new Error('Failed to load public key'); - } - - const data = await response.json(); - - if (data.success && data.key) { - document.getElementById('serverKey').value = data.key; - showSuccess('Public key loaded successfully'); - } else { - throw new Error(data.error || 'No public key found'); - } - - } catch (error) { - console.error('Error loading public key:', error); - showError('Failed to load public key: ' + error.message); - } -} - -async function usePublicIP() { - if (!detectedPublicIP) { - detectedPublicIP = await detectPublicIP(); - } - - if (detectedPublicIP) { - document.getElementById('serverHost').value = detectedPublicIP; - showSuccess('Public IP applied to Host field'); - } -} - -async function autoFillServerConfig() { - try { - showInfo('Auto-filling server configuration...'); - - // Detect public IP - const ip = await detectPublicIP(); - - if (!ip) { - showError('Could not detect public IP'); - return; - } - - // Load public key - const token = localStorage.getItem('authToken'); - const keyResponse = await fetch('/api/public-key', { - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (!keyResponse.ok) { - throw new Error('Failed to load public key'); - } - - const keyData = await keyResponse.json(); - - if (!keyData.success || !keyData.key) { - throw new Error('No public key found'); - } - - // Fill in the fields - document.getElementById('serverHost').value = ip; - document.getElementById('serverKey').value = keyData.key; - document.getElementById('serverApi').value = `https://${ip}`; - - showSuccess('Server configuration auto-filled successfully!'); - - } catch (error) { - console.error('Error auto-filling config:', error); - showError('Failed to auto-fill configuration: ' + error.message); - } -} - -function showInfo(message) { - // Simple info notification - const statusDiv = document.getElementById('generationStatus'); - const statusMessage = document.getElementById('statusMessage'); - - if (statusDiv && statusMessage) { - statusDiv.style.display = 'block'; - statusMessage.textContent = message; - statusMessage.className = 'status-message'; - - setTimeout(() => { - if (statusMessage.textContent === message) { - statusDiv.style.display = 'none'; - } - }, 3000); - } -} - -function showSuccess(message) { - const statusDiv = document.getElementById('generationStatus'); - const statusMessage = document.getElementById('statusMessage'); - - if (statusDiv && statusMessage) { - statusDiv.style.display = 'block'; - statusMessage.textContent = message; - statusMessage.className = 'status-message success'; - - setTimeout(() => { - if (statusMessage.textContent === message) { - statusDiv.style.display = 'none'; - } - }, 3000); - } -} diff --git a/web/static/css/i18n.css b/web/static/css/i18n.css deleted file mode 100644 index 5475fd3b..00000000 --- a/web/static/css/i18n.css +++ /dev/null @@ -1,166 +0,0 @@ -/** - * BetterDesk i18n Styles - * Language selector and internationalization UI components - */ - -/* Language Selector Container */ -.language-selector { - position: relative; - margin-top: 10px; -} - -/* Language Toggle Button */ -.lang-btn { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 8px; - color: rgba(255, 255, 255, 0.8); - cursor: pointer; - width: 100%; - transition: all 0.2s ease; - font-size: 0.85rem; -} - -.lang-btn:hover { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.2); - color: #fff; -} - -.lang-btn i:first-child { - color: var(--primary-color, #667eea); -} - -.lang-btn i:last-child { - margin-left: auto; - font-size: 0.7rem; - transition: transform 0.2s ease; -} - -.lang-dropdown.show + .lang-btn i:last-child, -.lang-btn:focus + .lang-dropdown i:last-child { - transform: rotate(180deg); -} - -/* Language Dropdown */ -.lang-dropdown { - position: absolute; - bottom: 100%; - left: 0; - right: 0; - background: rgba(26, 32, 44, 0.98); - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 8px; - margin-bottom: 6px; - max-height: 0; - overflow: hidden; - opacity: 0; - transition: all 0.25s ease; - z-index: 1001; - box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3); -} - -.lang-dropdown.show { - max-height: 300px; - opacity: 1; - overflow-y: auto; -} - -/* Language Option */ -.lang-option { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 14px; - cursor: pointer; - transition: all 0.15s ease; - color: rgba(255, 255, 255, 0.7); -} - -.lang-option:hover { - background: rgba(255, 255, 255, 0.08); - color: #fff; -} - -.lang-option.active { - background: rgba(102, 126, 234, 0.15); - color: #fff; -} - -.lang-option.active::before { - content: ''; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 3px; - background: var(--primary-color, #667eea); - border-radius: 0 3px 3px 0; -} - -.lang-name { - font-weight: 500; -} - -.lang-code { - font-size: 0.75rem; - color: rgba(255, 255, 255, 0.5); - background: rgba(255, 255, 255, 0.08); - padding: 2px 6px; - border-radius: 4px; -} - -.lang-option.active .lang-code { - background: rgba(102, 126, 234, 0.3); - color: rgba(255, 255, 255, 0.9); -} - -/* Scrollbar for dropdown */ -.lang-dropdown::-webkit-scrollbar { - width: 6px; -} - -.lang-dropdown::-webkit-scrollbar-track { - background: rgba(0, 0, 0, 0.2); - border-radius: 3px; -} - -.lang-dropdown::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.2); - border-radius: 3px; -} - -.lang-dropdown::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.3); -} - -/* Mobile responsive */ -@media (max-width: 768px) { - .lang-dropdown { - position: fixed; - bottom: 70px; - left: 10px; - right: 10px; - border-radius: 12px; - } - - .lang-option { - padding: 14px 16px; - } -} - -/* RTL Support */ -[dir="rtl"] .lang-btn i:last-child { - margin-left: 0; - margin-right: auto; -} - -[dir="rtl"] .lang-option.active::before { - left: auto; - right: 0; - border-radius: 3px 0 0 3px; -} diff --git a/web/static/js/i18n.js b/web/static/js/i18n.js deleted file mode 100644 index 677a489d..00000000 --- a/web/static/js/i18n.js +++ /dev/null @@ -1,331 +0,0 @@ -/** - * BetterDesk i18n JavaScript Module - * Client-side internationalization support - * - * Usage: - * - Add data-i18n="key.path" attribute to elements - * - Add data-i18n-placeholder="key.path" for input placeholders - * - Add data-i18n-title="key.path" for title attributes - * - Call i18n.init() after DOM is ready - * - Call i18n.setLanguage('en') to change language - */ - -const i18n = (function() { - 'use strict'; - - let translations = {}; - let currentLang = 'en'; - let availableLanguages = []; - let onLanguageChange = null; - - /** - * Get nested value from object using dot notation - * @param {Object} obj - The translations object - * @param {string} path - Dot-separated path (e.g., 'sidebar.dashboard') - * @returns {string|undefined} The translated string or undefined - */ - function getNestedValue(obj, path) { - if (!path) return undefined; - const keys = path.split('.'); - let value = obj; - for (const key of keys) { - if (value && typeof value === 'object' && key in value) { - value = value[key]; - } else { - return undefined; - } - } - return typeof value === 'string' ? value : undefined; - } - - /** - * Translate a key - * @param {string} key - Translation key in dot notation - * @param {Object} params - Optional parameters for interpolation - * @returns {string} Translated text or key if not found - */ - function t(key, params = {}) { - let text = getNestedValue(translations, key); - if (text === undefined) { - console.warn(`[i18n] Missing translation: ${key}`); - return key; - } - - // Simple interpolation: replace {name} with params.name - Object.keys(params).forEach(param => { - text = text.replace(new RegExp(`{${param}}`, 'g'), params[param]); - }); - - return text; - } - - /** - * Load translations for a language - * @param {string} lang - Language code (e.g., 'en', 'pl') - * @returns {Promise} Success status - */ - async function loadLanguage(lang) { - try { - const response = await fetch(`/api/i18n/translations/${lang}`); - if (!response.ok) { - throw new Error(`Failed to load ${lang}: ${response.status}`); - } - const data = await response.json(); - if (data.success && data.translations) { - translations = data.translations; - currentLang = data.language || lang; - return true; - } - return false; - } catch (error) { - console.error('[i18n] Error loading translations:', error); - return false; - } - } - - /** - * Load available languages list - * @returns {Promise} List of available languages - */ - async function loadAvailableLanguages() { - try { - const response = await fetch('/api/i18n/languages'); - if (!response.ok) return []; - const data = await response.json(); - availableLanguages = data.languages || []; - return availableLanguages; - } catch (error) { - console.error('[i18n] Error loading languages:', error); - return []; - } - } - - /** - * Apply translations to all elements with data-i18n attributes - */ - function translatePage() { - // Translate text content - document.querySelectorAll('[data-i18n]').forEach(el => { - const key = el.getAttribute('data-i18n'); - const translated = t(key); - if (translated !== key) { - el.textContent = translated; - } - }); - - // Translate placeholders - document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { - const key = el.getAttribute('data-i18n-placeholder'); - const translated = t(key); - if (translated !== key) { - el.placeholder = translated; - } - }); - - // Translate title attributes - document.querySelectorAll('[data-i18n-title]').forEach(el => { - const key = el.getAttribute('data-i18n-title'); - const translated = t(key); - if (translated !== key) { - el.title = translated; - } - }); - - // Translate aria-labels - document.querySelectorAll('[data-i18n-aria]').forEach(el => { - const key = el.getAttribute('data-i18n-aria'); - const translated = t(key); - if (translated !== key) { - el.setAttribute('aria-label', translated); - } - }); - - // Update page title if specified - const titleEl = document.querySelector('[data-i18n-page-title]'); - if (titleEl) { - document.title = t(titleEl.getAttribute('data-i18n-page-title')); - } - } - - /** - * Set the current language and update the page - * @param {string} lang - Language code - * @param {boolean} save - Whether to save preference to cookie - * @returns {Promise} Success status - */ - async function setLanguage(lang, save = true) { - const success = await loadLanguage(lang); - if (success) { - if (save) { - // Set cookie for server-side language detection - document.cookie = `lang=${lang};path=/;max-age=${365*24*60*60}`; - - // Notify server about language change - try { - await fetch(`/api/i18n/set/${lang}`, { method: 'POST' }); - } catch (e) { - // Ignore - cookie is set anyway - } - } - - translatePage(); - - // Update HTML lang attribute - document.documentElement.lang = lang; - - // Call callback if registered - if (typeof onLanguageChange === 'function') { - onLanguageChange(lang); - } - - return true; - } - return false; - } - - /** - * Get saved language from cookie or browser preference - * @returns {string} Language code - */ - function getPreferredLanguage() { - // Check cookie first - const match = document.cookie.match(/lang=(\w+)/); - if (match) return match[1]; - - // Check browser language - const browserLang = navigator.language?.split('-')[0] || 'en'; - return browserLang; - } - - /** - * Initialize the i18n system - * @param {Object} options - Configuration options - * @returns {Promise} - */ - async function init(options = {}) { - const defaultLang = options.defaultLang || 'en'; - onLanguageChange = options.onLanguageChange || null; - - // Load available languages - await loadAvailableLanguages(); - - // Determine initial language - let lang = getPreferredLanguage(); - - // Check if preferred language is available - if (availableLanguages.length > 0 && !availableLanguages.includes(lang)) { - lang = defaultLang; - } - - // Load and apply translations - await setLanguage(lang, false); - - console.log(`[i18n] Initialized with language: ${currentLang}`); - } - - /** - * Create a language selector dropdown - * @param {string} containerId - ID of container element - * @returns {HTMLElement|null} The created selector or null - */ - function createLanguageSelector(containerId) { - const container = document.getElementById(containerId); - if (!container) return null; - - const selector = document.createElement('div'); - selector.className = 'language-selector'; - selector.innerHTML = ` - -
- ${availableLanguages.map(lang => ` -
- ${getLanguageName(lang)} - ${lang.toUpperCase()} -
- `).join('')} -
- `; - - container.appendChild(selector); - - // Toggle dropdown - const toggle = selector.querySelector('#langToggle'); - const dropdown = selector.querySelector('#langDropdown'); - - toggle.addEventListener('click', (e) => { - e.stopPropagation(); - dropdown.classList.toggle('show'); - }); - - // Language selection - selector.querySelectorAll('.lang-option').forEach(option => { - option.addEventListener('click', async () => { - const lang = option.dataset.lang; - if (lang !== currentLang) { - await setLanguage(lang); - - // Update selector UI - selector.querySelectorAll('.lang-option').forEach(o => { - o.classList.toggle('active', o.dataset.lang === lang); - }); - document.getElementById('currentLangLabel').textContent = lang.toUpperCase(); - } - dropdown.classList.remove('show'); - }); - }); - - // Close dropdown when clicking outside - document.addEventListener('click', () => { - dropdown.classList.remove('show'); - }); - - return selector; - } - - /** - * Get human-readable language name - * @param {string} code - Language code - * @returns {string} Language name - */ - function getLanguageName(code) { - const names = { - 'en': 'English', - 'pl': 'Polski', - 'de': 'Deutsch', - 'fr': 'Français', - 'es': 'Español', - 'it': 'Italiano', - 'ru': 'Русский', - 'zh': '中文', - 'ja': '日本語', - 'pt': 'Português', - 'nl': 'Nederlands', - 'tr': 'Türkçe', - 'uk': 'Українська' - }; - return names[code] || code.toUpperCase(); - } - - // Public API - return { - init, - t, - setLanguage, - translatePage, - loadAvailableLanguages, - createLanguageSelector, - getLanguageName, - get currentLang() { return currentLang; }, - get availableLanguages() { return availableLanguages; }, - get translations() { return translations; } - }; -})(); - -// Export for module systems -if (typeof module !== 'undefined' && module.exports) { - module.exports = i18n; -} diff --git a/web/static/material-icons.woff2 b/web/static/material-icons.woff2 deleted file mode 100644 index 5492a6e75937db19f9ba860cd2575744887fa26b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 128352 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

l*K8_>P7#5$l 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 { - const dashboardSection = document.getElementById('dashboard'); - if (dashboardSection && dashboardSection.classList.contains('active')) { - loadDevices(); - loadStats(); - } - }, refreshInterval); -} - -// Authentication check -function checkAuth() { - authToken = localStorage.getItem('authToken'); - userRole = localStorage.getItem('role'); - username = localStorage.getItem('username'); - - if (!authToken) { - window.location.href = '/login'; - return false; - } - - return true; -} - -// Setup user info in sidebar -function setupUserInfo() { - document.getElementById('sidebarUsername').textContent = username || 'User'; - document.getElementById('sidebarRole').textContent = userRole || 'viewer'; - - // Show admin-only sections - if (userRole === 'admin') { - document.querySelectorAll('.admin-only').forEach(el => { - el.style.display = ''; - }); - } -} - -// Setup sidebar navigation -function setupSidebar() { - document.querySelectorAll('.sidebar-item').forEach(item => { - item.addEventListener('click', function(e) { - // Skip if this is a link (like Client Generator) - if (this.hasAttribute('href') && this.getAttribute('href') !== '#') { - return; // Allow normal navigation - } - - e.preventDefault(); - const sectionId = this.dataset.section; - - // Skip if no section ID (e.g., for external links) - if (!sectionId) { - return; - } - - // Update active states - document.querySelectorAll('.sidebar-item').forEach(i => i.classList.remove('active')); - this.classList.add('active'); - - // Show selected section - const targetSection = document.getElementById(sectionId); - if (targetSection) { - document.querySelectorAll('.content-section').forEach(s => s.classList.remove('active')); - targetSection.classList.add('active'); - - // Load section-specific data - if (sectionId === 'users' && userRole === 'admin') { - loadUsers(); - } - if (sectionId === 'settings' && userRole === 'admin') { - loadServerSettings(); - } - } - }); - }); -} - -// Get auth headers for API calls -function getAuthHeaders() { - return { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authToken}` - }; -} - -// Handle authentication errors -function handleAuthError(error, response) { - if (response && response.status === 401) { - localStorage.removeItem('authToken'); - localStorage.removeItem('username'); - localStorage.removeItem('role'); - window.location.href = '/login'; - return true; - } - return false; -} - -// Logout function -async function logout() { - try { - await fetch('/api/auth/logout', { - method: 'POST', - headers: getAuthHeaders() - }); - } catch (error) { - console.error('Logout error:', error); - } finally { - localStorage.removeItem('authToken'); - localStorage.removeItem('username'); - localStorage.removeItem('role'); - window.location.href = '/login'; - } -} - -// ============================================================================ -// DASHBOARD - DEVICE MANAGEMENT -// ============================================================================ - -// Load devices from API -async function loadDevices() { - if (!checkAuth()) return; - - try { - const response = await fetch('/api/devices', { - headers: getAuthHeaders() - }); - - if (handleAuthError(null, response)) return; - - const data = await response.json(); - - if (data.success) { - allDevices = data.devices; - renderDevices(allDevices); - updateNavStats(allDevices); - } else { - showToast('Error loading devices: ' + data.error, 'error'); - } - } catch (error) { - console.error('Error:', error); - showToast('Failed to load devices', 'error'); - } -} - -// Load statistics -async function loadStats() { - if (!checkAuth()) return; - - try { - const response = await fetch('/api/stats', { - headers: getAuthHeaders() - }); - - if (handleAuthError(null, response)) return; - - const data = await response.json(); - - if (data.success) { - document.getElementById('statTotal').textContent = data.stats.total; - document.getElementById('statActive').textContent = data.stats.active; - document.getElementById('statInactive').textContent = data.stats.inactive; - document.getElementById('statBanned').textContent = data.stats.banned || 0; - document.getElementById('statNotes').textContent = data.stats.with_notes; - } - } catch (error) { - console.error('Error loading stats:', error); - } -} - -// Update navigation stats -function updateNavStats(devices) { - const total = devices.length; - const active = devices.filter(d => d.online).length; - - const totalDevicesEl = document.querySelector('#totalDevices span'); - const activeDevicesEl = document.querySelector('#activeDevices span'); - - if (totalDevicesEl) totalDevicesEl.textContent = total; - if (activeDevicesEl) activeDevicesEl.textContent = active; -} - -// Render devices table -function renderDevices(devices) { - const tbody = document.getElementById('devicesTableBody'); - - if (devices.length === 0) { - tbody.innerHTML = ` - - - - No devices found - - - `; - return; - } - - tbody.innerHTML = devices.map(device => { - const isBanned = device.is_banned === true || device.is_banned === 1; - const rowClass = isBanned ? 'style="opacity: 0.6; background: rgba(255, 0, 0, 0.05);"' : ''; - - const canEdit = userRole === 'admin' || userRole === 'operator'; - const canBan = userRole === 'admin' || userRole === 'operator'; - - // Handle detailed status (online/degraded/critical/offline) - const statusDetail = device.status_detail || (device.online ? 'online' : 'offline'); - let statusClass = 'status-inactive'; - let statusIcon = 'fa-circle'; - let statusText = 'Offline'; - - switch (statusDetail) { - case 'online': - statusClass = 'status-active'; - statusIcon = 'fa-circle'; - statusText = 'Online'; - break; - case 'degraded': - statusClass = 'status-warning'; - statusIcon = 'fa-exclamation-circle'; - statusText = 'Degraded'; - break; - case 'critical': - statusClass = 'status-critical'; - statusIcon = 'fa-exclamation-triangle'; - statusText = 'Critical'; - break; - default: - statusClass = 'status-inactive'; - statusIcon = 'fa-circle'; - statusText = 'Offline'; - } - - return ` - - - ${escapeHtml(device.id)} - ${isBanned ? '
BANNED' : ''} - - ${escapeHtml(device.note) || 'No note'} - - - - ${statusText} - - - ${formatDate(device.created_at)} - - - - ${canEdit ? ` - - ` : ''} - ${canBan ? (isBanned ? - `` : - `` - ) : ''} - ${canEdit ? ` - - ` : ''} - - - `}).join(''); -} - -// Filter devices by search -function filterDevices() { - const searchTerm = document.getElementById('searchInput').value.toLowerCase(); - - if (!searchTerm) { - renderDevices(allDevices); - return; - } - - const filtered = allDevices.filter(device => - device.id.toLowerCase().includes(searchTerm) || - (device.note && device.note.toLowerCase().includes(searchTerm)) - ); - - renderDevices(filtered); -} - -// Get custom URL scheme for RustDesk connections -// Users with personalized clients can set this via Settings or localStorage -function getCustomScheme() { - // Priority: localStorage > default - return localStorage.getItem('rustdeskScheme') || 'rustdesk'; -} - -// Set custom URL scheme (call from Settings page or browser console) -// Example: setCustomScheme('mycompany-rustdesk') -function setCustomScheme(scheme) { - if (!scheme || typeof scheme !== 'string') { - console.error('Invalid scheme. Must be a non-empty string.'); - return false; - } - // Remove :// if user included it - scheme = scheme.replace('://', '').trim(); - localStorage.setItem('rustdeskScheme', scheme); - showToast(`Custom scheme set to: ${scheme}://`); - return true; -} - -// Clear custom scheme (revert to default rustdesk://) -function clearCustomScheme() { - localStorage.removeItem('rustdeskScheme'); - showToast('Reverted to default rustdesk:// scheme'); -} - -// Connect to device using configured URL scheme -function connectDevice(deviceId) { - const scheme = getCustomScheme(); - window.location.href = `${scheme}://${deviceId}`; - showToast(`Connecting to ${deviceId}...`); -} - -// Show device details -function showDetails(deviceId) { - const device = allDevices.find(d => d.id === deviceId); - if (!device) return; - - const isBanned = device.is_banned === true || device.is_banned === 1; - - const detailsContent = document.getElementById('detailsContent'); - detailsContent.innerHTML = ` -

-
ID:
-
${escapeHtml(device.id)}
-
-
-
GUID:
-
${escapeHtml(device.guid) || 'N/A'}
-
-
-
UUID:
-
${escapeHtml(device.uuid) || 'N/A'}
-
-
-
Status:
-
- - - ${device.status_detail === 'degraded' ? 'Degraded' : - device.status_detail === 'critical' ? 'Critical' : - device.online ? 'Online' : 'Offline'} - -
-
- ${isBanned ? ` -
-
BAN STATUS:
-
BANNED
-
-
-
Banned At:
-
${device.banned_at ? formatDate(device.banned_at) : 'N/A'}
-
-
-
Banned By:
-
${escapeHtml(device.banned_by) || 'N/A'}
-
-
-
Ban Reason:
-
${escapeHtml(device.ban_reason) || 'No reason provided'}
-
- ` : ''} -
-
Note:
-
${escapeHtml(device.note) || 'No note'}
-
-
-
Created:
-
${formatDate(device.created_at)}
-
- `; - - openModal('detailsModal'); -} - -// Edit device -function editDevice(deviceId) { - const device = allDevices.find(d => d.id === deviceId); - if (!device) return; - - currentDeviceId = deviceId; - document.getElementById('editDeviceId').value = deviceId; - document.getElementById('editNewId').value = ''; - document.getElementById('editNote').value = device.note || ''; - - // Show ID history if available - const historyGroup = document.getElementById('idHistoryGroup'); - const historyList = document.getElementById('idHistoryList'); - - if (device.previous_ids && device.previous_ids.length > 0) { - historyGroup.style.display = 'block'; - historyList.innerHTML = device.previous_ids.map(id => - `${escapeHtml(id)}` - ).join(' → '); - if (device.id_changed_at) { - historyList.innerHTML += `
Last changed: ${device.id_changed_at}`; - } - } else { - historyGroup.style.display = 'none'; - historyList.innerHTML = ''; - } - - openModal('editModal'); -} - -// Save device changes -async function saveDevice() { - if (!checkAuth()) return; - - const newId = document.getElementById('editNewId').value.trim(); - const note = document.getElementById('editNote').value.trim(); - - if (note.length > 500) { - showToast('Note is too long (max 500 characters)', 'error'); - return; - } - - const data = { note }; - if (newId) data.new_id = newId; - - try { - const response = await fetch(`/api/device/${currentDeviceId}`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify(data) - }); - - if (handleAuthError(null, response)) return; - - const result = await response.json(); - - if (result.success) { - showToast('Device updated successfully'); - closeEditModal(); - await loadDevices(); - await loadStats(); - } else { - showToast('Error: ' + result.error, 'error'); - } - } catch (error) { - console.error('Error:', error); - showToast('Failed to update device', 'error'); - } -} - -// Delete device -function deleteDevice(deviceId) { - currentDeviceId = deviceId; - document.getElementById('deleteDeviceId').textContent = deviceId; - openModal('deleteModal'); -} - -// Confirm delete -async function confirmDelete() { - if (!checkAuth()) return; - - try { - const response = await fetch(`/api/device/${currentDeviceId}`, { - method: 'DELETE', - headers: getAuthHeaders() - }); - - if (handleAuthError(null, response)) return; - - const result = await response.json(); - - if (result.success) { - showToast('Device deleted successfully'); - closeDeleteModal(); - await loadDevices(); - await loadStats(); - } else { - showToast('Error: ' + result.error, 'error'); - } - } catch (error) { - console.error('Error:', error); - showToast('Failed to delete device', 'error'); - } -} - -// Ban device -async function banDevice(deviceId) { - if (!checkAuth()) return; - - const reason = prompt(`⚠️ BAN DEVICE: ${deviceId}\n\nEnter ban reason (optional):`); - if (reason === null) return; - - try { - const response = await fetch(`/api/device/${deviceId}/ban`, { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ - reason: reason || '', - banned_by: username - }) - }); - - if (handleAuthError(null, response)) return; - - const result = await response.json(); - - if (result.success) { - showToast(`Device ${deviceId} banned successfully`); - await loadDevices(); - await loadStats(); - } else { - showToast('Error: ' + result.error, 'error'); - } - } catch (error) { - console.error('Error:', error); - showToast('Failed to ban device', 'error'); - } -} - -// Unban device -async function unbanDevice(deviceId) { - if (!checkAuth()) return; - - if (!confirm(`✓ UNBAN DEVICE: ${deviceId}\n\nAre you sure?`)) return; - - try { - const response = await fetch(`/api/device/${deviceId}/unban`, { - method: 'POST', - headers: getAuthHeaders() - }); - - if (handleAuthError(null, response)) return; - - const result = await response.json(); - - if (result.success) { - showToast(`Device ${deviceId} unbanned successfully`); - await loadDevices(); - await loadStats(); - } else { - showToast('Error: ' + result.error, 'error'); - } - } catch (error) { - console.error('Error:', error); - showToast('Failed to unban device', 'error'); - } -} - -// Refresh devices manually -async function refreshDevices() { - showToast('Refreshing devices...'); - await loadDevices(); - await loadStats(); -} - -// ============================================================================ -// PUBLIC KEY SECTION -// ============================================================================ - -async function verifyPasswordForKey() { - const password = document.getElementById('keyPassword').value; - - if (!password) { - showToast('Please enter your password', 'error'); - return; - } - - try { - const response = await fetch('/api/auth/verify-password', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ password: password }) - }); - - if (handleAuthError(null, response)) return; - - const result = await response.json(); - - if (result.success) { - // Password correct, fetch public key - const keyResponse = await fetch('/api/public-key', { - headers: getAuthHeaders() - }); - - if (handleAuthError(null, keyResponse)) return; - - const keyData = await keyResponse.json(); - - if (keyData.success) { - publicKeyCache = keyData.key; - document.getElementById('publicKeyDisplay').textContent = keyData.key; - document.getElementById('keyPasswordPrompt').style.display = 'none'; - document.getElementById('keyContent').style.display = 'block'; - document.getElementById('keyPassword').value = ''; - showToast('Public key revealed'); - } else { - showToast('Error loading key: ' + keyData.error, 'error'); - } - } else { - showToast('Incorrect password', 'error'); - document.getElementById('keyPassword').value = ''; - } - } catch (error) { - console.error('Error:', error); - showToast('Failed to verify password', 'error'); - } -} - -function copyPublicKey() { - const keyText = document.getElementById('publicKeyDisplay').textContent; - navigator.clipboard.writeText(keyText).then(() => { - showToast('Public key copied to clipboard'); - }).catch(err => { - showToast('Failed to copy', 'error'); - }); -} - -function lockKey() { - publicKeyCache = null; - document.getElementById('keyPasswordPrompt').style.display = 'block'; - document.getElementById('keyContent').style.display = 'none'; - document.getElementById('keyPassword').value = ''; -} - -// ============================================================================ -// SETTINGS - PASSWORD CHANGE -// ============================================================================ - -async function changePassword(event) { - event.preventDefault(); - - const currentPassword = document.getElementById('currentPassword').value; - const newPassword = document.getElementById('newPassword').value; - const confirmPassword = document.getElementById('confirmPassword').value; - - if (newPassword !== confirmPassword) { - showToast('New passwords do not match', 'error'); - return; - } - - if (newPassword.length < 8) { - showToast('Password must be at least 8 characters', 'error'); - return; - } - - try { - const response = await fetch('/api/auth/change-password', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ - old_password: currentPassword, - new_password: newPassword - }) - }); - - if (handleAuthError(null, response)) return; - - const result = await response.json(); - - if (result.success) { - showToast('Password changed successfully'); - document.getElementById('passwordForm').reset(); - // New token issued, update local storage - if (result.token) { - authToken = result.token; - localStorage.setItem('authToken', result.token); - } - } else { - showToast('Error: ' + result.error, 'error'); - } - } catch (error) { - console.error('Error:', error); - showToast('Failed to change password', 'error'); - } -} - -// ============================================================================ -// USER MANAGEMENT (ADMIN ONLY) -// ============================================================================ - -async function loadUsers() { - if (!checkAuth()) return; - if (userRole !== 'admin') return; - - try { - const response = await fetch('/api/users', { - headers: getAuthHeaders() - }); - - if (handleAuthError(null, response)) return; - - const data = await response.json(); - - if (data.success) { - renderUsers(data.users); - } else { - showToast('Error loading users: ' + data.error, 'error'); - } - } catch (error) { - console.error('Error:', error); - showToast('Failed to load users', 'error'); - } -} - -function renderUsers(users) { - const tbody = document.getElementById('usersTableBody'); - - if (users.length === 0) { - tbody.innerHTML = 'No users found'; - return; - } - - tbody.innerHTML = users.map(user => { - const statusClass = user.is_active ? 'status-active' : 'status-inactive'; - const statusText = user.is_active ? 'Active' : 'Inactive'; - - let roleClass = 'role-viewer'; - if (user.role === 'admin') roleClass = 'role-admin'; - else if (user.role === 'operator') roleClass = 'role-operator'; - - const lastLogin = user.last_login ? formatDate(user.last_login) : 'Never'; - - return ` - - ${escapeHtml(user.username)} - ${user.role} - ${lastLogin} - ${statusText} - - - - - - `; - }).join(''); -} - -// Add User Modal -function showAddUserModal() { - openModal('addUserModal'); -} - -function closeAddUserModal() { - closeModal('addUserModal'); - document.getElementById('newUsername').value = ''; - document.getElementById('newUserPassword').value = ''; - document.getElementById('newUserRole').value = 'viewer'; -} - -async function createUser() { - const username = document.getElementById('newUsername').value.trim(); - const password = document.getElementById('newUserPassword').value; - const role = document.getElementById('newUserRole').value; - - if (!username || !password) { - showToast('Username and password are required', 'error'); - return; - } - - if (password.length < 8) { - showToast('Password must be at least 8 characters', 'error'); - return; - } - - try { - const response = await fetch('/api/users', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ username, password, role }) - }); - - if (handleAuthError(null, response)) return; - - const result = await response.json(); - - if (result.success) { - showToast(`User ${username} created successfully`); - closeAddUserModal(); - loadUsers(); - } else { - showToast('Error: ' + result.error, 'error'); - } - } catch (error) { - console.error('Error:', error); - showToast('Failed to create user', 'error'); - } -} - -// Edit/Delete User (placeholders - to be implemented with proper modals) -function showEditUserModal(userId, username, role, isActive) { - showToast('Edit user functionality - coming soon'); -} - -function showDeleteUserModal(userId, username) { - if (!confirm(`⚠️ DELETE USER: ${username}\n\nAre you sure?`)) return; - deleteUser(userId); -} - -async function deleteUser(userId) { - try { - const response = await fetch(`/api/users/${userId}`, { - method: 'DELETE', - headers: getAuthHeaders() - }); - - if (handleAuthError(null, response)) return; - - const result = await response.json(); - - if (result.success) { - showToast('User deleted successfully'); - loadUsers(); - } else { - showToast('Error: ' + result.error, 'error'); - } - } catch (error) { - console.error('Error:', error); - showToast('Failed to delete user', 'error'); - } -} - -// ============================================================================ -// MODAL FUNCTIONS -// ============================================================================ - -function openModal(modalId) { - document.getElementById(modalId).classList.add('active'); -} - -function closeModal(modalId) { - document.getElementById(modalId).classList.remove('active'); -} - -function closeEditModal() { - closeModal('editModal'); - currentDeviceId = null; -} - -function closeDeleteModal() { - closeModal('deleteModal'); - currentDeviceId = null; -} - -function closeDetailsModal() { - closeModal('detailsModal'); -} - -// Close modal when clicking outside -window.onclick = function(event) { - if (event.target.classList.contains('modal')) { - event.target.classList.remove('active'); - } -} - -// ============================================================================ -// SERVER SETTINGS (ADMIN ONLY) -// ============================================================================ - -// Global server config cache -let serverConfig = { - peer_timeout_secs: 60, - heartbeat_interval_secs: 5, - warning_threshold: 2, - critical_threshold: 4 -}; - -async function loadServerSettings() { - if (!checkAuth()) return; - if (userRole !== 'admin') return; - - try { - const response = await fetch('/api/server/config', { - headers: getAuthHeaders() - }); - - if (handleAuthError(null, response)) return; - - const data = await response.json(); - - if (data.success && data.config) { - serverConfig = data.config; - - // Update form fields - document.getElementById('peerTimeoutSecs').value = serverConfig.peer_timeout_secs || 60; - document.getElementById('heartbeatIntervalSecs').value = serverConfig.heartbeat_interval_secs || 5; - document.getElementById('warningThreshold').value = serverConfig.warning_threshold || 2; - document.getElementById('criticalThreshold').value = serverConfig.critical_threshold || 4; - - console.log('Server settings loaded:', serverConfig); - } - } catch (error) { - console.error('Error loading server settings:', error); - } -} - -async function saveServerSettings(event) { - event.preventDefault(); - - if (!checkAuth()) return; - if (userRole !== 'admin') { - showToast('Only admins can change server settings', 'error'); - return; - } - - const newConfig = { - peer_timeout_secs: parseInt(document.getElementById('peerTimeoutSecs').value) || 60, - heartbeat_interval_secs: parseInt(document.getElementById('heartbeatIntervalSecs').value) || 5, - warning_threshold: parseInt(document.getElementById('warningThreshold').value) || 2, - critical_threshold: parseInt(document.getElementById('criticalThreshold').value) || 4 - }; - - // Validation - if (newConfig.peer_timeout_secs < 10 || newConfig.peer_timeout_secs > 300) { - showToast('Peer timeout must be between 10 and 300 seconds', 'error'); - return; - } - if (newConfig.heartbeat_interval_secs < 1 || newConfig.heartbeat_interval_secs > 30) { - showToast('Heartbeat interval must be between 1 and 30 seconds', 'error'); - return; - } - if (newConfig.critical_threshold <= newConfig.warning_threshold) { - showToast('Critical threshold must be greater than warning threshold', 'error'); - return; - } - - try { - const response = await fetch('/api/server/config', { - method: 'POST', - headers: { - ...getAuthHeaders(), - 'Content-Type': 'application/json' - }, - body: JSON.stringify(newConfig) - }); - - if (handleAuthError(null, response)) return; - - const data = await response.json(); - - if (data.success) { - serverConfig = newConfig; - showToast('Server settings saved successfully'); - } else { - showToast('Error: ' + (data.error || 'Failed to save settings'), 'error'); - } - } catch (error) { - console.error('Error saving server settings:', error); - showToast('Failed to save server settings', 'error'); - } -} - -// Get peer timeout for online status calculation (in milliseconds) -function getPeerTimeoutMs() { - return (serverConfig.peer_timeout_secs || 60) * 1000; -} - -// ============================================================================ -// UTILITY FUNCTIONS -// ============================================================================ - -function showToast(message, type = 'success') { - const toast = document.getElementById('toast'); - const icon = toast.querySelector('i'); - - if (type === 'error') { - icon.className = 'fas fa-exclamation-circle'; - icon.style.color = 'var(--danger-color)'; - } else { - icon.className = 'fas fa-check-circle'; - icon.style.color = 'var(--success-color)'; - } - - document.getElementById('toastMessage').textContent = message; - toast.classList.add('show'); - - setTimeout(() => { - toast.classList.remove('show'); - }, 3000); -} - -function escapeHtml(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -function formatDate(dateString) { - if (!dateString) return 'N/A'; - const date = new Date(dateString); - const options = { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }; - return date.toLocaleDateString('en-US', options); -} diff --git a/web/static/style.css b/web/static/style.css deleted file mode 100644 index 2ddab7ce..00000000 --- a/web/static/style.css +++ /dev/null @@ -1,907 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -:root { - /* Grayscale Dark Theme */ - --primary-color: #6b7280; - --secondary-color: #4b5563; - --success-color: #10b981; - --danger-color: #ef4444; - --warning-color: #f59e0b; - --info-color: #3b82f6; - --dark-bg: #0a0a0a; - --card-bg: rgba(30, 30, 30, 0.8); - --text-primary: #e5e7eb; - --text-secondary: #9ca3af; - --glass-bg: rgba(20, 20, 20, 0.8); - --glass-border: rgba(75, 85, 99, 0.3); - --hover-bg: rgba(55, 65, 81, 0.5); -} - -body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - background: var(--dark-bg); - color: var(--text-primary); - min-height: 100vh; - overflow-x: hidden; -} - -.bg-gradient { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: linear-gradient(135deg, - rgba(107, 114, 128, 0.15) 0%, - rgba(75, 85, 99, 0.1) 25%, - rgba(55, 65, 81, 0.12) 50%, - rgba(107, 114, 128, 0.15) 75%, - rgba(75, 85, 99, 0.1) 100%); - opacity: 1; - z-index: -1; - /* Heavy animation removed for better GPU performance */ -} - -/* Animation simplified - only opacity for minimal GPU usage */ -@keyframes gradientShift { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.95; - } -} - -/* Glass Effect - Zoptymalizowany dla GPU */ -.glass-effect { - background: var(--glass-bg); - /* backdrop-filter: blur(3px); */ - /* Blur disabled for better performance - uncomment if hardware supports it */ - border: 1px solid var(--glass-border); - box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); -} - -/* Navigation */ -.navbar { - padding: 0.5rem 0; - position: sticky; - top: 0; - z-index: 100; - margin-bottom: 1rem; -} - -.nav-content { - max-width: 1600px; - margin: 0 auto; - padding: 0 1rem; - display: flex; - justify-content: space-between; - align-items: center; -} - -.nav-brand { - display: flex; - align-items: center; - gap: 0.75rem; - font-size: 1.5rem; - font-weight: 700; - color: var(--text-primary); -} - -.nav-brand i { - color: var(--primary-color); -} - -.nav-stats { - display: flex; - gap: 1rem; -} - -.stat-badge { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - background: var(--glass-bg); - border-radius: 20px; - border: 1px solid var(--glass-border); -} - -.stat-badge.active { - background: rgba(67, 233, 123, 0.1); - border-color: var(--success-color); -} - -.stat-badge i { - color: var(--primary-color); -} - -.stat-badge.active i { - color: var(--success-color); -} - -/* Container */ -.container { - max-width: 1400px; - margin: 0 auto; - padding: 0 2rem 2rem; -} - -/* Header Section */ -.header-section { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 2rem; -} - -.page-title { - font-size: 2.5rem; - font-weight: 700; - background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -.action-buttons { - display: flex; - gap: 1rem; -} - -/* Buttons */ -.btn { - padding: 0.5rem 1rem; - border: none; - border-radius: 8px; - font-size: 0.9rem; - font-weight: 600; - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 0.4rem; - transition: transform 0.2s ease, box-shadow 0.2s ease; - position: relative; - overflow: hidden; - will-change: transform; -} - -/* Simplified ripple effect - less GPU intensive */ -.btn::before { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(255, 255, 255, 0.1); - opacity: 0; - transition: opacity 0.3s ease; -} - -.btn:hover::before { - opacity: 1; -} - -.btn-primary { - background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); - color: white; -} - -.btn-secondary { - background: var(--glass-bg); - color: var(--text-primary); - border: 1px solid var(--glass-border); -} - -.btn-danger { - background: linear-gradient(135deg, var(--danger-color), #ff6b6b); - color: white; -} - -.btn:hover { - transform: translateY(-2px); - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3); -} - -.btn-block { - width: 100%; - justify-content: center; -} - -/* Stats Grid */ -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 1.5rem; - margin-bottom: 2rem; -} - -.stat-card { - padding: 1rem; - border-radius: 12px; - display: flex; - align-items: center; - gap: 1rem; - transition: transform 0.2s ease, box-shadow 0.2s ease; - will-change: transform; -} - -.stat-card:hover { - transform: translateY(-3px); - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3); -} - -.stat-icon { - width: 48px; - height: 48px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.5rem; - color: white; -} - -.stat-info { - flex: 1; -} - -.stat-label { - font-size: 0.9rem; - color: var(--text-secondary); - margin-bottom: 0.25rem; -} - -.stat-value { - font-size: 2rem; - font-weight: 700; - color: var(--text-primary); -} - -/* Table Container */ -.table-container { - border-radius: 15px; - padding: 1.5rem; -} - -.table-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; -} - -.table-header h2 { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 1.5rem; -} - -.search-box { - position: relative; - display: flex; - align-items: center; -} - -.search-box i { - position: absolute; - left: 1rem; - color: var(--text-secondary); -} - -.search-box input { - padding: 0.75rem 1rem 0.75rem 2.5rem; - background: var(--glass-bg); - border: 1px solid var(--glass-border); - border-radius: 10px; - color: var(--text-primary); - font-size: 1rem; - width: 300px; - transition: all 0.3s ease; -} - -.search-box input:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); -} - -/* Table */ -.table-wrapper { - overflow-x: auto; - border-radius: 10px; -} - -.devices-table { - width: 100%; - border-collapse: collapse; -} - -.devices-table thead tr { - background: var(--glass-bg); -} - -.devices-table th { - padding: 1rem; - text-align: left; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - font-size: 0.85rem; - letter-spacing: 0.5px; -} - -.devices-table tbody tr { - border-bottom: 1px solid var(--glass-border); - transition: all 0.3s ease; -} - -.devices-table tbody tr:hover { - background: var(--glass-bg); -} - -.devices-table td { - padding: 1rem; - color: var(--text-primary); -} - -.status-badge { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.4rem 0.8rem; - border-radius: 20px; - font-size: 0.85rem; - font-weight: 600; -} - -.status-active { - background: rgba(67, 233, 123, 0.2); - color: var(--success-color); -} - -.status-warning { - background: rgba(255, 193, 7, 0.2); - color: #ffc107; -} - -.status-critical { - background: rgba(255, 152, 0, 0.2); - color: #ff9800; -} - -.status-inactive { - background: rgba(245, 87, 108, 0.2); - color: var(--danger-color); -} - -.action-btn { - background: none; - border: none; - color: var(--text-secondary); - cursor: pointer; - padding: 0.5rem; - border-radius: 5px; - transition: all 0.3s ease; - font-size: 1rem; -} - -.action-btn:hover { - background: var(--glass-bg); -} - -.action-btn.edit:hover { - color: var(--info-color); -} - -.action-btn.delete:hover { - color: var(--danger-color); -} - -.action-btn.details:hover { - color: var(--primary-color); -} - -.action-btn.connect:hover { - color: var(--success-color); -} - -.loading { - text-align: center; - padding: 3rem; - color: var(--text-secondary); -} - -.loading span { - display: inline-block; - margin-left: 1rem; -} - -.spinner { - display: inline-block; - width: 30px; - height: 30px; - border: 3px solid var(--glass-border); - border-top-color: var(--primary-color); - border-radius: 50%; - animation: spin 1s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -/* Modal - Zoptymalizowany dla GPU */ -.modal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.85); - /* backdrop-filter disabled for better performance */ - z-index: 1000; - align-items: center; - justify-content: center; - animation: fadeIn 0.2s ease; -} - -.modal.active { - display: flex; -} - -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -.modal-content { - width: 90%; - max-width: 500px; - border-radius: 15px; - overflow: hidden; - animation: slideUp 0.3s ease; -} - -.modal-large { - max-width: 700px; -} - -@keyframes slideUp { - from { - transform: translateY(50px); - opacity: 0; - } - to { - transform: translateY(0); - opacity: 1; - } -} - -.modal-header { - padding: 1.5rem; - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid var(--glass-border); -} - -.modal-header h2 { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 1.5rem; -} - -.modal-close { - background: none; - border: none; - color: var(--text-secondary); - font-size: 2rem; - cursor: pointer; - padding: 0; - width: 30px; - height: 30px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 5px; - transition: all 0.3s ease; -} - -.modal-close:hover { - background: var(--glass-bg); - color: var(--text-primary); -} - -.modal-body { - padding: 1.5rem; -} - -.modal-footer { - padding: 1.5rem; - display: flex; - gap: 1rem; - justify-content: flex-end; - border-top: 1px solid var(--glass-border); -} - -.form-group { - margin-bottom: 1.5rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - color: var(--text-secondary); - font-weight: 600; -} - -.form-control { - width: 100%; - padding: 0.75rem; - background: var(--glass-bg); - border: 1px solid var(--glass-border); - border-radius: 10px; - color: var(--text-primary); - font-size: 1rem; - transition: all 0.3s ease; -} - -.form-control:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); -} - -.form-control:read-only { - background: rgba(255, 255, 255, 0.03); - cursor: not-allowed; -} - -textarea.form-control { - resize: vertical; - min-height: 100px; - font-family: inherit; -} - -.form-text { - font-size: 0.8rem; - margin-top: 0.25rem; -} - -.text-muted { - color: var(--text-secondary); - opacity: 0.7; -} - -/* ID History Display */ -.id-history-list { - padding: 0.75rem; - background: rgba(255, 255, 255, 0.03); - border-radius: 8px; - font-size: 0.9rem; - color: var(--text-secondary); -} - -.id-history-item { - display: inline-block; - padding: 0.25rem 0.5rem; - background: var(--glass-bg); - border: 1px solid var(--glass-border); - border-radius: 4px; - margin: 0.1rem; - font-family: monospace; - font-size: 0.85rem; - color: var(--text-primary); -} - -.warning-text { - color: var(--warning-color); - margin-top: 0.5rem; - font-size: 0.9rem; -} - -.key-display { - background: var(--dark-bg); - padding: 1rem; - border-radius: 10px; - margin-bottom: 1rem; - word-break: break-all; -} - -.key-display code { - color: var(--success-color); - font-family: 'Courier New', monospace; - font-size: 0.95rem; -} - -.details-grid { - display: grid; - gap: 1rem; -} - -.detail-item { - display: grid; - grid-template-columns: 150px 1fr; - gap: 1rem; - padding: 0.75rem; - background: var(--glass-bg); - border-radius: 10px; -} - -.detail-label { - font-weight: 600; - color: var(--text-secondary); -} - -.detail-value { - color: var(--text-primary); - word-break: break-all; - font-family: monospace; -} - -/* Toast Notification */ -.toast { - position: fixed; - bottom: 2rem; - right: 2rem; - padding: 1rem 1.5rem; - border-radius: 10px; - display: flex; - align-items: center; - gap: 0.75rem; - color: var(--text-primary); - transform: translateX(400px); - transition: transform 0.3s ease; - z-index: 2000; -} - -.toast.show { - transform: translateX(0); -} - -.toast i { - color: var(--success-color); - font-size: 1.2rem; -} - -/* Animations */ -.fade-in { - animation: fadeIn 0.6s ease; -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* Responsive */ -@media (max-width: 768px) { - .container { - padding: 0 1rem 1rem; - } - - .header-section { - flex-direction: column; - gap: 1rem; - align-items: flex-start; - } - - .action-buttons { - width: 100%; - } - - .action-buttons .btn { - flex: 1; - } - - .stats-grid { - grid-template-columns: 1fr; - } - - .search-box input { - width: 100%; - } - - .table-header { - flex-direction: column; - gap: 1rem; - align-items: flex-start; - } - - .modal-content { - width: 95%; - } - - .detail-item { - grid-template-columns: 1fr; - } - - .nav-stats { - flex-wrap: wrap; - } -} -/* About Page Styles */ -.about-container { - padding: 2rem; - max-width: 900px; - margin: 0 auto; -} - -.about-container h2 { - font-size: 2rem; - margin-bottom: 2rem; - color: var(--text-primary); - display: flex; - align-items: center; - gap: 0.75rem; -} - -.about-section { - margin-bottom: 2.5rem; -} - -.about-section h3 { - font-size: 1.4rem; - margin-bottom: 1rem; - color: var(--primary-color); -} - -.about-section p { - margin-bottom: 0.75rem; - line-height: 1.6; - color: var(--text-secondary); -} - -.about-section ul { - list-style: none; - padding-left: 0; -} - -.about-section ul li { - margin-bottom: 0.75rem; - padding-left: 1.5rem; - position: relative; - line-height: 1.6; - color: var(--text-secondary); -} - -.about-section ul li i { - position: absolute; - left: 0; - top: 0.25rem; - color: var(--success-color); -} - -.about-section ul li strong { - color: var(--text-primary); -} - -.github-link { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1.5rem; - background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%); - color: var(--text-primary); - text-decoration: none; - border-radius: 8px; - font-weight: 600; - transition: all 0.3s ease; - border: 1px solid var(--glass-border); -} - -.github-link:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(107, 114, 128, 0.4); - background: linear-gradient(135deg, #4b5563 0%, #374151 100%); -} - -.github-link i { - font-size: 1.2rem; -} - -/* User Management Styles */ -.users-container { - padding: 2rem; -} - -.users-container h2 { - margin-bottom: 1.5rem; - display: flex; - align-items: center; - gap: 0.75rem; -} -/* Badge Styles */ -.badge { - display: inline-block; - padding: 0.25rem 0.75rem; - border-radius: 12px; - font-size: 0.85rem; - font-weight: 600; - text-transform: capitalize; -} - -.badge-success { - background: var(--success-color); - color: white; -} - -.badge-danger { - background: var(--danger-color); - color: white; -} - -.badge-warning { - background: var(--warning-color); - color: white; -} - -.badge-info { - background: var(--info-color); - color: white; -} - -/* Settings Page Styles */ -.settings-container { - padding: 2rem; - max-width: 800px; - margin: 0 auto; -} - -.settings-container h2 { - font-size: 2rem; - margin-bottom: 2rem; - display: flex; - align-items: center; - gap: 0.75rem; -} - -.settings-section { - margin-bottom: 2rem; - padding: 1.5rem; - background: rgba(30, 30, 30, 0.5); - border-radius: 12px; - border: 1px solid var(--glass-border); -} - -.settings-section h3 { - font-size: 1.3rem; - margin-bottom: 1rem; - color: var(--primary-color); -} - -.settings-section p { - margin-bottom: 1rem; - color: var(--text-secondary); - line-height: 1.6; -} - -/* Key Page Styles */ -.key-container { - padding: 2rem; - max-width: 800px; - margin: 0 auto; -} - -.key-container h2 { - font-size: 2rem; - margin-bottom: 1.5rem; - display: flex; - align-items: center; - gap: 0.75rem; -} diff --git a/web/templates/client_generator.html b/web/templates/client_generator.html deleted file mode 100644 index fea1e19e..00000000 --- a/web/templates/client_generator.html +++ /dev/null @@ -1,2011 +0,0 @@ - - - - - - - BetterDesk Console - Client Generator - - - - - - - - - -
- - - - -
- -
-
- -

Build Method

-
-
-
- -
-
- -
-

Source Compilation

- Build time: ~10-15 min -
-
-

- Compiles RustDesk from source with your custom server configuration, branding, logo, and app name. -

-
-
-
- - -
-
- - Supported platforms (source compilation): -
-
- - Linux x64 - - - Linux ARM64 - -
-

- Source compilation creates a custom client with embedded server configuration, custom name and logo. -
Build time: 5-15 minutes (first build takes longer due to dependency download). -

-
-
- - -
-
- -

Target Platform

-
-
-
- -
Linux
-
x86_64
-
- Supported -
-
-
- -
Linux
-
ARM64
-
- Supported -
-
-
- -
Windows
-
64-bit (x64)
-
- Experimental -
-
-
- -
Windows
-
32-bit (x86)
-
- Experimental -
-
-
- -
macOS
-
Intel (x64)
-
- Not supported -
-
-
- -
macOS
-
Apple Silicon
-
- Not supported -
-
-
-
- - Note: Linux platforms are fully supported for source compilation. - Windows requires vcpkg cross-compilation setup (experimental). - macOS requires native Apple hardware. -
-
- - -
-
- -

Basic Configuration

-
- -
-
- -
-
- -
- -
- Select the RustDesk version to use -
-
- - - Filename for the generated client (without extension) -
-
-
- - -
-
-

Branding & Customization

- -
-
-
- - Customize how the RustDesk client appears to users. Logo and text will be displayed in the client's main window. -
- -
- - - Name displayed in the client window title (leave empty for default "RustDesk") -
- -
-
- -
-
- - - PNG, JPG or SVG (max 256x256px recommended) -
-
- -
-
-
-
- - - Direct URL to logo image (alternative to upload) -
-
- -
- - - Text displayed below the logo in client window (support contact, company name, etc.) -
- -
- -
-
- - - Windows icon file for taskbar/shortcuts -
-
- -
-
-
-
-
- - -
-
- -

Server Configuration

-
- -
-
- - - Your RustDesk server hostname or IP -
-
- - - Your server's public key for secure connection -
-
- -
- - - Only needed if using custom API port -
- - -
- - -
-
-

Security Options

- -
-
-
-
- - -
-
- - - Pre-configured permanent password -
-
- -
- - -
-
-
- - -
-
-

Default Permissions

- -
-
-

- Configure which permissions are enabled by default for remote sessions. -

-
- - - - - - - - -
-
-
- - -
-
-

Display Options

- -
-
-
-
- - -
-
- - -
-
-
- - -
-
-
- - -
- - -
- - -
- - -
-
- - -
-
-
-
-
Preparing...
-
-
-
-
Initializing build process
- -
-
-
- Download -
-
-
- Configure -
-
-
- Package -
-
-
- Complete -
-
-
-
- -
- -
-
- - -
-
-
- -
-

Client Generated Successfully!

-

Your custom RustDesk client is ready for download.

- -
-
- Filename - - -
-
- Platform - - -
-
- Version - - -
-
- Size - - -
-
- -
- - -
-
-
-
- - - - - - diff --git a/web/templates/index.html b/web/templates/index.html deleted file mode 100644 index 5ad15e3a..00000000 --- a/web/templates/index.html +++ /dev/null @@ -1,1082 +0,0 @@ - - - - - - - - - BetterDesk Console v1.5 - - - - - - - -
- - - - - -
- -
- - - - -
- -
-

Device Dashboard

-
- -
-
- - -
-
-
- -
-
-
Total Devices
-
0
-
-
- -
-
- -
-
-
Active
-
0
-
-
- -
-
- -
-
-
Inactive
-
0
-
-
- -
-
- -
-
-
Banned
-
0
-
-
- -
-
- -
-
-
With Notes
-
0
-
-
-
- - -
-
-

Connected Devices

- -
- -
- - - - - - - - - - - - - - - -
IDNoteStatusCreatedActions
-
- Loading devices... -
-
-
-
-
- - -
-
-

Server Public Key

- -
-

Protected Content

-

- Enter your password to view the server public key -

- - -
- - -
-
- - -
-
-

Settings

- - -
-

Server Settings

-
-
- - - - Time before a device is marked as offline (10-300 seconds) - -
-
- - - - How often to check device status (1-30 seconds) - -
-
- - - - Number of missed heartbeats before "Degraded" status - -
-
- - - - Number of missed heartbeats before "Critical" status - -
- - -
-
- - -
-

Change Password

-
-
- - -
-
- - - - Minimum 8 characters, must contain letters and numbers - -
-
- - -
- -
-
- - -
-

Language Settings

- - -
- - - - Your language preference is saved automatically - -
- - -
- -
- - - -
- - Upload a JSON language file. See documentation for format. - -
- - -
- -
-
-
-
-
- - - - - -
-
-

About BetterDesk Console

- -
-

Project Information

-

- BetterDesk Console v1.5.0 - Advanced management interface for RustDesk Server -

-
- -
-

Source Code

-

- This project is open source and available on GitHub: -

-

- - github.com/UNITRONIX/Rustdesk-FreeConsole - -

-

- Issues, feature requests, and contributions are welcome! -

-
- -
-

Open Source Components

-

This project is built with the following open source software:

- -
- -
-

Security Features

-
    -
  • Authentication System - Secure login with bcrypt password hashing
  • -
  • Role-Based Access Control - Admin, Operator, and Viewer roles
  • -
  • CSRF Protection - Cross-Site Request Forgery prevention
  • -
  • Rate Limiting - Protection against brute force attacks
  • -
  • Fail-Closed Policy - Banned devices cannot connect even if service restarts
  • -
  • Audit Logging - Complete history of user actions
  • -
  • Content Security Policy - Protection against XSS attacks
  • -
-
- -
-

Credits

-

- Developed by UNITRONIX
- Special thanks to the RustDesk team and open source community. -

-

- © 2026 UNITRONIX. Released under AGPL-3.0 License. -

-
-
-
-
- - - - - - - - - - - - - - -
- - -
- - - - - - diff --git a/web/templates/login.html b/web/templates/login.html deleted file mode 100644 index 09261d0c..00000000 --- a/web/templates/login.html +++ /dev/null @@ -1,390 +0,0 @@ - - - - - - - - - Login - BetterDesk Console - - - - - - - - - - - - -