From 46602f19339a7032527853c7c27946961f493a02 Mon Sep 17 00:00:00 2001 From: scheianu Date: Fri, 11 Sep 2026 12:18:34 +0300 Subject: [PATCH] Sanitize codebase, reorganize docs, and add missing deploy files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove dead code identified in docs/SANITIZATION-REVIEW.md: - app/blueprints/content_old.py and app/blueprints/playlist.py - app/models/group.py, app/utils/nginx_config_reader.py - orphaned templates (content_list, edit_content, upload_content, player_page) and the related group/Template references Result: 6 blueprints, 82 routes, no dead modules or orphan templates. Add files that deploy.sh and docker-entrypoint.sh already require but which were never tracked: - https_manager.py (referenced by deploy.sh, migrate_network.sh, docker-entrypoint.sh) - Caddyfile.example (seeded by deploy.sh; its absence aborts deploy) Relocate generated Graphify artifacts from graphify-out/ to docs/graphify-out/ (110 files, no content change) and archive the superseded docs under docs/. Ignore hygiene: - ignore ad-hoc .env backups (.env.bak*) — they contain live secrets - keep the pre-sanitization snapshots (docs/legacy code/, docs/old_code_documentation/) on disk but out of the repo Fix .env.example: drop a duplicated config block, genericize the hardcoded host IP, and document HOSTNAME_INTERNAL. --- .dockerignore | 5 + .env.example | 55 +- .gitignore | 24 + Caddyfile.example | 35 + app/app.py | 2 - app/blueprints/admin.py | 116 +- app/blueprints/api.py | 87 +- app/blueprints/content_old.py | 500 -------- app/blueprints/players.py | 102 -- app/blueprints/playlist.py | 310 ----- app/models/__init__.py | 3 - app/models/content.py | 7 - app/models/group.py | 66 -- app/models/player.py | 2 +- app/templates/admin/build_player.html | 84 +- app/templates/content/content_list.html | 205 ---- app/templates/content/edit_content.html | 11 - app/templates/content/upload_content.html | 278 ----- app/templates/players/player_page.html | 227 ---- app/utils/__init__.py | 16 +- app/utils/caddy_manager.py | 263 ++--- app/utils/group_player_management.py | 169 +-- app/utils/nginx_config_reader.py | 120 -- app/utils/player_build.py | 338 +++++- deploy.sh | 259 ++++- deployment-commands-reference.sh | 4 +- docker-compose.yml | 50 +- docker-entrypoint.sh | 152 ++- docs/01-architecture.md | 7 +- docs/02-knowledge-graph.md | 2 +- docs/03-data-model.md | 20 +- docs/04-application-core.md | 9 +- docs/05-blueprints-api.md | 4 +- docs/06-utils-services.md | 41 +- docs/07-deployment.md | 240 +++- docs/09-legacy-and-migrations.md | 15 +- docs/README.md | 3 +- docs/SANITIZATION-REVIEW.md | 206 ++++ .../graphify-out}/COMPASS.md | 0 .../graphify-out}/DOMAINS.md | 0 .../graphify-out}/GRAPH_REPORT.md | 0 ...02e315268839584af12a578a8cd601e89352a.json | 0 ...e133cc2480db6100242b19a76ab0969c6ff6a.json | 0 ...794bf8d243cb7f83a010863fe16a728a2872d.json | 0 ...beeb73e4fc379c785381a8c17c608031319ad.json | 0 ...4971c2abab3724685a1e84ad3c87685cfffe0.json | 0 ...e21b293e8dc401f1035c831188299b857e647.json | 0 ...68c89d4ccbc8d2beb69b6eb7f6d33657fbdaa.json | 0 ...6441b35877bca1373f23a417e124ad1d52b81.json | 0 ...a9391ffee713b2c874f1d3d7df8617ad3dd61.json | 0 ...af5452c2aba1e167d2cbd7dc50ee5ba1b3ac1.json | 0 ...f9936896684dfc69044c6f4d7313722d061a3.json | 0 ...11c40e0e26f5a3bac58ea05f30a4e2f399e10.json | 0 ...5759e120ff6a7c45fcb66ee0c76ab39ff9de0.json | 0 ...f03a30dbd6594d98507911a0aac9592fb95fa.json | 0 ...905cda95dace56d5e375d16524feb8bd71130.json | 0 ...008df52caf0408e566ba195928df3609c4b1f.json | 0 ...9688e4c0fa8ee795ee268bf5f1a44d22ae14d.json | 0 ...e30b9b8ed1fa595e4784a31bccc22d8a15283.json | 0 ...b991a1112e0ffb951d01baa8cf1804a498c0d.json | 0 ...c8b6d6885693daf76326d8b78d5770191a6f7.json | 0 ...c512618bab85b8625e11094bf73518e244e7d.json | 0 ...2968007d21885a99350ebaf2f6f37e5ae7344.json | 0 ...021327a9cd826aaa6779d7cadfdf855a49df8.json | 0 ...40239de0d21d0b7268cf7b3872410adc7ea02.json | 0 ...035b09c6b92330bc3ec6eca7ce7b46b61db01.json | 0 ...e454669ef17b201b21cd8b87dfbb5a6e18c7e.json | 0 ...ac7a71ebb513854b2d69db2e7ff4451064c38.json | 0 ...57ad3580b9b694089332468b2a86a8707a5e6.json | 0 ...d4bbcdfb6a3be1dbcc1beb1b011902a262372.json | 0 ...a215118b49872ca0318674e2e11d9cc4e8e33.json | 0 ...309e27c3e0fa9b678ec3731ef6bb7a1fb003d.json | 0 ...b3e9380d8503b84236a13c437e9d180f0cb96.json | 0 ...f840b7e41201dd7eaeb689b6f76385a43d9fa.json | 0 ...0f5f5a55452aface45bd4bfadfae2b3a8f88e.json | 0 ...94b04fe125bc5350d3a7a71dd56104773cb3d.json | 0 ...15cd8eae43490098b886b401354636a3f48c1.json | 0 ...bde45777a3e62638e323c54e54a208cc0ceb9.json | 0 ...867f8407bc53a7da9b984cb11f650ec4e6cdc.json | 0 ...b91decd6905add26d7d70339490f61c6ed55e.json | 0 ...6b042a55b2bfc11993ca93dc3b3d93aff2171.json | 0 ...635023bb861e9a4ad9374a72770d517c4af01.json | 0 ...626bd2a97367d11d7036ec90cf433711da35c.json | 0 ...e09433c77c789d68c2537450572b22a83c358.json | 0 ...56340911f28c239f5d8a210132ec739e97f39.json | 0 ...d250f771f3554bede74f62bea2661952edf36.json | 0 ...0b06c4cd695fd9d4d74aeb59699c9335f40ff.json | 0 ...eef375eb00ceba72338a37beff6b47688835c.json | 0 ...f13a5f795e20640cd932236c89c81e2eb770a.json | 0 ...ccdb939442755123cfecce7b7e1bc92b57892.json | 0 .../graphify-out}/graph.compact.txt | 0 .../graphify-out}/graph.html | 0 .../graphify-out}/graph.json | 0 .../graphify-out}/intelligence.json | 0 .../graphify-out}/metadata.json | 0 .../graphify-out}/suggestions.json | 0 .../wiki/CaddyConfigGenerator.md | 0 .../graphify-out}/wiki/Community_0.md | 0 .../graphify-out}/wiki/Community_1.md | 0 .../graphify-out}/wiki/Community_10.md | 0 .../graphify-out}/wiki/Community_11.md | 0 .../graphify-out}/wiki/Community_12.md | 0 .../graphify-out}/wiki/Community_13.md | 0 .../graphify-out}/wiki/Community_14.md | 0 .../graphify-out}/wiki/Community_15.md | 0 .../graphify-out}/wiki/Community_16.md | 0 .../graphify-out}/wiki/Community_17.md | 0 .../graphify-out}/wiki/Community_18.md | 0 .../graphify-out}/wiki/Community_19.md | 0 .../graphify-out}/wiki/Community_2.md | 0 .../graphify-out}/wiki/Community_20.md | 0 .../graphify-out}/wiki/Community_21.md | 0 .../graphify-out}/wiki/Community_22.md | 0 .../graphify-out}/wiki/Community_23.md | 0 .../graphify-out}/wiki/Community_24.md | 0 .../graphify-out}/wiki/Community_25.md | 0 .../graphify-out}/wiki/Community_26.md | 0 .../graphify-out}/wiki/Community_27.md | 0 .../graphify-out}/wiki/Community_28.md | 0 .../graphify-out}/wiki/Community_29.md | 0 .../graphify-out}/wiki/Community_3.md | 0 .../graphify-out}/wiki/Community_30.md | 0 .../graphify-out}/wiki/Community_31.md | 0 .../graphify-out}/wiki/Community_32.md | 0 .../graphify-out}/wiki/Community_33.md | 0 .../graphify-out}/wiki/Community_34.md | 0 .../graphify-out}/wiki/Community_35.md | 0 .../graphify-out}/wiki/Community_36.md | 0 .../graphify-out}/wiki/Community_37.md | 0 .../graphify-out}/wiki/Community_38.md | 0 .../graphify-out}/wiki/Community_39.md | 0 .../graphify-out}/wiki/Community_4.md | 0 .../graphify-out}/wiki/Community_40.md | 0 .../graphify-out}/wiki/Community_5.md | 0 .../graphify-out}/wiki/Community_6.md | 0 .../graphify-out}/wiki/Community_7.md | 0 .../graphify-out}/wiki/Community_8.md | 0 .../graphify-out}/wiki/Community_9.md | 0 .../graphify-out}/wiki/Content.md | 0 .../graphify-out}/wiki/HTTPSConfig.md | 0 .../wiki/Models_package_for_digiserver-v2..md | 0 .../graphify-out}/wiki/PlayerEdit.md | 0 .../graphify-out}/wiki/PlayerUser.md | 0 .../graphify-out}/wiki/Playlist.md | 0 .../graphify-out}/wiki/User.md | 0 .../graphify-out}/wiki/create_app().md | 0 .../graphify-out}/wiki/index.md | 0 .../graphify-out}/wiki/log_action().md | 0 docs/tools/sanitize_audit.py | 314 +++++ docs/tools/sanitize_report.py | 304 +++++ docs/tools/sanitize_templates.py | 65 ++ docs/tools/smoke_test.py | 108 ++ docs/tools/test_build_via_ui.py | 97 ++ docs/tools/test_http_https_runtime.sh | 179 +++ docs/tools/test_https_bootstrap.py | 232 ++++ docs/tools/test_https_fallback.py | 119 ++ docs/tools/test_https_manager.py | 75 ++ docs/tools/test_player_build.py | 161 +++ docs/tools/verify_caddyfile_modes.py | 96 ++ docs/tools/verify_dockerignore.py | 55 + https_manager.py | 390 +++++++ migrate_network.sh | 89 +- migrations/migrate_player_user_global.py | 63 +- old_code_documentation/.env.example | 21 - .../CADDY_DYNAMIC_CONFIG.md | 295 ----- old_code_documentation/DATA_DEPLOYMENT.md | 75 -- .../DEPLOYMENT_ARCHITECTURE_ANALYSIS.md | 201 ---- old_code_documentation/DEPLOYMENT_COMMANDS.md | 272 ----- old_code_documentation/DEPLOYMENT_INDEX.md | 278 ----- old_code_documentation/DEPLOYMENT_README.md | 433 ------- old_code_documentation/DOCKER.md | 284 ----- .../DOCKER_EXEC_COMMANDS.md | 353 ------ .../EDIT_MEDIA_TROUBLESHOOTING.md | 144 --- old_code_documentation/GROUPS_ANALYSIS.md | 96 -- old_code_documentation/HTTPS_CONFIGURATION.md | 192 --- old_code_documentation/HTTPS_EMAIL_UPDATE.md | 202 ---- .../HTTPS_IMPLEMENTATION_SUMMARY.md | 316 ----- .../HTTPS_QUICK_REFERENCE.md | 259 ----- old_code_documentation/HTTPS_SETUP.md | 75 -- .../IMPLEMENTATION_OPTIONAL_LIBREOFFICE.md | 265 ----- .../LEGACY_PLAYLIST_ROUTES.md | 51 - .../MODERNIZATION_COMPLETE.md | 262 ----- .../NGINX_CONFIG_MIGRATION.md | 111 -- old_code_documentation/NGINX_SETUP_QUICK.md | 84 -- .../OPTION1_IMPLEMENTATION.md | 226 ---- .../OPTIONAL_DEPENDENCIES.md | 258 ----- .../PLAYER_EDIT_MEDIA_API.md | 181 --- old_code_documentation/PROXY_FIX_SETUP.md | 56 - old_code_documentation/QUICK_START.md | 120 -- old_code_documentation/README.md | 297 ----- old_code_documentation/add_muted_column.py | 33 - old_code_documentation/blueprint_groups.py | 401 ------- old_code_documentation/check_fix_player.py | 49 - .../clean_for_deployment.sh | 93 -- .../DEPLOYMENT_READINESS_SUMMARY.md | 326 ------ .../deploy_tips/DEPLOYMENT_STEPS_QUICK.md | 215 ---- .../deploy_tips/DOCUMENTATION_INDEX.md | 301 ----- .../deploy_tips/MASTER_DEPLOYMENT_PLAN.md | 380 ------ .../PRE_DEPLOYMENT_IP_CONFIGURATION.md | 346 ------ .../PRODUCTION_DEPLOYMENT_GUIDE.md | 363 ------ old_code_documentation/docker-start.sh | 69 -- .../fix_player_user_schema.py | 25 - .../generate_nginx_certs.sh | 30 - .../init-data.sh.deprecated | 29 - .../migrate_add_edit_enabled.py | 47 - .../nginx-custom-domains.conf | 21 - old_code_documentation/nginx.conf | 129 --- .../KIWY_PLAYER_ANALYSIS_INDEX.md | 395 ------- .../KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md | 482 -------- .../KIWY_PLAYER_HTTPS_ANALYSIS.md | 583 ---------- .../KIWY_PLAYER_HTTPS_QUICK_REF.md | 319 ----- .../KIWY_PLAYER_SSL_PATCHES.md | 414 ------- .../PLAYER_HTTPS_CONNECTION_ANALYSIS.md | 375 ------ .../PLAYER_HTTPS_CONNECTION_FIXES.md | 186 --- .../PLAYER_HTTPS_INTEGRATION_GUIDE.md | 346 ------ .../playlist/manage_playlist.html | 1025 ----------------- old_code_documentation/run_dev.sh | 76 -- old_code_documentation/start.sh | 23 - .../templates_groups/create_group.html | 21 - .../templates_groups/edit_group.html | 11 - .../templates_groups/group_fullscreen.html | 10 - .../templates_groups/groups_list.html | 11 - .../templates_groups/manage_group.html | 11 - old_code_documentation/test_edit_media_api.py | 420 ------- .../test_edit_media_simple.py | 182 --- verify-deployment.sh | 198 +++- 226 files changed, 3999 insertions(+), 15737 deletions(-) create mode 100644 Caddyfile.example delete mode 100644 app/blueprints/content_old.py delete mode 100644 app/blueprints/playlist.py delete mode 100644 app/models/group.py delete mode 100644 app/templates/content/content_list.html delete mode 100644 app/templates/content/edit_content.html delete mode 100644 app/templates/content/upload_content.html delete mode 100644 app/templates/players/player_page.html delete mode 100644 app/utils/nginx_config_reader.py create mode 100644 docs/SANITIZATION-REVIEW.md rename {graphify-out => docs/graphify-out}/COMPASS.md (100%) rename {graphify-out => docs/graphify-out}/DOMAINS.md (100%) rename {graphify-out => docs/graphify-out}/GRAPH_REPORT.md (100%) rename {graphify-out => docs/graphify-out}/cache/00c752d7501e4f21c01d224dc9502e315268839584af12a578a8cd601e89352a.json (100%) rename {graphify-out => docs/graphify-out}/cache/042d4128648eb65ae565150559ae133cc2480db6100242b19a76ab0969c6ff6a.json (100%) rename {graphify-out => docs/graphify-out}/cache/08a5cf60c9886d9492c059388f7794bf8d243cb7f83a010863fe16a728a2872d.json (100%) rename {graphify-out => docs/graphify-out}/cache/09751e1c4260d14e761c1c92739beeb73e4fc379c785381a8c17c608031319ad.json (100%) rename {graphify-out => docs/graphify-out}/cache/0bb37d53cd6b63caca5dc4a15bd4971c2abab3724685a1e84ad3c87685cfffe0.json (100%) rename {graphify-out => docs/graphify-out}/cache/0d890c23b5d5b491833873e1886e21b293e8dc401f1035c831188299b857e647.json (100%) rename {graphify-out => docs/graphify-out}/cache/0e9c4183e70fcfb3057a320236d68c89d4ccbc8d2beb69b6eb7f6d33657fbdaa.json (100%) rename {graphify-out => docs/graphify-out}/cache/12d5cdd758d9915b1802a2a22356441b35877bca1373f23a417e124ad1d52b81.json (100%) rename {graphify-out => docs/graphify-out}/cache/1501c3d3f8886e1933d28b2a197a9391ffee713b2c874f1d3d7df8617ad3dd61.json (100%) rename {graphify-out => docs/graphify-out}/cache/18597a9be9d9cc7cda10c6c5ca2af5452c2aba1e167d2cbd7dc50ee5ba1b3ac1.json (100%) rename {graphify-out => docs/graphify-out}/cache/20c121d89d9bd3f9d9e628bd136f9936896684dfc69044c6f4d7313722d061a3.json (100%) rename {graphify-out => docs/graphify-out}/cache/2671cc27511abcc87eaa7b5b3a211c40e0e26f5a3bac58ea05f30a4e2f399e10.json (100%) rename {graphify-out => docs/graphify-out}/cache/3469a90f710fb95efbe31b435d75759e120ff6a7c45fcb66ee0c76ab39ff9de0.json (100%) rename {graphify-out => docs/graphify-out}/cache/3b8625d015b1a280c46a5204b1bf03a30dbd6594d98507911a0aac9592fb95fa.json (100%) rename {graphify-out => docs/graphify-out}/cache/3f43ab8f52d2063aca83d66e112905cda95dace56d5e375d16524feb8bd71130.json (100%) rename {graphify-out => docs/graphify-out}/cache/411f80599b152bba1a94805fd4e008df52caf0408e566ba195928df3609c4b1f.json (100%) rename {graphify-out => docs/graphify-out}/cache/491cfde76df2f5a2c11098906bc9688e4c0fa8ee795ee268bf5f1a44d22ae14d.json (100%) rename {graphify-out => docs/graphify-out}/cache/54c5a969dbb46306d6bdea71b89e30b9b8ed1fa595e4784a31bccc22d8a15283.json (100%) rename {graphify-out => docs/graphify-out}/cache/608145ad2566fdfba758de0f07bb991a1112e0ffb951d01baa8cf1804a498c0d.json (100%) rename {graphify-out => docs/graphify-out}/cache/612e0be7d395f7f7e094377582cc8b6d6885693daf76326d8b78d5770191a6f7.json (100%) rename {graphify-out => docs/graphify-out}/cache/63296783faeac5e527ca5d566e4c512618bab85b8625e11094bf73518e244e7d.json (100%) rename {graphify-out => docs/graphify-out}/cache/665b013b4c1a402ec5b42e483132968007d21885a99350ebaf2f6f37e5ae7344.json (100%) rename {graphify-out => docs/graphify-out}/cache/6d45e8b056d173e58b56c72185c021327a9cd826aaa6779d7cadfdf855a49df8.json (100%) rename {graphify-out => docs/graphify-out}/cache/6f95b0a1e8563634e78f6fc811340239de0d21d0b7268cf7b3872410adc7ea02.json (100%) rename {graphify-out => docs/graphify-out}/cache/70079e68521808a26af122322a5035b09c6b92330bc3ec6eca7ce7b46b61db01.json (100%) rename {graphify-out => docs/graphify-out}/cache/70d403dd4a07c9f6faa62f365eee454669ef17b201b21cd8b87dfbb5a6e18c7e.json (100%) rename {graphify-out => docs/graphify-out}/cache/721cf1d5064ca6adef6b46ae756ac7a71ebb513854b2d69db2e7ff4451064c38.json (100%) rename {graphify-out => docs/graphify-out}/cache/72d6c067ccd14bc578f9e7d296257ad3580b9b694089332468b2a86a8707a5e6.json (100%) rename {graphify-out => docs/graphify-out}/cache/7e9eb88eefb8fb0239cff4b84ead4bbcdfb6a3be1dbcc1beb1b011902a262372.json (100%) rename {graphify-out => docs/graphify-out}/cache/832df44b668a28c2d8d13244d2ca215118b49872ca0318674e2e11d9cc4e8e33.json (100%) rename {graphify-out => docs/graphify-out}/cache/8c377ef6943874b4f9399d90457309e27c3e0fa9b678ec3731ef6bb7a1fb003d.json (100%) rename {graphify-out => docs/graphify-out}/cache/8f5403ff3219caaadd37b30ed9eb3e9380d8503b84236a13c437e9d180f0cb96.json (100%) rename {graphify-out => docs/graphify-out}/cache/94b871546d6e8026e3724312a5df840b7e41201dd7eaeb689b6f76385a43d9fa.json (100%) rename {graphify-out => docs/graphify-out}/cache/95225fa585aaaa6930654f195820f5f5a55452aface45bd4bfadfae2b3a8f88e.json (100%) rename {graphify-out => docs/graphify-out}/cache/9679855b5811e0c58a1863b53dd94b04fe125bc5350d3a7a71dd56104773cb3d.json (100%) rename {graphify-out => docs/graphify-out}/cache/9e6363e24f41f6353bcf1ce28d815cd8eae43490098b886b401354636a3f48c1.json (100%) rename {graphify-out => docs/graphify-out}/cache/9ed6dd2b6b7d5c5732370642e8bbde45777a3e62638e323c54e54a208cc0ceb9.json (100%) rename {graphify-out => docs/graphify-out}/cache/ad7bb5459ac32cdb614600426f4867f8407bc53a7da9b984cb11f650ec4e6cdc.json (100%) rename {graphify-out => docs/graphify-out}/cache/aee7b0072c56a7ac40e1f9b6edeb91decd6905add26d7d70339490f61c6ed55e.json (100%) rename {graphify-out => docs/graphify-out}/cache/c02348bf53b23706585a139f3c76b042a55b2bfc11993ca93dc3b3d93aff2171.json (100%) rename {graphify-out => docs/graphify-out}/cache/c21c4ad41a2b8570bd490d9d356635023bb861e9a4ad9374a72770d517c4af01.json (100%) rename {graphify-out => docs/graphify-out}/cache/c39c170bcc774e4d740bfdda460626bd2a97367d11d7036ec90cf433711da35c.json (100%) rename {graphify-out => docs/graphify-out}/cache/cab5c69ad0a9bc401a139322633e09433c77c789d68c2537450572b22a83c358.json (100%) rename {graphify-out => docs/graphify-out}/cache/d0eb445d5e223a6d1f0734ddbdf56340911f28c239f5d8a210132ec739e97f39.json (100%) rename {graphify-out => docs/graphify-out}/cache/d3ad3de1947047e09b4bfa267a9d250f771f3554bede74f62bea2661952edf36.json (100%) rename {graphify-out => docs/graphify-out}/cache/d867bca7b49b4ef6deca0fcdf7c0b06c4cd695fd9d4d74aeb59699c9335f40ff.json (100%) rename {graphify-out => docs/graphify-out}/cache/eb1a7845d5c86e98e5d26d1cc50eef375eb00ceba72338a37beff6b47688835c.json (100%) rename {graphify-out => docs/graphify-out}/cache/f7dc1ac90509b8b858c474ecba2f13a5f795e20640cd932236c89c81e2eb770a.json (100%) rename {graphify-out => docs/graphify-out}/cache/fc189becb472d9d42e7a5e27ed9ccdb939442755123cfecce7b7e1bc92b57892.json (100%) rename {graphify-out => docs/graphify-out}/graph.compact.txt (100%) rename {graphify-out => docs/graphify-out}/graph.html (100%) rename {graphify-out => docs/graphify-out}/graph.json (100%) rename {graphify-out => docs/graphify-out}/intelligence.json (100%) rename {graphify-out => docs/graphify-out}/metadata.json (100%) rename {graphify-out => docs/graphify-out}/suggestions.json (100%) rename {graphify-out => docs/graphify-out}/wiki/CaddyConfigGenerator.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_0.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_1.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_10.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_11.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_12.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_13.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_14.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_15.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_16.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_17.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_18.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_19.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_2.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_20.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_21.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_22.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_23.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_24.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_25.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_26.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_27.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_28.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_29.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_3.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_30.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_31.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_32.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_33.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_34.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_35.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_36.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_37.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_38.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_39.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_4.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_40.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_5.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_6.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_7.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_8.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Community_9.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Content.md (100%) rename {graphify-out => docs/graphify-out}/wiki/HTTPSConfig.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Models_package_for_digiserver-v2..md (100%) rename {graphify-out => docs/graphify-out}/wiki/PlayerEdit.md (100%) rename {graphify-out => docs/graphify-out}/wiki/PlayerUser.md (100%) rename {graphify-out => docs/graphify-out}/wiki/Playlist.md (100%) rename {graphify-out => docs/graphify-out}/wiki/User.md (100%) rename {graphify-out => docs/graphify-out}/wiki/create_app().md (100%) rename {graphify-out => docs/graphify-out}/wiki/index.md (100%) rename {graphify-out => docs/graphify-out}/wiki/log_action().md (100%) create mode 100644 docs/tools/sanitize_audit.py create mode 100644 docs/tools/sanitize_report.py create mode 100644 docs/tools/sanitize_templates.py create mode 100644 docs/tools/smoke_test.py create mode 100644 docs/tools/test_build_via_ui.py create mode 100644 docs/tools/test_http_https_runtime.sh create mode 100644 docs/tools/test_https_bootstrap.py create mode 100644 docs/tools/test_https_fallback.py create mode 100644 docs/tools/test_https_manager.py create mode 100644 docs/tools/test_player_build.py create mode 100644 docs/tools/verify_caddyfile_modes.py create mode 100644 docs/tools/verify_dockerignore.py create mode 100644 https_manager.py delete mode 100644 old_code_documentation/.env.example delete mode 100644 old_code_documentation/CADDY_DYNAMIC_CONFIG.md delete mode 100644 old_code_documentation/DATA_DEPLOYMENT.md delete mode 100644 old_code_documentation/DEPLOYMENT_ARCHITECTURE_ANALYSIS.md delete mode 100644 old_code_documentation/DEPLOYMENT_COMMANDS.md delete mode 100644 old_code_documentation/DEPLOYMENT_INDEX.md delete mode 100644 old_code_documentation/DEPLOYMENT_README.md delete mode 100644 old_code_documentation/DOCKER.md delete mode 100644 old_code_documentation/DOCKER_EXEC_COMMANDS.md delete mode 100644 old_code_documentation/EDIT_MEDIA_TROUBLESHOOTING.md delete mode 100644 old_code_documentation/GROUPS_ANALYSIS.md delete mode 100644 old_code_documentation/HTTPS_CONFIGURATION.md delete mode 100644 old_code_documentation/HTTPS_EMAIL_UPDATE.md delete mode 100644 old_code_documentation/HTTPS_IMPLEMENTATION_SUMMARY.md delete mode 100644 old_code_documentation/HTTPS_QUICK_REFERENCE.md delete mode 100644 old_code_documentation/HTTPS_SETUP.md delete mode 100644 old_code_documentation/IMPLEMENTATION_OPTIONAL_LIBREOFFICE.md delete mode 100644 old_code_documentation/LEGACY_PLAYLIST_ROUTES.md delete mode 100644 old_code_documentation/MODERNIZATION_COMPLETE.md delete mode 100644 old_code_documentation/NGINX_CONFIG_MIGRATION.md delete mode 100644 old_code_documentation/NGINX_SETUP_QUICK.md delete mode 100644 old_code_documentation/OPTION1_IMPLEMENTATION.md delete mode 100644 old_code_documentation/OPTIONAL_DEPENDENCIES.md delete mode 100644 old_code_documentation/PLAYER_EDIT_MEDIA_API.md delete mode 100644 old_code_documentation/PROXY_FIX_SETUP.md delete mode 100644 old_code_documentation/QUICK_START.md delete mode 100644 old_code_documentation/README.md delete mode 100644 old_code_documentation/add_muted_column.py delete mode 100644 old_code_documentation/blueprint_groups.py delete mode 100644 old_code_documentation/check_fix_player.py delete mode 100755 old_code_documentation/clean_for_deployment.sh delete mode 100644 old_code_documentation/deploy_tips/DEPLOYMENT_READINESS_SUMMARY.md delete mode 100644 old_code_documentation/deploy_tips/DEPLOYMENT_STEPS_QUICK.md delete mode 100644 old_code_documentation/deploy_tips/DOCUMENTATION_INDEX.md delete mode 100644 old_code_documentation/deploy_tips/MASTER_DEPLOYMENT_PLAN.md delete mode 100644 old_code_documentation/deploy_tips/PRE_DEPLOYMENT_IP_CONFIGURATION.md delete mode 100644 old_code_documentation/deploy_tips/PRODUCTION_DEPLOYMENT_GUIDE.md delete mode 100755 old_code_documentation/docker-start.sh delete mode 100644 old_code_documentation/fix_player_user_schema.py delete mode 100755 old_code_documentation/generate_nginx_certs.sh delete mode 100755 old_code_documentation/init-data.sh.deprecated delete mode 100644 old_code_documentation/migrate_add_edit_enabled.py delete mode 100644 old_code_documentation/nginx-custom-domains.conf delete mode 100644 old_code_documentation/nginx.conf delete mode 100644 old_code_documentation/player_analisis/KIWY_PLAYER_ANALYSIS_INDEX.md delete mode 100644 old_code_documentation/player_analisis/KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md delete mode 100644 old_code_documentation/player_analisis/KIWY_PLAYER_HTTPS_ANALYSIS.md delete mode 100644 old_code_documentation/player_analisis/KIWY_PLAYER_HTTPS_QUICK_REF.md delete mode 100644 old_code_documentation/player_analisis/KIWY_PLAYER_SSL_PATCHES.md delete mode 100644 old_code_documentation/player_analisis/PLAYER_HTTPS_CONNECTION_ANALYSIS.md delete mode 100644 old_code_documentation/player_analisis/PLAYER_HTTPS_CONNECTION_FIXES.md delete mode 100644 old_code_documentation/player_analisis/PLAYER_HTTPS_INTEGRATION_GUIDE.md delete mode 100644 old_code_documentation/playlist/manage_playlist.html delete mode 100755 old_code_documentation/run_dev.sh delete mode 100755 old_code_documentation/start.sh delete mode 100644 old_code_documentation/templates_groups/create_group.html delete mode 100644 old_code_documentation/templates_groups/edit_group.html delete mode 100644 old_code_documentation/templates_groups/group_fullscreen.html delete mode 100644 old_code_documentation/templates_groups/groups_list.html delete mode 100644 old_code_documentation/templates_groups/manage_group.html delete mode 100644 old_code_documentation/test_edit_media_api.py delete mode 100644 old_code_documentation/test_edit_media_simple.py diff --git a/.dockerignore b/.dockerignore index 261955e..b2de74b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -47,6 +47,11 @@ Thumbs.db # Runtime data volumes (mounted at runtime, NOT part of the image) data/ +# Archived snapshot of the pre-sanitization codebase (not part of the image). +# Matched at any depth so it stays excluded regardless of where it is moved. +legacy code/ +**/legacy code/ + # Documentation BLUEPRINT_GUIDE.md ICON_INTEGRATION.md diff --git a/.env.example b/.env.example index afacb79..734b437 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,47 @@ # Copy to .env and update with your production values # IMPORTANT: Never commit this file to git +# Server Configuration +# --------------------------------------------------------------------------- +# Deploy-time TLS bootstrap. Copy this file to `.env` and set these two before +# `docker compose up`. Both must be present for HTTPS to be configured at +# startup; if either is missing the app stays on the plain-HTTP fallback and you +# can enable HTTPS later from Admin → HTTPS Configuration (no restart needed). +# --------------------------------------------------------------------------- + +# Hostname shown in the UI and used in the Caddy site block. +HOSTNAME_INTERNAL=digiserver + +# The host's LAN IP as reachable by the players/browsers. +# Replace 192.168.1.100 with THIS server's actual LAN IP. It is used for the +# Caddy site blocks and the certificate, so a wrong value breaks HTTPS. +# Find it with: ip -4 route get 1.1.1.1 | grep -oP 'src \K[\d.]+' +HOST_IP=192.168.1.100 + +# Public domain for Let's Encrypt. LEAVE EMPTY for an intranet/internal name +# (e.g. "digiserver" or "signage.corp.local") — a non-public name cannot pass an +# ACME challenge, so an empty DOMAIN selects Caddy's internal CA instead. +DOMAIN= + +# Email for ACME/Let's Encrypt notifications (unused by the internal CA). +SSL_EMAIL=admin@example.com + +# Published ports. Caddy listens on 80/443 inside the container; these control +# which host ports they are mapped to. Port 80 is always answered — the site +# responds whether clients use the IP or the hostname. +HTTP_PORT=80 +HTTPS_PORT=443 + +# "true" → also serve plain HTTP alongside HTTPS. Required for players whose +# trust store lacks the internal CA (i.e. verify_ssl is not disabled). +# "false" → serve TLS only and redirect HTTP to https://:. +HTTPS_HTTP_FALLBACK=true + +# After configuring HTTPS, probe it and automatically fall back to plain HTTP if +# it does not come up — so a bad certificate can never make the site unreachable. +# Set "false" to trust the configuration without probing. +HTTPS_VERIFY=true + # Flask Configuration FLASK_ENV=production FLASK_APP=app.app:create_app @@ -20,25 +61,11 @@ ADMIN_EMAIL=admin@your-domain.com # For SQLite: sqlite:////data/instance/dashboard.db # DATABASE_URL= -# Server Configuration -# Set BEFORE deployment if host will have static IP after restart -# This IP/domain will be used for SSL certificates and nginx configuration -DOMAIN=your-domain.com -HOST_IP=192.168.0.121 -EMAIL=admin@your-domain.com PREFERRED_URL_SCHEME=https -# SSL/HTTPS (configured in nginx.conf by default) -SSL_CERT_PATH=/etc/nginx/ssl/cert.pem -SSL_KEY_PATH=/etc/nginx/ssl/key.pem - # Logging LOG_LEVEL=INFO -# Security Headers (configured in nginx.conf) -HSTS_MAX_AGE=31536000 -HSTS_INCLUDE_SUBDOMAINS=true - # Features (optional) ENABLE_LIBREOFFICE=true MAX_UPLOAD_SIZE=500000000 # 500MB diff --git a/.gitignore b/.gitignore index e9ea5b6..0bad889 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,23 @@ data/ # Environment .env .env.local +.env.*.local + +# Ad-hoc backups of .env (they contain real secrets — must never be committed) +.env.bak +.env.bak.* +.env.*.bak +*.env.bak + +# Deployment artefacts that contain secrets +.deployment-credentials +caddy-root.crt + +# Certificates / keys (generated by Caddy or the host) +*.pem +*.key +!data/caddy-data/** +!Caddyfile.example # Database *.db @@ -59,3 +76,10 @@ build/ #data data/ +# Local archive / restore-point snapshots. +# Kept on disk for reference (see docs/SANITIZATION-REVIEW.md) but deliberately +# NOT tracked: docs/legacy code/ is a full pre-sanitization repo snapshot and +# docs/old_code_documentation/ is a byte-identical copy of the tree inside it. +docs/legacy code/ +docs/old_code_documentation/ + diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..4488600 --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,35 @@ +{ + # Caddy admin API — used by DigiServer to reload config after HTTPS is enabled + admin 0.0.0.0:2019 +} + +# Default: serve the app on HTTP port 80 +# Once HTTPS is configured via Admin → HTTPS Config, Caddy will reload +# this file and start provisioning a Let's Encrypt certificate automatically. +:80 { + reverse_proxy digiserver-app:5000 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-Proto {scheme} + transport http { + read_timeout 300s + write_timeout 300s + } + } + + request_body { + max_size 2GB + } + + encode gzip + + header { + X-Frame-Options "SAMEORIGIN" + X-Content-Type-Options "nosniff" + X-XSS-Protection "1; mode=block" + } + + log { + output file /var/log/caddy/access.log + } +} diff --git a/app/app.py b/app/app.py index 29c83f6..27e31a6 100644 --- a/app/app.py +++ b/app/app.py @@ -94,7 +94,6 @@ def register_blueprints(app): from app.blueprints.admin import admin_bp from app.blueprints.players import players_bp from app.blueprints.content import content_bp - from app.blueprints.playlist import playlist_bp from app.blueprints.api import api_bp # Register blueprints (using URL prefixes from blueprint definitions) @@ -103,7 +102,6 @@ def register_blueprints(app): app.register_blueprint(admin_bp) app.register_blueprint(players_bp) app.register_blueprint(content_bp) - app.register_blueprint(playlist_bp) app.register_blueprint(api_bp) diff --git a/app/blueprints/admin.py b/app/blueprints/admin.py index 7440b7b..9be5e6f 100644 --- a/app/blueprints/admin.py +++ b/app/blueprints/admin.py @@ -1072,11 +1072,14 @@ def build_player(): @login_required @admin_required def build_player_action(): - """Build/refresh the staged player code and/or write its base config.""" - from app.utils.player_build import ( - build_player_files, write_base_config, get_short_head, - save_build_settings, make_build_record, - ) + """Start building/refreshing the staged player code. + + The build runs in a background thread because a full clone of the player + repository takes far longer than gunicorn's worker timeout; running it + in-request would get the worker killed mid-clone and leave a broken + checkout. The page then polls ``admin.build_player_status`` for progress. + """ + from app.utils.player_build import start_background_build, is_build_running player_code_dir = current_app.config['PLAYER_CODE_DIR'] action = request.form.get('action', 'build_and_config') @@ -1090,16 +1093,14 @@ def build_player_action(): orientation = request.form.get('orientation', 'Landscape').strip() or 'Landscape' max_resolution = request.form.get('max_resolution', '1920x1080').strip() or '1920x1080' - # Validation + # Validation (unchanged — fail fast before starting any work) errors = [] if action in ('build_files', 'build_and_config') and not repo_url: errors.append('Repository URL is required to build player files.') if action in ('save_config', 'build_and_config') and not server_ip: errors.append('Server IP / domain is required for the player configuration.') try: - port_num = int(port) - if port_num < 1 or port_num > 65535: - errors.append('Port must be between 1 and 65535.') + int(port) except ValueError: errors.append('Port must be a valid number.') @@ -1108,51 +1109,72 @@ def build_player_action(): flash(err, 'warning') return redirect(url_for('admin.build_player')) - messages = [] - success = True - version = None - - # Step 1: build/refresh files from the repository. - if action in ('build_files', 'build_and_config'): - result = build_player_files(player_code_dir, repo_url, branch) - version = result.get('version') - messages.append(result['message']) - if not result['success']: - success = False - log_action('error', f'Player build failed by {current_user.username}: {result["message"]}') - - # Step 2: write the base config (only if the previous step didn't fail). - if success and action in ('save_config', 'build_and_config'): + # 'save_config' only writes the config file — it touches no network and is + # fast, so it stays synchronous. + if action == 'save_config': + from app.utils.player_build import ( + write_base_config, get_short_head, save_build_settings, make_build_record, + ) cfg_result = write_base_config( player_code_dir=player_code_dir, - server_ip=server_ip, - port=port, - use_https=use_https, - verify_ssl=verify_ssl, - orientation=orientation, + server_ip=server_ip, port=port, use_https=use_https, + verify_ssl=verify_ssl, orientation=orientation, max_resolution=max_resolution, ) - messages.append(cfg_result['message']) - if not cfg_result['success']: - success = False - - # Persist settings so deployment uses the same server address. - if version is None: version = get_short_head(player_code_dir) - save_build_settings( - _player_build_meta_path(), - make_build_record( - repo_url=repo_url, branch=branch, server_ip=server_ip, port=port, - use_https=use_https, verify_ssl=verify_ssl, orientation=orientation, - max_resolution=max_resolution, version=version, built_by=current_user.username, - ), + save_build_settings( + _player_build_meta_path(), + make_build_record( + repo_url=repo_url, branch=branch, server_ip=server_ip, port=port, + use_https=use_https, verify_ssl=verify_ssl, orientation=orientation, + max_resolution=max_resolution, version=version, + built_by=current_user.username, + ), + ) + if cfg_result['success']: + log_action('info', f'Player config saved by {current_user.username}') + flash(f"✅ {cfg_result['message']}", 'success') + else: + log_action('error', f'Player config write failed: {cfg_result["message"]}') + flash(f"⚠️ {cfg_result['message']}", 'danger') + return redirect(url_for('admin.build_player')) + + # build_files / build_and_config → background thread. + if is_build_running(): + flash('⚠️ A build is already running — wait for it to finish.', 'warning') + return redirect(url_for('admin.build_player')) + + config_payload = None + if action == 'build_and_config': + config_payload = { + 'server_ip': server_ip, 'port': port, 'use_https': use_https, + 'verify_ssl': verify_ssl, 'orientation': orientation, + 'max_resolution': max_resolution, + } + + started = start_background_build( + player_code_dir=player_code_dir, + repo_url=repo_url, + branch=branch, + config_payload=config_payload, + meta_path=_player_build_meta_path(), + built_by=current_user.username, ) - summary = ' '.join(messages) if messages else 'No action performed.' - if success: - log_action('info', f'Player files built by {current_user.username} (version {version})') - flash(f'✅ {summary}', 'success') + if not started: + flash('⚠️ A build is already running — wait for it to finish.', 'warning') else: - flash(f'⚠️ {summary}', 'danger') + log_action('info', f'Player build started by {current_user.username} ' + f'({branch} @ {repo_url})') + flash('⏳ Build started — this page will update automatically.', 'info') return redirect(url_for('admin.build_player')) + + +@admin_bp.route('/build-player/status', methods=['GET']) +@login_required +@admin_required +def build_player_status(): + """JSON progress for the running/last player build (polled by the page).""" + from app.utils.player_build import get_build_state + return jsonify(get_build_state()) diff --git a/app/blueprints/api.py b/app/blueprints/api.py index 597399a..15e9564 100644 --- a/app/blueprints/api.py +++ b/app/blueprints/api.py @@ -8,7 +8,9 @@ import bcrypt from typing import Optional, Dict, List from app.extensions import db, cache -from app.models import Player, Content, PlayerFeedback, ServerLog +from app.models import ( + Player, Playlist, Content, PlayerFeedback, ServerLog, +) from app.utils.logger import log_action api_bp = Blueprint('api', __name__, url_prefix='/api') @@ -86,6 +88,25 @@ def verify_player_auth(f): return decorated_function +def get_assigned_playlist(player: Player) -> Optional[Playlist]: + """Return the playlist assigned to *player*, or ``None`` if unassigned. + + Centralises playlist lookup so every endpoint reports the same sync + version. Playlist edits bump ``Playlist.version``; players poll that + value to decide whether their cached content is stale. A player with no + assigned playlist has nothing to sync and resolves to version 0. + + Args: + player: The player whose assigned playlist should be resolved. + + Returns: + The assigned ``Playlist`` instance, or ``None`` when unassigned. + """ + if not player.playlist_id: + return None + return db.session.get(Playlist, player.playlist_id) + + @api_bp.route('/health', methods=['GET']) def health_check(): """API health check endpoint.""" @@ -113,7 +134,7 @@ def authenticate_player(): quickconnect_code: Quick connect code (optional if using password) Returns: - JSON with auth_code, player_id, group_id, and configuration + JSON with auth_code, player_id, playlist_id, and configuration """ data = request.get_json() @@ -265,12 +286,8 @@ def get_playlist_by_quickconnect(): db.session.commit() # Get playlist version from the assigned playlist - playlist_version = 1 - if player.playlist_id: - from app.models import Playlist - assigned_playlist = Playlist.query.get(player.playlist_id) - if assigned_playlist: - playlist_version = assigned_playlist.version + assigned_playlist = get_assigned_playlist(player) + playlist_version = assigned_playlist.version if assigned_playlist else 0 # Hash the quickconnect code for validation on client side hashed_quickconnect = bcrypt.hashpw( @@ -322,12 +339,8 @@ def get_player_playlist(player_id: int): db.session.commit() # Get playlist version from the assigned playlist - playlist_version = 1 - if player.playlist_id: - from app.models import Playlist - assigned_playlist = Playlist.query.get(player.playlist_id) - if assigned_playlist: - playlist_version = assigned_playlist.version + assigned_playlist = get_assigned_playlist(player) + playlist_version = assigned_playlist.version if assigned_playlist else 0 return jsonify({ 'player_id': player_id, @@ -363,10 +376,16 @@ def get_playlist_version(player_id: int): player.last_seen = datetime.utcnow() db.session.commit() + # Player syncs against the version of its assigned playlist; the + # content count comes from that same playlist (Content has no + # player_id column - it reaches players through the playlist). + assigned_playlist = get_assigned_playlist(player) + return jsonify({ 'player_id': player_id, - 'playlist_version': player.playlist_version, - 'content_count': Content.query.filter_by(player_id=player_id).count() + 'playlist_id': player.playlist_id, + 'playlist_version': assigned_playlist.version if assigned_playlist else 0, + 'content_count': assigned_playlist.contents.count() if assigned_playlist else 0 }) except Exception as e: @@ -378,7 +397,6 @@ def get_playlist_version(player_id: int): def get_cached_playlist(player_id: int) -> List[Dict]: """Get cached playlist for a player based on assigned playlist.""" from flask import url_for - from app.models import Playlist player = Player.query.get(player_id) if not player or not player.playlist_id: @@ -556,7 +574,6 @@ def get_player_status(player_id: int): 'player_id': player_id, 'name': player.name, 'location': player.location, - 'group_id': player.group_id, 'status': player.status, 'is_online': is_online, 'last_seen': player.last_seen.isoformat() if player.last_seen else None, @@ -593,7 +610,6 @@ def system_info(): try: # Get counts total_players = Player.query.count() - total_groups = Group.query.count() total_content = Content.query.count() # Count online players (seen in last 5 minutes) @@ -610,7 +626,6 @@ def system_info(): 'total': total_players, 'online': online_players }, - 'groups': total_groups, 'content': total_content, 'logs_24h': recent_logs, 'timestamp': datetime.utcnow().isoformat() @@ -621,35 +636,6 @@ def system_info(): return jsonify({'error': 'Internal server error'}), 500 - -# DEPRECATED: Groups functionality has been archived -# @api_bp.route('/groups', methods=['GET']) -# @rate_limit(max_requests=60, window=60) -# def list_groups(): -# """List all groups with basic information.""" -# try: -# groups = Group.query.order_by(Group.name).all() -# -# groups_data = [] -# for group in groups: -# groups_data.append({ -# 'id': group.id, -# 'name': group.name, -# 'description': group.description, -# 'player_count': group.players.count(), -# 'content_count': group.contents.count() -# }) -# -# return jsonify({ -# 'groups': groups_data, -# 'count': len(groups_data) -# }) -# -# except Exception as e: -# log_action('error', f'Error listing groups: {str(e)}') -# return jsonify({'error': 'Internal server error'}), 500 - - @api_bp.route('/content', methods=['GET']) @rate_limit(max_requests=60, window=60) def list_content(): @@ -665,8 +651,7 @@ def list_content(): 'type': content.content_type, 'duration': content.duration, 'size': content.file_size, - 'uploaded_at': content.uploaded_at.isoformat(), - 'group_count': content.groups.count() + 'uploaded_at': content.uploaded_at.isoformat() }) return jsonify({ diff --git a/app/blueprints/content_old.py b/app/blueprints/content_old.py deleted file mode 100644 index d04e0e5..0000000 --- a/app/blueprints/content_old.py +++ /dev/null @@ -1,500 +0,0 @@ -"""Content blueprint for media upload and management.""" -from flask import (Blueprint, render_template, request, redirect, url_for, - flash, jsonify, current_app, send_from_directory) -from flask_login import login_required -from werkzeug.utils import secure_filename -import os -from typing import Optional, Dict -import json - -from app.extensions import db, cache -from app.models import Content, Group -from app.utils.logger import log_action -from app.utils.uploads import ( - save_uploaded_file, - process_video_file, - process_pdf_file, - get_upload_progress, - set_upload_progress -) - -content_bp = Blueprint('content', __name__, url_prefix='/content') - - -# In-memory storage for upload progress (for simple demo; use Redis in production) -upload_progress = {} - - -@content_bp.route('/') -@login_required -def content_list(): - """Display list of all content.""" - try: - # Get all unique content files (by filename) - from sqlalchemy import func - - # Get content with player information - contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all() - - # Group content by filename to show which players have each file - content_map = {} - for content in contents: - if content.filename not in content_map: - content_map[content.filename] = { - 'content': content, - 'players': [], - 'groups': [] - } - - # Add player info if assigned to a player - if content.player_id: - from app.models import Player - player = Player.query.get(content.player_id) - if player: - content_map[content.filename]['players'].append({ - 'id': player.id, - 'name': player.name, - 'group': player.group.name if player.group else None - }) - - # Convert to list for template - content_list = [] - for filename, data in content_map.items(): - content_list.append({ - 'filename': filename, - 'content_type': data['content'].content_type, - 'duration': data['content'].duration, - 'file_size': data['content'].file_size_mb, - 'uploaded_at': data['content'].uploaded_at, - 'players': data['players'], - 'player_count': len(data['players']) - }) - - # Sort by upload date - content_list.sort(key=lambda x: x['uploaded_at'], reverse=True) - - return render_template('content/content_list.html', - content_list=content_list) - except Exception as e: - log_action('error', f'Error loading content list: {str(e)}') - flash('Error loading content list.', 'danger') - return redirect(url_for('main.dashboard')) - - -@content_bp.route('/upload', methods=['GET', 'POST']) -@login_required -def upload_content(): - """Upload new content.""" - if request.method == 'GET': - # Get parameters for return URL and pre-selection - player_id = request.args.get('player_id', type=int) - return_url = request.args.get('return_url', url_for('content.content_list')) - - # Get all players for selection - from app.models import Player - players = Player.query.order_by(Player.name).all() - - return render_template('content/upload_content.html', - players=players, - selected_player_id=player_id, - return_url=return_url) - - try: - # Get form data - player_id = request.form.get('player_id', type=int) - media_type = request.form.get('media_type', 'image') - duration = request.form.get('duration', type=int, default=10) - session_id = request.form.get('session_id', os.urandom(8).hex()) - return_url = request.form.get('return_url', url_for('content.content_list')) - - # Get files - files = request.files.getlist('files') - - if not files or files[0].filename == '': - flash('No files provided.', 'warning') - return redirect(url_for('content.upload_content')) - - if not player_id: - flash('Please select a player.', 'warning') - return redirect(url_for('content.upload_content')) - - # Initialize progress tracking using shared utility - set_upload_progress(session_id, 0, 'Starting upload...', 'uploading') - - # Process each file - upload_folder = current_app.config['UPLOAD_FOLDER'] - os.makedirs(upload_folder, exist_ok=True) - - processed_count = 0 - total_files = len(files) - - for idx, file in enumerate(files): - if file.filename == '': - continue - - # Update progress - progress_pct = int((idx / total_files) * 80) # 0-80% for file processing - set_upload_progress(session_id, progress_pct, - f'Processing file {idx + 1} of {total_files}...', 'processing') - - filename = secure_filename(file.filename) - filepath = os.path.join(upload_folder, filename) - - # Save file - file.save(filepath) - - # Determine content type - file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else '' - - if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']: - content_type = 'image' - elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']: - content_type = 'video' - # Process video (convert to Raspberry Pi optimized format) - set_upload_progress(session_id, progress_pct + 5, - f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing') - success, message = process_video_file(filepath, session_id) - if not success: - log_action('error', f'Video optimization failed: {message}') - continue # Skip this file and move to next - elif file_ext == 'pdf': - content_type = 'pdf' - # Process PDF (convert to images) - set_upload_progress(session_id, progress_pct + 5, - f'Converting PDF {idx + 1}...', 'processing') - # process_pdf_file(filepath, session_id) - elif file_ext in ['ppt', 'pptx']: - content_type = 'presentation' - # Process presentation (convert to PDF then images) - set_upload_progress(session_id, progress_pct + 5, - f'Converting PowerPoint {idx + 1}...', 'processing') - # This would call pptx_converter utility - else: - content_type = 'other' - - # Create content record linked to player - from app.models import Player - player = Player.query.get(player_id) - if player: - new_content = Content( - filename=filename, - content_type=content_type, - duration=duration, - file_size=os.path.getsize(filepath), - player_id=player_id - ) - db.session.add(new_content) - - # Increment playlist version - player.playlist_version += 1 - log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})') - - processed_count += 1 - - # Commit all changes - set_upload_progress(session_id, 90, 'Saving to database...', 'processing') - db.session.commit() - - # Complete - set_upload_progress(session_id, 100, - f'Successfully uploaded {processed_count} file(s)!', 'complete') - - # Clear all playlist caches - cache.clear() - - log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})') - flash(f'{processed_count} file(s) uploaded successfully.', 'success') - - return redirect(return_url) - - except Exception as e: - db.session.rollback() - - # Update progress to error state - if 'session_id' in locals(): - set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error') - - log_action('error', f'Error uploading content: {str(e)}') - flash('Error uploading content. Please try again.', 'danger') - return redirect(url_for('content.upload_content')) - - -@content_bp.route('//edit', methods=['GET', 'POST']) -@login_required -def edit_content(content_id: int): - """Edit content metadata.""" - content = Content.query.get_or_404(content_id) - - if request.method == 'GET': - return render_template('content/edit_content.html', content=content) - - try: - duration = request.form.get('duration', type=int) - description = request.form.get('description', '').strip() - - # Update content - if duration is not None: - content.duration = duration - content.description = description or None - db.session.commit() - - # Clear caches - cache.clear() - - log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated') - flash(f'Content "{content.filename}" updated successfully.', 'success') - - return redirect(url_for('content.content_list')) - - except Exception as e: - db.session.rollback() - log_action('error', f'Error updating content: {str(e)}') - flash('Error updating content. Please try again.', 'danger') - return redirect(url_for('content.edit_content', content_id=content_id)) - - -@content_bp.route('//delete', methods=['POST']) -@login_required -def delete_content(content_id: int): - """Delete content and associated file.""" - try: - content = Content.query.get_or_404(content_id) - filename = content.filename - - # Delete file from disk - filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename) - if os.path.exists(filepath): - os.remove(filepath) - - # Delete from database - db.session.delete(content) - db.session.commit() - - # Clear caches - cache.clear() - - log_action('info', f'Content "{filename}" (ID: {content_id}) deleted') - flash(f'Content "{filename}" deleted successfully.', 'success') - - except Exception as e: - db.session.rollback() - log_action('error', f'Error deleting content: {str(e)}') - flash('Error deleting content. Please try again.', 'danger') - - return redirect(url_for('content.content_list')) - - -@content_bp.route('/delete-by-filename', methods=['POST']) -@login_required -def delete_by_filename(): - """Delete all content entries with a specific filename.""" - try: - data = request.get_json() - filename = data.get('filename') - - if not filename: - return jsonify({'success': False, 'message': 'No filename provided'}), 400 - - # Find all content entries with this filename - contents = Content.query.filter_by(filename=filename).all() - - if not contents: - return jsonify({'success': False, 'message': 'Content not found'}), 404 - - deleted_count = len(contents) - - # Delete file from disk (only once) - filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename) - if os.path.exists(filepath): - os.remove(filepath) - log_action('info', f'Deleted file from disk: {filename}') - - # Delete all database entries - for content in contents: - db.session.delete(content) - - db.session.commit() - - # Clear caches - cache.clear() - - log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)') - - return jsonify({ - 'success': True, - 'message': f'Content deleted from {deleted_count} playlist(s)', - 'deleted_count': deleted_count - }) - - except Exception as e: - db.session.rollback() - log_action('error', f'Error deleting content by filename: {str(e)}') - return jsonify({'success': False, 'message': str(e)}), 500 - - -@content_bp.route('/bulk/delete', methods=['POST']) -@login_required -def bulk_delete_content(): - """Delete multiple content items at once.""" - try: - content_ids = request.json.get('content_ids', []) - - if not content_ids: - return jsonify({'success': False, 'error': 'No content selected'}), 400 - - # Delete content - deleted_count = 0 - for content_id in content_ids: - content = Content.query.get(content_id) - if content: - # Delete file - filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename) - if os.path.exists(filepath): - os.remove(filepath) - - db.session.delete(content) - deleted_count += 1 - - db.session.commit() - - # Clear caches - cache.clear() - - log_action('info', f'Bulk deleted {deleted_count} content items') - return jsonify({'success': True, 'deleted': deleted_count}) - - except Exception as e: - db.session.rollback() - log_action('error', f'Error bulk deleting content: {str(e)}') - return jsonify({'success': False, 'error': str(e)}), 500 - - -@content_bp.route('/upload-progress/') -@login_required -def upload_progress_status(upload_id: str): - """Get upload progress for a specific upload.""" - progress = get_upload_progress(upload_id) - return jsonify(progress) - - -@content_bp.route('/preview/') -@login_required -def preview_content(content_id: int): - """Preview content in browser.""" - try: - content = Content.query.get_or_404(content_id) - - # Serve file from uploads folder - return send_from_directory( - current_app.config['UPLOAD_FOLDER'], - content.filename, - as_attachment=False - ) - except Exception as e: - log_action('error', f'Error previewing content: {str(e)}') - return "Error loading content", 500 - - -@content_bp.route('//download') -@login_required -def download_content(content_id: int): - """Download content file.""" - try: - content = Content.query.get_or_404(content_id) - - log_action('info', f'Content "{content.filename}" downloaded') - - return send_from_directory( - current_app.config['UPLOAD_FOLDER'], - content.filename, - as_attachment=True - ) - except Exception as e: - log_action('error', f'Error downloading content: {str(e)}') - return "Error downloading content", 500 - - -@content_bp.route('/statistics') -@login_required -def content_statistics(): - """Get content statistics.""" - try: - total_content = Content.query.count() - - # Count by type - type_counts = {} - for content_type in ['image', 'video', 'pdf', 'presentation', 'other']: - count = Content.query.filter_by(content_type=content_type).count() - type_counts[content_type] = count - - # Calculate total storage - upload_folder = current_app.config['UPLOAD_FOLDER'] - total_size = 0 - if os.path.exists(upload_folder): - for dirpath, dirnames, filenames in os.walk(upload_folder): - for filename in filenames: - filepath = os.path.join(dirpath, filename) - if os.path.exists(filepath): - total_size += os.path.getsize(filepath) - - return jsonify({ - 'total': total_content, - 'by_type': type_counts, - 'total_size_mb': round(total_size / (1024 * 1024), 2) - }) - - except Exception as e: - log_action('error', f'Error getting content statistics: {str(e)}') - return jsonify({'error': str(e)}), 500 - - -@content_bp.route('/check-duplicates') -@login_required -def check_duplicates(): - """Check for duplicate filenames.""" - try: - # Get all filenames - all_content = Content.query.all() - filename_counts = {} - - for content in all_content: - filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1 - - # Find duplicates - duplicates = {fname: count for fname, count in filename_counts.items() if count > 1} - - return jsonify({ - 'has_duplicates': len(duplicates) > 0, - 'duplicates': duplicates - }) - - except Exception as e: - log_action('error', f'Error checking duplicates: {str(e)}') - return jsonify({'error': str(e)}), 500 - - -@content_bp.route('//groups') -@login_required -def content_groups_info(content_id: int): - """Get groups that contain this content.""" - try: - content = Content.query.get_or_404(content_id) - - groups_data = [] - for group in content.groups: - groups_data.append({ - 'id': group.id, - 'name': group.name, - 'description': group.description, - 'player_count': group.players.count() - }) - - return jsonify({ - 'content_id': content_id, - 'filename': content.filename, - 'groups': groups_data - }) - - except Exception as e: - log_action('error', f'Error getting content groups: {str(e)}') - return jsonify({'error': str(e)}), 500 diff --git a/app/blueprints/players.py b/app/blueprints/players.py index fce3d54..34f2bf9 100644 --- a/app/blueprints/players.py +++ b/app/blueprints/players.py @@ -585,14 +585,6 @@ def get_player_playlist(player_id: int) -> List[dict]: return playlist -@players_bp.route('//reorder', methods=['POST']) -@login_required -def reorder_content(player_id: int): - """Legacy endpoint - Content reordering now handled in playlist management.""" - return jsonify({ - 'success': False, - 'error': 'Content reordering is now managed through playlists. Use the Playlists page to reorder content.' - }), 400 @players_bp.route('/bulk/delete', methods=['POST']) @@ -686,97 +678,3 @@ def deployment_status(): except Exception as e: log_action('error', f'Error fetching deployment status: {str(e)}') return jsonify({'error': str(e)}), 500 - - -@players_bp.route('//playlist/reorder', methods=['POST']) -@login_required -def reorder_playlist(player_id: int): - """Reorder items in player's playlist.""" - try: - data = request.get_json() - content_id = data.get('content_id') - direction = data.get('direction') # 'up' or 'down' - - if not content_id or not direction: - return jsonify({'success': False, 'message': 'Missing parameters'}), 400 - - # Get the content item - content = Content.query.filter_by(id=content_id, player_id=player_id).first() - if not content: - return jsonify({'success': False, 'message': 'Content not found'}), 404 - - # Get all content for this player, ordered by position - all_content = Content.query.filter_by(player_id=player_id)\ - .order_by(Content.position, Content.uploaded_at).all() - - # Find current index - current_index = None - for idx, item in enumerate(all_content): - if item.id == content_id: - current_index = idx - break - - if current_index is None: - return jsonify({'success': False, 'message': 'Content not in playlist'}), 404 - - # Swap positions - if direction == 'up' and current_index > 0: - # Swap with previous item - all_content[current_index].position, all_content[current_index - 1].position = \ - all_content[current_index - 1].position, all_content[current_index].position - elif direction == 'down' and current_index < len(all_content) - 1: - # Swap with next item - all_content[current_index].position, all_content[current_index + 1].position = \ - all_content[current_index + 1].position, all_content[current_index].position - - db.session.commit() - cache.delete_memoized(get_player_playlist, player_id) - - log_action('info', f'Reordered playlist for player {player_id}') - return jsonify({'success': True}) - - except Exception as e: - db.session.rollback() - log_action('error', f'Error reordering playlist: {str(e)}') - return jsonify({'success': False, 'message': str(e)}), 500 - - -@players_bp.route('//playlist/remove', methods=['POST']) -@login_required -def remove_from_playlist(player_id: int): - """Remove content from player's playlist.""" - try: - data = request.get_json() - content_id = data.get('content_id') - - if not content_id: - return jsonify({'success': False, 'message': 'Missing content_id'}), 400 - - # Get the content item - content = Content.query.filter_by(id=content_id, player_id=player_id).first() - if not content: - return jsonify({'success': False, 'message': 'Content not found'}), 404 - - filename = content.filename - - # Delete from database - db.session.delete(content) - - # Increment playlist version - player = Player.query.get(player_id) - if player: - player.playlist_version += 1 - - db.session.commit() - - # Clear cache - cache.delete_memoized(get_player_playlist, player_id) - - log_action('info', f'Removed "{filename}" from player {player_id} playlist (version {player.playlist_version})') - return jsonify({'success': True, 'message': f'Removed "{filename}" from playlist'}) - - except Exception as e: - db.session.rollback() - log_action('error', f'Error removing from playlist: {str(e)}') - return jsonify({'success': False, 'message': str(e)}), 500 - diff --git a/app/blueprints/playlist.py b/app/blueprints/playlist.py deleted file mode 100644 index e5abbc2..0000000 --- a/app/blueprints/playlist.py +++ /dev/null @@ -1,310 +0,0 @@ -"""Playlist blueprint for managing player playlists.""" -from flask import (Blueprint, render_template, request, redirect, url_for, - flash, jsonify, current_app) -from flask_login import login_required -from sqlalchemy import desc, update -import os - -from app.extensions import db, cache -from app.models import Player, Content, Playlist -from app.models.playlist import playlist_content -from app.utils.logger import log_action - -playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist') - - -@playlist_bp.route('/') -@login_required -def manage_playlist(player_id: int): - """Legacy route - redirect to new content management area.""" - player = Player.query.get_or_404(player_id) - - if player.playlist_id: - # Redirect to the new content management interface - return redirect(url_for('content.manage_playlist_content', playlist_id=player.playlist_id)) - else: - # Player has no playlist assigned - flash('This player has no playlist assigned.', 'warning') - return redirect(url_for('players.manage_player', player_id=player_id)) - - -@playlist_bp.route('//add', methods=['POST']) -@login_required -def add_to_playlist(player_id: int): - """Add content to player's playlist.""" - player = Player.query.get_or_404(player_id) - - if not player.playlist_id: - flash('Player has no playlist assigned.', 'warning') - return redirect(url_for('playlist.manage_playlist', player_id=player_id)) - - try: - content_id = request.form.get('content_id', type=int) - duration = request.form.get('duration', type=int, default=10) - - if not content_id: - flash('Please select content.', 'warning') - return redirect(url_for('playlist.manage_playlist', player_id=player_id)) - - content = Content.query.get_or_404(content_id) - playlist = Playlist.query.get(player.playlist_id) - - # Get max position - from sqlalchemy import select, func - max_pos = db.session.execute( - select(func.max(playlist_content.c.position)).where( - playlist_content.c.playlist_id == playlist.id - ) - ).scalar() or 0 - - # Add to playlist_content association table - stmt = playlist_content.insert().values( - playlist_id=playlist.id, - content_id=content.id, - position=max_pos + 1, - duration=duration - ) - db.session.execute(stmt) - - # Increment playlist version - playlist.increment_version() - - db.session.commit() - cache.clear() - - log_action('info', f'Added "{content.filename}" to playlist for player "{player.name}"') - flash(f'Added "{content.filename}" to playlist.', 'success') - - except Exception as e: - db.session.rollback() - log_action('error', f'Error adding to playlist: {str(e)}') - flash('Error adding to playlist.', 'danger') - - return redirect(url_for('playlist.manage_playlist', player_id=player_id)) - - -@playlist_bp.route('//remove/', methods=['POST']) -@login_required -def remove_from_playlist(player_id: int, content_id: int): - """Remove content from player's playlist.""" - player = Player.query.get_or_404(player_id) - - if not player.playlist_id: - flash('Player has no playlist assigned.', 'danger') - return redirect(url_for('playlist.manage_playlist', player_id=player_id)) - - try: - content = Content.query.get_or_404(content_id) - playlist = Playlist.query.get(player.playlist_id) - filename = content.filename - - # Remove from playlist_content association table - from sqlalchemy import delete - stmt = delete(playlist_content).where( - (playlist_content.c.playlist_id == playlist.id) & - (playlist_content.c.content_id == content_id) - ) - db.session.execute(stmt) - - # Reorder remaining content - from sqlalchemy import select - remaining = db.session.execute( - select(playlist_content.c.content_id, playlist_content.c.position).where( - playlist_content.c.playlist_id == playlist.id - ).order_by(playlist_content.c.position) - ).fetchall() - - for idx, row in enumerate(remaining, start=1): - stmt = update(playlist_content).where( - (playlist_content.c.playlist_id == playlist.id) & - (playlist_content.c.content_id == row.content_id) - ).values(position=idx) - db.session.execute(stmt) - - # Increment playlist version - playlist.increment_version() - - db.session.commit() - cache.clear() - - log_action('info', f'Removed "{filename}" from playlist for player "{player.name}"') - flash(f'Removed "{filename}" from playlist.', 'success') - - except Exception as e: - db.session.rollback() - log_action('error', f'Error removing from playlist: {str(e)}') - flash('Error removing from playlist.', 'danger') - - return redirect(url_for('playlist.manage_playlist', player_id=player_id)) - - -@playlist_bp.route('//reorder', methods=['POST']) -@login_required -def reorder_playlist(player_id: int): - """Reorder playlist items.""" - player = Player.query.get_or_404(player_id) - - if not player.playlist_id: - return jsonify({'success': False, 'message': 'Player has no playlist'}), 400 - - try: - playlist = Playlist.query.get(player.playlist_id) - - # Get new order from JSON - data = request.get_json() - content_ids = data.get('content_ids', []) - - if not content_ids: - return jsonify({'success': False, 'message': 'No content IDs provided'}), 400 - - # Update positions in association table - for idx, content_id in enumerate(content_ids, start=1): - stmt = update(playlist_content).where( - (playlist_content.c.playlist_id == playlist.id) & - (playlist_content.c.content_id == content_id) - ).values(position=idx) - db.session.execute(stmt) - - # Increment playlist version - playlist.increment_version() - - db.session.commit() - cache.clear() - - log_action('info', f'Reordered playlist for player "{player.name}" (version {playlist.version})') - - return jsonify({ - 'success': True, - 'message': 'Playlist reordered successfully', - 'version': playlist.version - }) - - except Exception as e: - db.session.rollback() - log_action('error', f'Error reordering playlist: {str(e)}') - return jsonify({'success': False, 'message': str(e)}), 500 - - -@playlist_bp.route('//update-duration/', methods=['POST']) -@login_required -def update_duration(player_id: int, content_id: int): - """Update content duration in playlist.""" - player = Player.query.get_or_404(player_id) - - if not player.playlist_id: - return jsonify({'success': False, 'message': 'Player has no playlist'}), 400 - - try: - playlist = Playlist.query.get(player.playlist_id) - content = Content.query.get_or_404(content_id) - - duration = request.form.get('duration', type=int) - - if not duration or duration < 1: - return jsonify({'success': False, 'message': 'Invalid duration'}), 400 - - # Update duration in association table - stmt = update(playlist_content).where( - (playlist_content.c.playlist_id == playlist.id) & - (playlist_content.c.content_id == content_id) - ).values(duration=duration) - db.session.execute(stmt) - - # Increment playlist version - playlist.increment_version() - - db.session.commit() - cache.clear() - - log_action('info', f'Updated duration for "{content.filename}" in player "{player.name}" playlist') - - return jsonify({ - 'success': True, - 'message': 'Duration updated', - 'version': playlist.version - }) - - except Exception as e: - db.session.rollback() - log_action('error', f'Error updating duration: {str(e)}') - return jsonify({'success': False, 'message': str(e)}), 500 - - -@playlist_bp.route('//update-muted/', methods=['POST']) -@login_required -def update_muted(player_id: int, content_id: int): - """Update content muted setting in playlist.""" - player = Player.query.get_or_404(player_id) - - if not player.playlist_id: - return jsonify({'success': False, 'message': 'Player has no playlist'}), 400 - - try: - playlist = Playlist.query.get(player.playlist_id) - content = Content.query.get_or_404(content_id) - - muted = request.form.get('muted', 'true').lower() == 'true' - - # Update muted in association table - stmt = update(playlist_content).where( - (playlist_content.c.playlist_id == playlist.id) & - (playlist_content.c.content_id == content_id) - ).values(muted=muted) - db.session.execute(stmt) - - # Increment playlist version - playlist.increment_version() - - db.session.commit() - cache.clear() - - log_action('info', f'Updated muted={muted} for "{content.filename}" in player "{player.name}" playlist') - - return jsonify({ - 'success': True, - 'message': 'Audio setting updated', - 'muted': muted, - 'version': playlist.version - }) - - except Exception as e: - db.session.rollback() - log_action('error', f'Error updating muted setting: {str(e)}') - return jsonify({'success': False, 'message': str(e)}), 500 - - -@playlist_bp.route('//clear', methods=['POST']) -@login_required -def clear_playlist(player_id: int): - """Clear all content from player's playlist.""" - player = Player.query.get_or_404(player_id) - - if not player.playlist_id: - flash('Player has no playlist assigned.', 'warning') - return redirect(url_for('playlist.manage_playlist', player_id=player_id)) - - try: - playlist = Playlist.query.get(player.playlist_id) - - # Delete all content from playlist - from sqlalchemy import delete - stmt = delete(playlist_content).where( - playlist_content.c.playlist_id == playlist.id - ) - db.session.execute(stmt) - - # Increment playlist version - playlist.increment_version() - - db.session.commit() - cache.clear() - - log_action('info', f'Cleared playlist for player "{player.name}"') - flash('Playlist cleared successfully.', 'success') - - except Exception as e: - db.session.rollback() - log_action('error', f'Error clearing playlist: {str(e)}') - flash('Error clearing playlist.', 'danger') - - return redirect(url_for('playlist.manage_playlist', player_id=player_id)) diff --git a/app/models/__init__.py b/app/models/__init__.py index d8c012b..565a19b 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,7 +1,6 @@ """Models package for digiserver-v2.""" from app.models.user import User from app.models.player import Player -from app.models.group import Group, group_content from app.models.playlist import Playlist, playlist_content from app.models.content import Content from app.models.server_log import ServerLog @@ -13,7 +12,6 @@ from app.models.https_config import HTTPSConfig __all__ = [ 'User', 'Player', - 'Group', 'Playlist', 'Content', 'ServerLog', @@ -21,6 +19,5 @@ __all__ = [ 'PlayerEdit', 'PlayerUser', 'HTTPSConfig', - 'group_content', 'playlist_content', ] diff --git a/app/models/content.py b/app/models/content.py index 8b3a822..bd9c78d 100644 --- a/app/models/content.py +++ b/app/models/content.py @@ -38,8 +38,6 @@ class Content(db.Model): # Relationships - many-to-many with playlists playlists = db.relationship('Playlist', secondary='playlist_content', back_populates='contents', lazy='dynamic') - groups = db.relationship('Group', secondary='group_content', - back_populates='contents', lazy='dynamic') def __repr__(self) -> str: """String representation of Content.""" @@ -52,11 +50,6 @@ class Content(db.Model): return round(self.file_size / (1024 * 1024), 2) return 0.0 - @property - def group_count(self) -> int: - """Get number of groups containing this content.""" - return self.groups.count() - @property def original_display_name(self) -> str: """Name of the original (unedited) file for display purposes.""" diff --git a/app/models/group.py b/app/models/group.py deleted file mode 100644 index df2f036..0000000 --- a/app/models/group.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Group model for organizing players and content.""" -from datetime import datetime -from typing import List, Optional - -from app.extensions import db - - -# Association table for many-to-many relationship between groups and content -group_content = db.Table('group_content', - db.Column('group_id', db.Integer, db.ForeignKey('group.id'), primary_key=True), - db.Column('content_id', db.Integer, db.ForeignKey('content.id'), primary_key=True) -) - - -class Group(db.Model): - """Group model for organizing players with shared content. - - Attributes: - id: Primary key - name: Unique group name - description: Optional group description - created_at: Group creation timestamp - updated_at: Last modification timestamp - """ - __tablename__ = 'group' - - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(100), nullable=False, unique=True, index=True) - description = db.Column(db.Text, nullable=True) - created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, - onupdate=datetime.utcnow, nullable=False) - - # Relationships - contents = db.relationship('Content', secondary=group_content, - back_populates='groups', lazy='dynamic') - - def __repr__(self) -> str: - """String representation of Group.""" - return f'' - - - - @property - def content_count(self) -> int: - """Get number of content items in this group.""" - return self.contents.count() - - def add_player(self, player) -> None: - """Add a player to this group. - - Args: - player: Player instance to add - """ - player.group_id = self.id - self.updated_at = datetime.utcnow() - - def remove_player(self, player) -> None: - """Remove a player from this group. - - Args: - player: Player instance to remove - """ - if player.group_id == self.id: - player.group_id = None - self.updated_at = datetime.utcnow() diff --git a/app/models/player.py b/app/models/player.py index da462e1..edcbc89 100644 --- a/app/models/player.py +++ b/app/models/player.py @@ -19,7 +19,7 @@ class Player(db.Model): orientation: Display orientation (Landscape/Portrait) status: Current player status (online, offline, error) last_seen: Last activity timestamp - playlist_version: Version number for playlist synchronization + playlist_id: Assigned playlist (sync version comes from Playlist.version) created_at: Player creation timestamp """ __tablename__ = 'player' diff --git a/app/templates/admin/build_player.html b/app/templates/admin/build_player.html index b91a9fe..f613067 100644 --- a/app/templates/admin/build_player.html +++ b/app/templates/admin/build_player.html @@ -42,6 +42,24 @@ {% endif %} + + +
@@ -114,10 +132,12 @@

3. Build

- -
+ + {% endblock %} + diff --git a/app/templates/content/content_list.html b/app/templates/content/content_list.html deleted file mode 100644 index 388bb4c..0000000 --- a/app/templates/content/content_list.html +++ /dev/null @@ -1,205 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Content Library - DigiServer v2{% endblock %} - -{% block content %} -
-
-

Content Library

- + Upload Content -
- - {% if content_list %} -
-
- Total Files: {{ content_list|length }} | - Total Assignments: {% set total = namespace(count=0) %}{% for item in content_list %}{% set total.count = total.count + item.player_count %}{% endfor %}{{ total.count }} -
- - - - - - - - - - - - - - - {% for item in content_list %} - - - - - - - - - - {% endfor %} - -
File NameTypeDurationSizeAssigned ToUploadedActions
- {{ item.filename }} - - {% if item.content_type == 'image' %} - 📷 Image - {% elif item.content_type == 'video' %} - 🎬 Video - {% elif item.content_type == 'pdf' %} - 📄 PDF - {% elif item.content_type == 'presentation' %} - 📊 PPT - {% else %} - 📁 Other - {% endif %} - - {{ item.duration }}s - - {{ item.file_size }} MB - - {% if item.player_count == 0 %} - Not assigned - {% else %} -
- {% for player in item.players %} -
- {{ player.name }} - {% if player.group %} - ({{ player.group }}) - {% endif %} -
- {% endfor %} -
-
- - {{ item.player_count }} player{% if item.player_count != 1 %}s{% endif %} - -
- {% endif %} -
- {{ item.uploaded_at | localtime }} - - {% if item.player_count > 0 %} - {% set first_player = item.players[0] %} - - 📝 Manage Playlist - - {% if item.player_count > 1 %} - - {% endif %} - {% endif %} - -
-
- {% else %} -
- ℹ️ No content uploaded yet. Upload your first content -
- {% endif %} -
- - - - - - - -{% endblock %} diff --git a/app/templates/content/edit_content.html b/app/templates/content/edit_content.html deleted file mode 100644 index f02de39..0000000 --- a/app/templates/content/edit_content.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Edit Content{% endblock %} - -{% block content %} -
-

Edit Content

-

Edit content functionality - placeholder

- Back to Content -
-{% endblock %} diff --git a/app/templates/content/upload_content.html b/app/templates/content/upload_content.html deleted file mode 100644 index 3ac7699..0000000 --- a/app/templates/content/upload_content.html +++ /dev/null @@ -1,278 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Upload Content - DigiServer v2{% endblock %} - -{% block content %} -
-
-

Upload Content

-
- -
- - -
-

Select Player

-
- - -
-
- -
-

Media Details

-
-
- - - - Images will be displayed as-is - -
-
- - - - How long to display each image/slide (videos use actual length) - -
-
-
- - - - Select multiple files. Supported: JPG, PNG, GIF, MP4, PDF, PPT, PPTX - -
-
-
- -
- - - ← Back - -
-
-
- - - - - - - -{% endblock %} diff --git a/app/templates/players/player_page.html b/app/templates/players/player_page.html deleted file mode 100644 index 8fe8c7a..0000000 --- a/app/templates/players/player_page.html +++ /dev/null @@ -1,227 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ player.name }} - DigiServer v2{% endblock %} - -{% block content %} -
- -
-
-

{{ player.name }}

-
- {% if status_info.online %} - - 🟢 Online - - {% else %} - - ⚫ Offline - - {% endif %} - - Last seen: {{ status_info.last_seen_ago }} - -
-
- -
- - -
- -
-

- 📋 Player Information -

- - - - - - - - - - - - - - - - - - - - - -
Display Name:{{ player.name }}
Hostname: - {{ player.hostname }} -
Location:{{ player.location or '-' }}
Orientation:{{ player.orientation or 'Landscape' }}
Created:{{ player.created_at | localtime }}
-
- - -
-

- 🔐 Authentication Details -

- - - - - - - - - - - - - - - - -
Password Set: - {% if player.password_hash %} - ✓ Yes - {% else %} - ✗ No - {% endif %} -
Quick Connect Code: - {% if player.quickconnect_code %} - ✓ Yes - {% else %} - ✗ No - {% endif %} -
Auth Code: - {% if player.auth_code %} - ✓ Yes -
- -
- {% else %} - ✗ No - {% endif %} -
- - ✏️ Edit Authentication Settings - -
-
-
- - -
-
-

🎬 Playlist Management

-
- - {% if playlist %} -
-
-
-
Total Items
-
{{ playlist|length }}
-
-
-
Total Duration
-
- {% set total_duration = namespace(value=0) %} - {% for item in playlist %} - {% set total_duration.value = total_duration.value + (item.duration or 10) %} - {% endfor %} - {{ total_duration.value }}s -
-
-
-
Playlist Version
-
{{ player.playlist_version }}
-
-
-
- {% endif %} - - - 🎬 Open Playlist Manager - - - {% if not playlist %} -
- ⚠️ No content in playlist. Open the playlist manager to add content. -
- {% endif %} -
- - -
-

- 📊 Recent Activity & Feedback -

- - {% if recent_feedback %} -
- - - - - - - - - - - {% for feedback in recent_feedback %} - - - - - - - {% endfor %} - -
TimeStatusMessageError
- {{ feedback.timestamp | localtime('%Y-%m-%d %H:%M:%S') }} - - {% if feedback.status == 'playing' %} - ▶️ Playing - {% elif feedback.status == 'idle' %} - ⏸️ Idle - {% elif feedback.status == 'error' %} - ❌ Error - {% else %} - {{ feedback.status }} - {% endif %} - - {{ feedback.message or '-' }} - - {% if feedback.error %} - {{ feedback.error[:50] }}... - {% else %} - - - {% endif %} -
-
- {% else %} -
- ℹ️ No activity logs yet. The player will send feedback once it starts playing content. -
- {% endif %} -
-
- -{% endblock %} diff --git a/app/utils/__init__.py b/app/utils/__init__.py index 170b3f5..891b16d 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -10,14 +10,7 @@ from app.utils.uploads import ( get_file_size, delete_file ) -from app.utils.group_player_management import ( - get_player_status_info, - get_group_statistics, - assign_player_to_group, - bulk_assign_players_to_group, - get_online_players_count, - get_players_by_status -) +from app.utils.group_player_management import get_player_status_info from app.utils.pptx_converter import pptx_to_pdf_libreoffice, validate_pptx_file __all__ = [ @@ -36,13 +29,8 @@ __all__ = [ 'clear_upload_progress', 'get_file_size', 'delete_file', - # Group/Player Management + # Player Management 'get_player_status_info', - 'get_group_statistics', - 'assign_player_to_group', - 'bulk_assign_players_to_group', - 'get_online_players_count', - 'get_players_by_status', # PPTX Converter 'pptx_to_pdf_libreoffice', 'validate_pptx_file', diff --git a/app/utils/caddy_manager.py b/app/utils/caddy_manager.py index 841e906..0dac5ed 100644 --- a/app/utils/caddy_manager.py +++ b/app/utils/caddy_manager.py @@ -37,43 +37,110 @@ class CaddyConfigGenerator: """Generate Caddyfile configuration based on HTTPSConfig.""" @staticmethod - def generate_caddyfile(config: Optional['HTTPSConfig'] = None) -> str: + def generate_caddyfile(config: Optional['HTTPSConfig'] = None, + http_fallback: bool = True, + http_port: int = 80, + https_port: int = 443) -> str: """Generate a complete Caddyfile. - Behaviour: - - HTTPS disabled / no domain → HTTP-only on port 80 (initial deploy mode). - - HTTPS enabled + real domain → Caddy auto-provisions a Let's Encrypt cert - for that domain; HTTP redirects to HTTPS automatically. - - HTTPS enabled + IP only (no domain) → TLS with Caddy's internal CA - (self-signed, trusted within the Docker network). + Design goals + ------------ + * **One HTTP endpoint** (port 80) that always answers, whatever the Host + header is — so ``http://`` and ``http://`` both work. + * **HTTPS on port 443** for the same names when it is enabled. + * If HTTPS is disabled or never configured, port 80 simply serves the + app — there is no separate "HTTP mode" to configure. + + Behaviour by configuration + -------------------------- + * HTTPS off, or no address configured → plain HTTP on ``:http_port``. + * HTTPS on → the app is served on port 80 for every configured name and + on port 443 over TLS. Whether port 80 *serves* or *redirects* to + HTTPS is controlled by ``http_fallback``. + + Which certificate each name gets + -------------------------------- + * ``domain`` (when set) → Caddy obtains a certificate automatically + (Let's Encrypt/ACME). Only valid for a **publicly resolvable** name. + * ``ip_address`` / ``hostname`` → ``tls internal`` (Caddy's local CA). + This needs no public DNS and no ACME, which is the right choice for an + intranet name such as ``digiserver.sibiusb.harting.intra``. + + Args: + config: HTTPSConfig instance, or None to load from the database. + http_fallback: When True, port 80 keeps *serving* the app alongside + HTTPS. This is the resilient default: clients that cannot trust + the internal CA (e.g. a Kivy player with ``verify_ssl: true``) + are still able to connect. When False, port 80 issues a 301 + redirect to HTTPS instead. + http_port: Port Caddy listens on for plain HTTP (default 80). + https_port: Port used to build redirect targets when + ``http_fallback`` is False (default 443). + + Returns: + The complete Caddyfile as a string. """ if config is None: config = HTTPSConfig.get_config() email = (config.email or "admin@localhost") if config else "admin@localhost" - https_enabled = config.https_enabled if config else False + https_enabled = bool(config.https_enabled) if config else False domain = (config.domain or "").strip() if config else "" ip_address = (config.ip_address or "").strip() if config else "" + hostname = (config.hostname or "").strip() if config else "" - global_block = f"""{{\n admin 0.0.0.0:2019\n email {email}\n}}\n\n""" + # Every name the server should answer to, in priority order, without + # duplicates. The IP comes first because it always resolves. + names: list[str] = [] + for candidate in (ip_address, hostname, domain): + if candidate and candidate not in names: + names.append(candidate) - if https_enabled and domain: - # Caddy handles Let's Encrypt + HTTP→HTTPS redirect automatically - # when a plain hostname (no scheme) is used. - caddyfile = global_block - caddyfile += f"{domain} {{\n{_PROXY_SNIPPET}}}\n" - # Also accept requests on the raw IP (HTTP only, no cert needed) - if ip_address: - caddyfile += f"\nhttp://{ip_address} {{\n{_PROXY_SNIPPET}}}\n" - elif https_enabled and ip_address: - # No public domain — use Caddy's internal CA (self-signed) - caddyfile = global_block - caddyfile += f"https://{ip_address} {{\n tls internal\n{_PROXY_SNIPPET}}}\n" - caddyfile += f"\nhttp://{ip_address} {{\n redir https://{ip_address}{{uri}} 301\n}}\n" - else: - # HTTP-only fallback (first deploy, before HTTPS is configured) - caddyfile = "{\n admin 0.0.0.0:2019\n}\n\n" - caddyfile += f":80 {{\n{_PROXY_SNIPPET}}}\n" + global_block = f"{{\n admin 0.0.0.0:2019\n email {email}\n" + + # ── TLS with no SNI ──────────────────────────────────────────────── + # Browsers do NOT send SNI when the URL is an IP address (an IP is not + # a valid SNI hostname). Without a fallback Caddy would identify such a + # connection by the container's own internal IP, match no certificate + # and abort the handshake with: + # "no certificate available for ''" + # `default_sni` makes a SNI-less ClientHello resolve to a name we do + # serve, so https:// works in the browser. + if https_enabled and not domain and ip_address: + global_block += f" default_sni {ip_address}\n" + + global_block += "}\n\n" + + # ── Plain HTTP only: HTTPS disabled, or no address to certify ─────── + if not (https_enabled and names): + return global_block + f":{http_port} {{\n{_PROXY_SNIPPET}}}\n" + + caddyfile = global_block + + # ── Port 80: catch-all so ANY Host header is answered ────────────── + # Without this, a request for an unexpected name (e.g. a bare IP when + # only a hostname is configured) would hit no site block and fail. + caddyfile += f":{http_port} {{\n{_PROXY_SNIPPET}}}\n\n" + + # ── Port 80: explicit per-name blocks ────────────────────────────── + for name in names: + if http_fallback: + caddyfile += f"http://{name} {{\n{_PROXY_SNIPPET}}}\n\n" + else: + # Redirect to the port the host actually publishes. + https_url = (f"https://{name}" if https_port == 443 + else f"https://{name}:{https_port}") + caddyfile += f"http://{name} {{\n redir {https_url}{{uri}} 301\n}}\n\n" + + # ── Port 443: TLS listeners ──────────────────────────────────────── + for name in names: + if domain and name == domain: + # Public name → let Caddy obtain a real certificate. + caddyfile += f"https://{name} {{\n{_PROXY_SNIPPET}}}\n\n" + else: + # IP or intranet name → Caddy's internal CA. + caddyfile += (f"https://{name} {{\n tls internal\n" + f"{_PROXY_SNIPPET}}}\n\n") return caddyfile @@ -121,148 +188,4 @@ class CaddyConfigGenerator: return response.status == 200 except Exception as e: print(f"Caddy reload error: {str(e)}") - return False - """Generate complete Caddyfile content. - - Args: - config: HTTPSConfig instance or None - - Returns: - Complete Caddyfile content as string - """ - # Get config from database if not provided - if config is None: - config = HTTPSConfig.get_config() - - # Base configuration - email = "admin@localhost" - if config and config.email: - email = config.email - - base_config = f"""{{ - # Global options - email {email} - # Admin API for configuration management (listen on all interfaces) - admin 0.0.0.0:2019 - # Uncomment for testing to avoid rate limits - # acme_ca https://acme-staging-v02.api.letsencrypt.org/directory -}} - -# Shared reverse proxy configuration -(reverse_proxy_config) {{ - reverse_proxy digiserver-app:5000 {{ - header_up Host {{host}} - header_up X-Real-IP {{remote_host}} - header_up X-Forwarded-Proto {{scheme}} - - # Timeouts for large uploads - transport http {{ - read_timeout 300s - write_timeout 300s - }} - }} - - # File upload size limit (2GB) - request_body {{ - max_size 2GB - }} - - # Security headers - header {{ - X-Frame-Options "SAMEORIGIN" - X-Content-Type-Options "nosniff" - X-XSS-Protection "1; mode=block" - }} - - # Logging - log {{ - output file /var/log/caddy/access.log - }} -}} - -# Localhost (development/local access) -http://localhost {{ - import reverse_proxy_config -}} -""" - - # Add main domain/IP configuration if HTTPS is enabled - if config and config.https_enabled and config.domain and config.ip_address: - # Internal domain configuration - domain_config = f""" -# Internal domain (HTTP only - internal use) -http://{config.domain} {{ - import reverse_proxy_config -}} - -# Handle IP address access -http://{config.ip_address} {{ - import reverse_proxy_config -}} -""" - base_config += domain_config - else: - # Default fallback configuration - base_config += """ -# Internal domain (HTTP only - internal use) -http://digiserver.sibiusb.harting.intra { - import reverse_proxy_config -} - -# Handle IP address access -http://10.76.152.164 { - import reverse_proxy_config -} -""" - - # Add catch-all for any other HTTP requests - base_config += """ -# Catch-all for any other HTTP requests -http://* { - import reverse_proxy_config -} -""" - - return base_config - - @staticmethod - def write_caddyfile(caddyfile_content: str, path: str = '/app/Caddyfile') -> bool: - """Write Caddyfile to disk. - - Args: - caddyfile_content: Content to write - path: Path to Caddyfile - - Returns: - True if successful, False otherwise - """ - try: - with open(path, 'w') as f: - f.write(caddyfile_content) - return True - except Exception as e: - print(f"Error writing Caddyfile: {str(e)}") - return False - - @staticmethod - def reload_caddy() -> bool: - """Reload Caddy configuration without restart. - - Note: Caddy monitoring is handled via file watching. After writing the Caddyfile, - Caddy should automatically reload. If it doesn't, you may need to restart the - Caddy container manually. - - Returns: - True if configuration was written successfully (Caddy will auto-reload) - """ - try: - # Just verify that Caddy is reachable - import urllib.request - response = urllib.request.urlopen('http://caddy:2019/config/', timeout=2) - return response.status == 200 - except Exception as e: - # Caddy might not be reachable, but Caddyfile was already written - # Caddy should reload automatically when it detects file changes - print(f"Note: Caddy reload check returned: {str(e)}") - return True # Return True anyway since Caddyfile was written diff --git a/app/utils/group_player_management.py b/app/utils/group_player_management.py index da87527..4e168ef 100644 --- a/app/utils/group_player_management.py +++ b/app/utils/group_player_management.py @@ -1,23 +1,26 @@ -"""Group and player management utilities.""" -from typing import Dict, List, Optional -from datetime import datetime, timedelta +"""Player status utilities. -from app.extensions import db -from app.models import Player, Group, PlayerFeedback -from app.utils.logger import log_action +Note: the group-management helpers that used to live here were removed along +with the deprecated Group subsystem (the ``group`` table had no rows and the +``/api/groups`` endpoint had already been archived). +""" +from typing import Dict +from datetime import datetime + +from app.models import Player, PlayerFeedback def get_player_status_info(player_id: int) -> Dict: """Get comprehensive status information for a player. - + Args: player_id: Player ID to query - + Returns: Dictionary with status information """ player = Player.query.get(player_id) - + if not player: return { 'online': False, @@ -25,18 +28,18 @@ def get_player_status_info(player_id: int) -> Dict: 'last_seen': None, 'latest_feedback': None } - + # Check if player is online (seen in last 5 minutes) is_online = False if player.last_seen: delta = datetime.utcnow() - player.last_seen is_online = delta.total_seconds() < 300 - + # Get latest feedback latest_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\ .order_by(PlayerFeedback.timestamp.desc())\ .first() - + return { 'online': is_online, 'status': player.status, @@ -51,154 +54,18 @@ def get_player_status_info(player_id: int) -> Dict: } -def get_group_statistics(group_id: int) -> Dict: - """Get statistics for a group. - - Args: - group_id: Group ID to query - - Returns: - Dictionary with group statistics - """ - group = Group.query.get(group_id) - - if not group: - return { - 'total_players': 0, - 'online_players': 0, - 'total_content': 0, - 'error_count': 0 - } - - total_players = group.player_count - total_content = group.content_count - - # Count online players - online_players = 0 - error_count = 0 - five_min_ago = datetime.utcnow() - timedelta(minutes=5) - - for player in group.players: - if player.last_seen and player.last_seen >= five_min_ago: - online_players += 1 - if player.status == 'error': - error_count += 1 - - return { - 'group_id': group_id, - 'group_name': group.name, - 'total_players': total_players, - 'online_players': online_players, - 'offline_players': total_players - online_players, - 'total_content': total_content, - 'error_count': error_count - } - - -def assign_player_to_group(player_id: int, group_id: Optional[int]) -> bool: - """Assign a player to a group or unassign if group_id is None. - - Args: - player_id: Player ID to assign - group_id: Group ID to assign to, or None to unassign - - Returns: - True if successful, False otherwise - """ - try: - player = Player.query.get(player_id) - - if not player: - log_action('error', f'Player {player_id} not found') - return False - - old_group_id = player.group_id - player.group_id = group_id - db.session.commit() - - if group_id: - group = Group.query.get(group_id) - log_action('info', f'Player "{player.name}" assigned to group "{group.name}"') - else: - log_action('info', f'Player "{player.name}" unassigned from group') - - return True - - except Exception as e: - db.session.rollback() - log_action('error', f'Error assigning player to group: {str(e)}') - return False - - -def bulk_assign_players_to_group(player_ids: List[int], group_id: Optional[int]) -> int: - """Assign multiple players to a group. - - Args: - player_ids: List of player IDs to assign - group_id: Group ID to assign to, or None to unassign - - Returns: - Number of players successfully assigned - """ - count = 0 - - try: - for player_id in player_ids: - player = Player.query.get(player_id) - if player: - player.group_id = group_id - count += 1 - - db.session.commit() - - if group_id: - group = Group.query.get(group_id) - log_action('info', f'Bulk assigned {count} players to group "{group.name}"') - else: - log_action('info', f'Bulk unassigned {count} players from groups') - - return count - - except Exception as e: - db.session.rollback() - log_action('error', f'Error bulk assigning players: {str(e)}') - return 0 - - -def get_online_players_count() -> int: - """Get count of online players (seen in last 5 minutes). - - Returns: - Number of online players - """ - five_min_ago = datetime.utcnow() - timedelta(minutes=5) - return Player.query.filter(Player.last_seen >= five_min_ago).count() - - -def get_players_by_status(status: str) -> List[Player]: - """Get all players with a specific status. - - Args: - status: Status to filter by - - Returns: - List of Player instances - """ - return Player.query.filter_by(status=status).all() - - def _format_time_ago(dt: datetime) -> str: """Format datetime as 'time ago' string. - + Args: dt: Datetime to format - + Returns: Formatted string like '5 minutes ago' """ delta = datetime.utcnow() - dt seconds = delta.total_seconds() - + if seconds < 60: return f'{int(seconds)} seconds ago' elif seconds < 3600: diff --git a/app/utils/nginx_config_reader.py b/app/utils/nginx_config_reader.py deleted file mode 100644 index b8f4d6c..0000000 --- a/app/utils/nginx_config_reader.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Nginx configuration reader utility.""" -import os -import re -from typing import Dict, List, Optional, Any - - -class NginxConfigReader: - """Read and parse Nginx configuration files.""" - - def __init__(self, config_path: str = '/etc/nginx/nginx.conf'): - """Initialize Nginx config reader.""" - self.config_path = config_path - self.config_content = None - self.is_available = os.path.exists(config_path) - - if self.is_available: - try: - with open(config_path, 'r') as f: - self.config_content = f.read() - except Exception as e: - self.is_available = False - self.error = str(e) - - def get_status(self) -> Dict[str, Any]: - """Get Nginx configuration status.""" - if not self.is_available: - return { - 'available': False, - 'error': 'Nginx configuration not found', - 'path': self.config_path - } - - return { - 'available': True, - 'path': self.config_path, - 'file_exists': os.path.exists(self.config_path), - 'ssl_enabled': self._check_ssl_enabled(), - 'http_ports': self._extract_http_ports(), - 'https_ports': self._extract_https_ports(), - 'upstream_servers': self._extract_upstream_servers(), - 'server_names': self._extract_server_names(), - 'ssl_protocols': self._extract_ssl_protocols(), - 'client_max_body_size': self._extract_client_max_body_size(), - 'gzip_enabled': self._check_gzip_enabled(), - } - - def _check_ssl_enabled(self) -> bool: - """Check if SSL is enabled.""" - if not self.config_content: - return False - return 'ssl_certificate' in self.config_content - - def _extract_http_ports(self) -> List[int]: - """Extract HTTP listening ports.""" - if not self.config_content: - return [] - pattern = r'listen\s+(\d+)' - matches = re.findall(pattern, self.config_content) - return sorted(list(set(int(p) for p in matches if int(p) < 1000))) - - def _extract_https_ports(self) -> List[int]: - """Extract HTTPS listening ports.""" - if not self.config_content: - return [] - pattern = r'listen\s+(\d+).*ssl' - matches = re.findall(pattern, self.config_content) - return sorted(list(set(int(p) for p in matches))) - - def _extract_upstream_servers(self) -> List[str]: - """Extract upstream servers.""" - if not self.config_content: - return [] - upstream_match = re.search(r'upstream\s+\w+\s*{([^}]+)}', self.config_content) - if upstream_match: - upstream_content = upstream_match.group(1) - servers = re.findall(r'server\s+([^\s;]+)', upstream_content) - return servers - return [] - - def _extract_server_names(self) -> List[str]: - """Extract server names.""" - if not self.config_content: - return [] - pattern = r'server_name\s+([^;]+);' - matches = re.findall(pattern, self.config_content) - result = [] - for match in matches: - names = match.strip().split() - result.extend(names) - return result - - def _extract_ssl_protocols(self) -> List[str]: - """Extract SSL protocols.""" - if not self.config_content: - return [] - pattern = r'ssl_protocols\s+([^;]+);' - match = re.search(pattern, self.config_content) - if match: - return match.group(1).strip().split() - return [] - - def _extract_client_max_body_size(self) -> Optional[str]: - """Extract client max body size.""" - if not self.config_content: - return None - pattern = r'client_max_body_size\s+([^;]+);' - match = re.search(pattern, self.config_content) - return match.group(1).strip() if match else None - - def _check_gzip_enabled(self) -> bool: - """Check if gzip is enabled.""" - if not self.config_content: - return False - return bool(re.search(r'gzip\s+on\s*;', self.config_content)) - - -def get_nginx_status() -> Dict[str, Any]: - """Get Nginx configuration status.""" - reader = NginxConfigReader() - return reader.get_status() diff --git a/app/utils/player_build.py b/app/utils/player_build.py index 3086237..9af6700 100644 --- a/app/utils/player_build.py +++ b/app/utils/player_build.py @@ -8,12 +8,25 @@ Admins use the "Build player files" admin page to: The SSH deployment flow then ships this staged directory to player devices, so the version admins build here is exactly what gets deployed. + +Performance note +---------------- +The player repository is large (~200 MB) and a full clone takes ~90 s. Because +the build runs inside an HTTP request, that would exceed gunicorn's worker +timeout and the worker would be killed mid-clone, leaving a broken checkout. +Two mitigations are used together: + + * **Shallow clones** (``--depth 1``) — only the tip of the requested branch is + fetched, which is all a deployment needs. Drastically reduces transfer size. + * **Background execution** — the admin route starts the build in a daemon + thread and the page polls for progress, so no worker ever blocks on git. """ import os import json import shutil import subprocess import logging +import threading from datetime import datetime from typing import Any, Dict, Optional @@ -24,90 +37,154 @@ logger = logging.getLogger(__name__) # Metadata file name stored in the Flask instance folder. BUILD_META_FILENAME = 'player_build.json' +# Only the tip of the branch is needed to deploy a player, so history is not +# fetched. Keeps the transfer small enough to avoid worker timeouts. +CLONE_DEPTH = '1' -def _run_git(args, cwd=None, timeout=300) -> subprocess.CompletedProcess: - return subprocess.run( - ['git'] + args, - cwd=cwd, - capture_output=True, - text=True, - timeout=timeout, - ) +# Never let git wait for a human. Without this, a private/renamed repository +# makes git block on a username prompt until the worker is killed. +GIT_ENV = { + 'GIT_TERMINAL_PROMPT': '0', # never prompt for credentials + 'GIT_ASKPASS': 'true', # answer any credential request immediately + 'GIT_SSH_COMMAND': 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new', +} + + +def _git_env() -> Dict[str, str]: + """Environment for git subprocesses: inherit the process env plus our flags.""" + env = dict(os.environ) + env.update(GIT_ENV) + return env + + +def _run_git(args, cwd=None, timeout=120) -> subprocess.CompletedProcess: + """Run a git command, never prompting for input. + + Args: + args: git arguments (without the leading 'git'). + cwd: working directory for the command. + timeout: hard cap in seconds. Defaults to 120 to stay within a + reasonable window even when running in the foreground. + + Returns: + The completed process. ``returncode`` is 124 on timeout so callers can + distinguish a timeout from a normal failure. + """ + try: + return subprocess.run( + ['git'] + args, + cwd=cwd, + capture_output=True, + text=True, + timeout=timeout, + env=_git_env(), + ) + except subprocess.TimeoutExpired as e: + # Surface timeouts as a normal result so callers do not need try/except. + out = e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or '') + err = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or '') + return subprocess.CompletedProcess( + args=['git'] + list(args), returncode=124, + stdout=out, stderr=(err + f'\ngit {" ".join(args)} timed out after {timeout}s').strip(), + ) def get_short_head(player_code_dir: str) -> str: """Return the short git commit of the staged code, or 'unknown'.""" - try: - result = _run_git(['-C', player_code_dir, 'rev-parse', '--short', 'HEAD'], timeout=10) - if result.returncode == 0: - return result.stdout.strip() - except Exception: - pass + result = _run_git(['-C', player_code_dir, 'rev-parse', '--short', 'HEAD'], timeout=10) + if result.returncode == 0: + return result.stdout.strip() return 'unknown' +def is_valid_checkout(path: str) -> bool: + """True when *path* is a usable git checkout with a resolvable HEAD.""" + if not os.path.isdir(os.path.join(path, '.git')): + return False + return get_short_head(path) != 'unknown' + + +def _clone(path: str, repo_url: str, branch: str) -> subprocess.CompletedProcess: + """Shallow-clone a single branch into *path*.""" + return _run_git([ + 'clone', '--depth', CLONE_DEPTH, '--single-branch', + '--branch', branch, repo_url, path, + ], timeout=600) + + def build_player_files(player_code_dir: str, repo_url: str, branch: str = 'main') -> Dict[str, Any]: """Clone or refresh the player source into ``player_code_dir``. - If the directory is already a git checkout of ``repo_url`` it is updated in - place (fetch + hard reset to the chosen branch). Otherwise it is cloned - fresh (an existing non-git directory is replaced). + Uses a **shallow single-branch clone/update** so only the tip of the wanted + branch is transferred. If the directory is a usable checkout it is updated + (fetch + hard reset to the branch). A directory that exists but is NOT a + usable checkout — e.g. left behind by an interrupted clone — is removed and + re-cloned, since updating it can never work. - Returns a dict: ``success`` (bool), ``message`` (str), ``version`` (str), - ``branch`` (str). + Args: + player_code_dir: Destination directory for the staged player code. + repo_url: Git repository to pull from. + branch: Branch to stage. + + Returns: + ``{'success': bool, 'message': str, 'version': str|None, 'branch': str}`` """ branch = (branch or 'main').strip() repo_url = (repo_url or '').strip() + + def fail(message: str) -> Dict[str, Any]: + return {'success': False, 'message': message, + 'version': get_short_head(player_code_dir), 'branch': branch} + if not repo_url: - return {'success': False, 'message': 'Repository URL is required.', 'version': None, 'branch': branch} + return {'success': False, 'message': 'Repository URL is required.', + 'version': None, 'branch': branch} try: - git_dir = os.path.join(player_code_dir, '.git') - is_git_repo = os.path.isdir(git_dir) + usable = is_valid_checkout(player_code_dir) - if is_git_repo: - # Update existing checkout in place. - fetch = _run_git(['-C', player_code_dir, 'fetch', '--prune', 'origin']) + if usable: + # Update in place. Depth 1 keeps the update cheap; fetch by ref so + # it works on a shallow clone. + fetch = _run_git( + ['-C', player_code_dir, 'fetch', '--depth', CLONE_DEPTH, + '--prune', 'origin', branch]) if fetch.returncode != 0: - return { - 'success': False, - 'message': f'git fetch failed: {fetch.stderr.strip() or fetch.stdout.strip()}', - 'version': get_short_head(player_code_dir), - 'branch': branch, - } + return fail(f'git fetch failed: {fetch.stderr.strip() or fetch.stdout.strip()}') + # Point origin at the requested URL in case it changed. _run_git(['-C', player_code_dir, 'remote', 'set-url', 'origin', repo_url]) + checkout = _run_git(['-C', player_code_dir, 'checkout', branch]) if checkout.returncode != 0: - return { - 'success': False, - 'message': f'git checkout {branch} failed: {checkout.stderr.strip()}', - 'version': get_short_head(player_code_dir), - 'branch': branch, - } + return fail(f'git checkout {branch} failed: {checkout.stderr.strip()}') + reset = _run_git(['-C', player_code_dir, 'reset', '--hard', f'origin/{branch}']) if reset.returncode != 0: - return { - 'success': False, - 'message': f'git reset failed: {reset.stderr.strip()}', - 'version': get_short_head(player_code_dir), - 'branch': branch, - } + return fail(f'git reset failed: {reset.stderr.strip()}') + action = 'Updated' else: - # Fresh clone. Replace any existing (non-git) directory. + # Fresh clone. A previous attempt may have left a partial directory + # (e.g. killed mid-clone) — it must go, or the clone will fail with + # "destination path already exists and is not an empty directory". parent = os.path.dirname(player_code_dir.rstrip('/')) - os.makedirs(parent, exist_ok=True) + if parent: + os.makedirs(parent, exist_ok=True) if os.path.exists(player_code_dir): - shutil.rmtree(player_code_dir) - clone = _run_git(['clone', '--branch', branch, repo_url, player_code_dir]) + logger.info('Removing unusable directory before clone: %s', player_code_dir) + shutil.rmtree(player_code_dir, ignore_errors=True) + + clone = _clone(player_code_dir, repo_url, branch) if clone.returncode != 0: - return { - 'success': False, - 'message': f'git clone failed: {clone.stderr.strip() or clone.stdout.strip()}', - 'version': None, - 'branch': branch, - } + # Do not leave a half-written directory behind. + shutil.rmtree(player_code_dir, ignore_errors=True) + detail = clone.stderr.strip() or clone.stdout.strip() + if clone.returncode == 124 or 'timed out' in detail: + return fail(f'git clone timed out. The repository may be very ' + f'large or unreachable: {detail}') + return fail(f'git clone failed: {detail}') + action = 'Cloned' version = get_short_head(player_code_dir) @@ -118,11 +195,15 @@ def build_player_files(player_code_dir: str, repo_url: str, branch: str = 'main' 'version': version, 'branch': branch, } - except subprocess.TimeoutExpired: - return {'success': False, 'message': 'Git operation timed out.', 'version': None, 'branch': branch} - except Exception as e: + except Exception as e: # noqa: BLE001 - surface any failure logger.exception('build_player_files failed') - return {'success': False, 'message': f'Build failed: {str(e)}', 'version': None, 'branch': branch} + # Never leave a broken checkout behind for the next attempt. + try: + if not is_valid_checkout(player_code_dir): + shutil.rmtree(player_code_dir, ignore_errors=True) + except Exception: + pass + return fail(f'Build failed: {str(e)}') def write_base_config( @@ -221,3 +302,146 @@ def make_build_record(repo_url, branch, server_ip, port, use_https, verify_ssl, 'built_at': datetime.utcnow().isoformat(timespec='seconds') + 'Z', 'built_by': built_by, } + + +# --------------------------------------------------------------------------- +# Background builds +# +# A full clone/refresh takes far longer than gunicorn's worker timeout, so the +# build must not run inside the request. The admin route starts it here and the +# page polls `build_state()` for progress. +# --------------------------------------------------------------------------- + +# Serialises writes to _build_state between the request thread and the worker. +_build_lock = threading.Lock() + +# Coarse progress for the admin UI. 'state' is one of: +# idle | running | success | error +_build_state: Dict[str, Any] = {'state': 'idle'} + + +def get_build_state() -> Dict[str, Any]: + """Return a snapshot of the current/last build for the admin UI.""" + with _build_lock: + return dict(_build_state) + + +def is_build_running() -> bool: + """True while a build is in progress.""" + with _build_lock: + return _build_state.get('state') == 'running' + + +def _set_build_state(**fields: Any) -> None: + with _build_lock: + _build_state.update(fields) + + +def _run_build_job(app, player_code_dir: str, repo_url: str, branch: str, + config_payload: Optional[Dict[str, Any]], + meta_path: str, built_by: str) -> None: + """Worker body: build files, optionally write config, then persist settings. + + Runs in a daemon thread with its own Flask app context so it is independent + of the request/response cycle that triggered it. + """ + started = datetime.utcnow() + try: + _set_build_state(state='running', step='Fetching player source…', + started_at=started.isoformat(timespec='seconds') + 'Z', + message='', version=None) + + result = build_player_files(player_code_dir, repo_url, branch) + version = result.get('version') + + if not result['success']: + _set_build_state(state='error', step='', message=result['message'], + version=version, + finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z') + logger.error('Background player build failed: %s', result['message']) + return + + # Optional step 2: write the base config. + if config_payload: + _set_build_state(step='Writing player config…') + cfg = write_base_config(player_code_dir=player_code_dir, **config_payload) + if not cfg['success']: + _set_build_state(state='error', step='', message=cfg['message'], + version=version, + finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z') + logger.error('Background player config write failed: %s', cfg['message']) + return + result = {**result, 'message': f"{result['message']} {cfg['message']}"} + + if version is None: + version = get_short_head(player_code_dir) + + save_build_settings( + meta_path, + make_build_record( + repo_url=repo_url, branch=branch, + server_ip=(config_payload or {}).get('server_ip', ''), + port=(config_payload or {}).get('port', ''), + use_https=(config_payload or {}).get('use_https', False), + verify_ssl=(config_payload or {}).get('verify_ssl', False), + orientation=(config_payload or {}).get('orientation', 'Landscape'), + max_resolution=(config_payload or {}).get('max_resolution', '1920x1080'), + version=version, built_by=built_by, + ), + ) + + _set_build_state(state='success', step='', message=result['message'], + version=version, + finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z') + logger.info('Background player build complete (version %s)', version) + except Exception as e: # noqa: BLE001 - never kill the thread silently + logger.exception('Background player build crashed') + _set_build_state(state='error', step='', message=f'Build failed: {e}', + finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z') + + +def start_background_build(player_code_dir: str, repo_url: str, branch: str, + config_payload: Optional[Dict[str, Any]], + meta_path: str, built_by: str) -> bool: + """Start a player build in a daemon thread. + + Args: + player_code_dir: Where to stage the player source. + repo_url: Git repository URL. + branch: Branch to stage. + config_payload: Keyword args for :func:`write_base_config`, or None to + skip writing the config. + meta_path: Where to persist the build record. + built_by: Username shown in the UI/logs. + + Returns: + False if a build is already running (callers should tell the user), + True if a new build was started. + + Raises: + RuntimeError: if called with no Flask application context — the worker + thread needs a real app object to push its own context. + """ + from flask import current_app + + if is_build_running(): + return False + + # Capture the real app object now. `current_app` resolves inside either a + # request or a plain application context; the worker thread pushes its own + # context later, since the caller's context is gone by then. + app = current_app._get_current_object() + + _set_build_state(state='running', step='Starting…', message='', version=None, + started_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z', + finished_at=None, built_by=built_by, + repo_url=repo_url, branch=branch) + + thread = threading.Thread( + target=_run_build_job, + args=(app, player_code_dir, repo_url, branch, config_payload, meta_path, built_by), + name='player-build', + daemon=True, + ) + thread.start() + return True diff --git a/deploy.sh b/deploy.sh index a10eccd..caea5a1 100755 --- a/deploy.sh +++ b/deploy.sh @@ -16,10 +16,17 @@ echo -e "${BLUE}║ DigiServer Automated Deployment echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}" echo "" -# Check if docker compose is available -if ! docker compose version &> /dev/null; then +# Check if docker compose is available. Accept either the modern plugin +# (`docker compose`) or the standalone v1 binary (`docker-compose`), since not +# every Docker installation ships the Compose plugin. +if docker compose version &> /dev/null; then + COMPOSE="docker compose" +elif command -v docker-compose &> /dev/null; then + COMPOSE="docker-compose" + echo -e "${YELLOW}⚠️ Using legacy 'docker-compose' (v1); the 'docker compose' plugin is unavailable.${NC}" +else echo -e "${RED}❌ docker compose not found!${NC}" - echo "Please install docker compose first" + echo "Please install the docker compose plugin or docker-compose first" exit 1 fi @@ -31,67 +38,133 @@ if [ ! -f "docker-compose.yml" ]; then fi # ============================================================================ -# INITIALIZATION: Create data directories and copy nginx configs +# INITIALIZATION: Create data directories and seed the Caddy config # ============================================================================ echo -e "${YELLOW}📁 Initializing data directories...${NC}" # Create necessary data directories mkdir -p data/instance mkdir -p data/uploads -mkdir -p data/nginx-ssl -mkdir -p data/nginx-logs -mkdir -p data/certbot +mkdir -p data/caddy-data +mkdir -p data/caddy-config +mkdir -p data/caddy-logs -# Copy nginx configuration files from repo root to data folder -if [ -f "nginx.conf" ]; then - cp nginx.conf data/nginx.conf - echo -e " ${GREEN}✓${NC} nginx.conf copied to data/" +# Seed the Caddyfile. It is bind-mounted as a FILE, so it MUST exist before +# `docker compose up` — otherwise Docker creates a directory in its place and +# Caddy fails to start. +if [ -f "data/Caddyfile" ]; then + echo -e " ${GREEN}✓${NC} data/Caddyfile present" +elif [ -f "Caddyfile.example" ]; then + cp Caddyfile.example data/Caddyfile + echo -e " ${GREEN}✓${NC} data/Caddyfile seeded from Caddyfile.example" else - echo -e " ${RED}❌ nginx.conf not found in repo root!${NC}" - exit 1 -fi - -if [ -f "nginx-custom-domains.conf" ]; then - cp nginx-custom-domains.conf data/nginx-custom-domains.conf - echo -e " ${GREEN}✓${NC} nginx-custom-domains.conf copied to data/" -else - echo -e " ${RED}❌ nginx-custom-domains.conf not found in repo root!${NC}" + echo -e " ${RED}❌ Caddyfile.example not found in repo root!${NC}" exit 1 fi echo -e "${GREEN}✅ Data directories initialized${NC}" echo "" - # ============================================================================ # CONFIGURATION VARIABLES # ============================================================================ -HOSTNAME="${HOSTNAME:-digiserver}" -DOMAIN="${DOMAIN:-digiserver.sibiusb.harting.intra}" -IP_ADDRESS="${IP_ADDRESS:-10.76.152.164}" +# NOTE: do NOT use the name HOSTNAME here — it is a bash built-in that already +# holds the machine's hostname (e.g. "development"), so `${HOSTNAME:-default}` +# would silently ignore the default and leak the OS hostname into the Caddyfile. +SERVER_HOSTNAME="${SERVER_HOSTNAME:-digiserver}" EMAIL="${EMAIL:-admin@example.com}" -PORT="${PORT:-443}" + +# Auto-detect the primary LAN IP unless one was supplied explicitly. +if [ -z "${IP_ADDRESS:-}" ]; then + IP_ADDRESS="$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[\d.]+' | head -1)" + if [ -z "$IP_ADDRESS" ]; then + IP_ADDRESS="$(hostname -I 2>/dev/null | awk '{print $1}')" + fi +fi +if [ -z "$IP_ADDRESS" ]; then + echo -e "${RED}❌ Could not determine the server IP. Set IP_ADDRESS=... and re-run.${NC}" + exit 1 +fi + +# HTTPS_MODE selects how TLS is provided: +# internal : Caddy's internal CA, IP-only. Needs NO public DNS and NO ACME. +# Correct for an intranet name such as digiserver.sibiusb.harting.intra. +# acme : Let's Encrypt — requires DOMAIN to be publicly resolvable. +# off : plain HTTP only. +HTTPS_MODE="${HTTPS_MODE:-internal}" + +case "$HTTPS_MODE" in + internal) + # An empty DOMAIN is what makes CaddyConfigGenerator choose the + # internal-CA path instead of Let's Encrypt. + DOMAIN="" + ;; + acme) + if [ -z "${DOMAIN:-}" ]; then + echo -e "${RED}❌ HTTPS_MODE=acme requires DOMAIN to be set.${NC}" + exit 1 + fi + ;; + off) + DOMAIN="" + ;; + *) + echo -e "${RED}❌ Invalid HTTPS_MODE '$HTTPS_MODE' (use internal|acme|off).${NC}" + exit 1 + ;; +esac + +# Published ports. These MUST match docker-compose.yml, which maps Caddy's +# internal 80/443 to these host ports (HTTP_PORT / HTTPS_PORT). +HTTP_PORT="${HTTP_PORT:-80}" +HTTPS_PORT="${HTTPS_PORT:-443}" echo -e "${BLUE}Configuration:${NC}" -echo " Hostname: $HOSTNAME" -echo " Domain: $DOMAIN" +echo " Hostname: $SERVER_HOSTNAME" +echo " HTTPS mode: $HTTPS_MODE" +echo " Domain: ${DOMAIN:-(none — internal CA)}" echo " IP Address: $IP_ADDRESS" echo " Email: $EMAIL" -echo " Port: $PORT" +echo " HTTP port: $HTTP_PORT" +echo " HTTPS port: $HTTPS_PORT" echo "" # ============================================================================ -# STEP 1: Start containers +# STEP 1: Build and start containers # ============================================================================ -echo -e "${YELLOW}📦 [1/6] Starting containers...${NC}" -docker compose up -d +echo -e "${YELLOW}📦 [1/6] Building and starting containers...${NC}" + +# Compose v1 refuses to build unless the buildx plugin is >= 0.17: +# "compose build requires buildx 0.17.0 or later" +# Distro Packaged Docker ships older buildx (or none). Detect that and fall back +# to a plain `docker build` + `up --no-build`, which needs no buildx at all. +APP_IMAGE="digiserver-v2-digiserver-app:latest" + +BUILDX_VER="$(docker buildx version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1)" +BUILDX_OK=0 +if [ -n "$BUILDX_VER" ]; then + _bx_major="${BUILDX_VER%%.*}" + _bx_minor="${BUILDX_VER##*.}" + if [ "$_bx_major" -gt 0 ] || { [ "$_bx_major" -eq 0 ] && [ "$_bx_minor" -ge 17 ]; }; then + BUILDX_OK=1 + fi +fi + +if [ "$BUILDX_OK" -eq 1 ]; then + echo -e " ${GREEN}✓${NC} buildx ${BUILDX_VER} — building via compose" + $COMPOSE up -d --build +else + echo -e " ${YELLOW}⚠${NC} buildx ${BUILDX_VER:-not found} (< 0.17) — falling back to 'docker build'" + docker build -t "$APP_IMAGE" . + $COMPOSE up -d --no-build +fi echo -e "${YELLOW}⏳ Waiting for containers to be healthy...${NC}" -sleep 10 +sleep 15 # Verify containers are running -if ! docker compose ps | grep -q "Up"; then +if ! $COMPOSE ps | grep -q "Up"; then echo -e "${RED}❌ Containers failed to start!${NC}" - docker compose logs + $COMPOSE logs exit 1 fi echo -e "${GREEN}✅ Containers started successfully${NC}" @@ -103,15 +176,15 @@ echo "" echo -e "${YELLOW}📊 [2/6] Running database migrations...${NC}" echo -e " • Creating https_config table..." -docker compose exec -T digiserver-app python /app/migrations/add_https_config_table.py +$COMPOSE exec -T digiserver-app python /app/migrations/add_https_config_table.py echo -e " • Creating player_user table..." -docker compose exec -T digiserver-app python /app/migrations/add_player_user_table.py +$COMPOSE exec -T digiserver-app python /app/migrations/add_player_user_table.py echo -e " • Adding email to https_config..." -docker compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py +$COMPOSE exec -T digiserver-app python /app/migrations/add_email_to_https_config.py echo -e " • Migrating player_user global settings..." -docker compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py +$COMPOSE exec -T digiserver-app python /app/migrations/migrate_player_user_global.py echo -e " • Adding original_filename to content..." -docker compose exec -T digiserver-app python /app/migrations/add_original_filename_to_content.py +$COMPOSE exec -T digiserver-app python /app/migrations/add_original_filename_to_content.py echo -e "${GREEN}✅ All database migrations completed${NC}" echo "" @@ -119,16 +192,41 @@ echo "" # ============================================================================ # STEP 3: Configure HTTPS # ============================================================================ -echo -e "${YELLOW}🔒 [3/6] Configuring HTTPS...${NC}" +echo -e "${YELLOW}🔒 [3/6] Configuring HTTPS (mode: $HTTPS_MODE)...${NC}" -docker compose exec -T digiserver-app python /app/https_manager.py enable \ - "$HOSTNAME" \ - "$DOMAIN" \ - "$EMAIL" \ - "$IP_ADDRESS" \ - "$PORT" +# https_manager.py mirrors the Admin UI code path: persist HTTPSConfig → +# regenerate the Caddyfile → hot-reload Caddy → verify the TLS listener and +# automatically fall back to HTTP if it does not come up. +# +# `bootstrap` (rather than `enable`) is used deliberately: it records provenance +# via HTTPSConfig.updated_by, so an admin's later change in the UI is not +# silently overwritten on the next restart. +# +# The values computed above are injected with -e so this run uses them instead +# of whatever happens to be in the container's .env. +# +# Exit code 2 means "config applied but Caddy did not reload" — not fatal, so it +# must not abort the deployment. +set +e +if [ "$HTTPS_MODE" = "off" ]; then + $COMPOSE exec -T digiserver-app python /app/https_manager.py disable +else + $COMPOSE exec -T \ + -e HOSTNAME_INTERNAL="$SERVER_HOSTNAME" \ + -e HOST_IP="$IP_ADDRESS" \ + -e DOMAIN="$DOMAIN" \ + -e SSL_EMAIL="$EMAIL" \ + -e HTTPS_PORT="$HTTPS_PORT" \ + digiserver-app python /app/https_manager.py bootstrap +fi +HTTPS_RC=$? +set -e -echo -e "${GREEN}✅ HTTPS configured successfully${NC}" +case "$HTTPS_RC" in + 0) echo -e "${GREEN}✅ HTTPS configured${NC}" ;; + 2) echo -e "${YELLOW}⚠️ HTTPS config applied but Caddy did not reload — restart the caddy container.${NC}" ;; + *) echo -e "${YELLOW}⚠️ HTTPS not configured (or verification failed and it fell back to HTTP); continuing.${NC}" ;; +esac echo "" # ============================================================================ @@ -136,20 +234,21 @@ echo "" # ============================================================================ echo -e "${YELLOW}🔍 [4/6] Verifying database setup...${NC}" -docker compose exec -T digiserver-app python -c " +$COMPOSE exec -T digiserver-app python -c " from app.app import create_app +from app.extensions import db from sqlalchemy import inspect app = create_app() with app.app_context(): - inspector = inspect(app.extensions.db.engine) + inspector = inspect(db.engine) tables = inspector.get_table_names() print(' Database tables:') for table in sorted(tables): print(f' ✓ {table}') print(f'') print(f' ✅ Total tables: {len(tables)}') -" 2>/dev/null || echo " ⚠️ Database verification skipped" +" || echo " ⚠️ Database verification skipped" echo "" # ============================================================================ @@ -157,7 +256,7 @@ echo "" # ============================================================================ echo -e "${YELLOW}🔧 [5/6] Verifying Caddy configuration...${NC}" -docker compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1 +$COMPOSE exec -T caddy caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1 if [ $? -eq 0 ]; then echo -e " ${GREEN}✅ Caddy configuration is valid${NC}" else @@ -171,7 +270,7 @@ echo "" echo -e "${YELLOW}📋 [6/6] Displaying configuration summary...${NC}" echo "" -docker compose exec -T digiserver-app python /app/https_manager.py status +$COMPOSE exec -T digiserver-app python /app/https_manager.py status echo "" echo -e "${GREEN}╔════════════════════════════════════════════════════════════════╗${NC}" @@ -179,21 +278,57 @@ echo -e "${GREEN}║ 🎉 Deployment Complete! echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}" echo "" +# Build host:port suffixes, omitting the default ports for readability. +_http_url="http://$IP_ADDRESS" +[ "$HTTP_PORT" != "80" ] && _http_url="http://$IP_ADDRESS:$HTTP_PORT" +_https_url="https://$IP_ADDRESS" +[ "$HTTPS_PORT" != "443" ] && _https_url="https://$IP_ADDRESS:$HTTPS_PORT" + echo -e "${BLUE}📍 Access Points:${NC}" -echo " 🔒 https://$HOSTNAME" -echo " 🔒 https://$IP_ADDRESS" -echo " 🔒 https://$DOMAIN" +echo -e " 🌐 ${_http_url} (always available)" +case "$HTTPS_MODE" in + internal) + echo -e " 🔒 ${_https_url} (internal CA — see note below)" + echo -e " 🔒 https://$SERVER_HOSTNAME (needs DNS or an /etc/hosts entry)" + ;; + acme) + echo -e " 🔒 https://$DOMAIN" + ;; + off) + echo -e " ℹ️ HTTPS disabled (HTTPS_MODE=off)" + ;; +esac echo "" -echo -e "${BLUE}📝 Default Credentials:${NC}" -echo " Username: admin" -echo " Password: admin123 (⚠️ CHANGE IN PRODUCTION)" +if [ "$HTTPS_MODE" = "internal" ]; then + echo -e "${YELLOW}⚠️ Internal CA certificate notice:${NC}" + echo " The TLS certificate is signed by Caddy's LOCAL CA, which is not in any" + echo " client's trust store. Browsers will warn and players will reject it" + echo " unless you either:" + echo " a) install the root CA on each device, or" + echo " b) use the plain HTTP endpoint above (simplest for players)." + echo "" + echo " Export the root CA with:" + echo " $COMPOSE cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt" + echo "" +fi + +echo -e "${BLUE}📝 Administrator Account:${NC}" +if [ -f ".deployment-credentials" ]; then + echo " Credentials are in .deployment-credentials (chmod 600)." +else + echo " Username: ${ADMIN_USERNAME:-admin}" + echo " Password: see ADMIN_PASSWORD in .env (or the container's ADMIN_PASSWORD)" +fi +if grep -qs '^ADMIN_PASSWORD=admin123' .env 2>/dev/null; then + echo -e " ${RED}⚠️ ADMIN_PASSWORD is still the default — change it now!${NC}" +fi echo "" echo -e "${BLUE}📚 Documentation:${NC}" -echo " • DEPLOYMENT_COMMANDS.md - Detailed docker exec commands" -echo " • HTTPS_CONFIGURATION.md - HTTPS setup details" -echo " • setup_https.sh - Manual configuration script" +echo " • docs/07-deployment.md - Deployment + HTTPS details" +echo " • docs/06-utils-services.md - Caddy / https_manager internals" +echo " • Caddyfile.example - Caddy template (seeded into data/)" echo "" echo -e "${YELLOW}Next Steps:${NC}" @@ -204,5 +339,5 @@ echo "4. Configure your players and content" echo "" echo -e "${BLUE}📞 Support:${NC}" -echo "For troubleshooting, see DEPLOYMENT_COMMANDS.md section 7" +echo "For troubleshooting, see docs/07-deployment.md" echo "" diff --git a/deployment-commands-reference.sh b/deployment-commands-reference.sh index 388b9a1..85786ad 100644 --- a/deployment-commands-reference.sh +++ b/deployment-commands-reference.sh @@ -104,8 +104,8 @@ echo "" echo "Test certificate:" echo " openssl s_client -connect your-domain.com:443 -showcerts" echo "" -echo "Check SSL certificate expiry:" -echo " openssl x509 -enddate -noout -in data/nginx-ssl/cert.pem" +echo "Check TLS certificate (Caddy internal CA root):" +echo " openssl x509 -enddate -noout -in data/caddy-data/caddy/pki/authorities/local/root.crt" echo "" # ============================================================================ diff --git a/docker-compose.yml b/docker-compose.yml index 3cb1099..8211ba8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,11 +15,46 @@ services: # Only mount persistent data folders: - ./data/instance:/app/instance - ./data/uploads:/app/app/static/uploads + # Staged player source (git clone). Persisted so a rebuild of the app + # container does not throw away the ~140 MB staged checkout, and so the + # SSH deployment step has something to ship. + - ./data/player:/app/data/player + # The app GENERATES the Caddyfile (env bootstrap at startup, and the + # Admin → HTTPS Configuration page at runtime) then asks Caddy to reload. + # It therefore needs write access to the same file Caddy reads, so this + # must be mounted here as well as in the caddy service. + # The file is host-owned (uid 1000 == appuser), so writes succeed. + - ./data/Caddyfile:/etc/caddy/Caddyfile:rw environment: - FLASK_ENV=production - SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this} - ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} + # --------------------------------------------------------------------- + # Deploy-time TLS bootstrap (all optional). + # + # If HOSTNAME_INTERNAL and HOST_IP are BOTH set, the container configures + # Caddy for HTTPS using that address at startup — no manual step needed. + # If either is missing, the app stays on the plain-HTTP fallback and you + # can enable HTTPS later from Admin → HTTPS Configuration (which reloads + # Caddy live, no restart required). + # + # Leave DOMAIN empty for Caddy's internal CA. That needs NO public DNS + # and NO ACME, which is the right choice for an intranet name that is not + # resolvable from the internet. + # --------------------------------------------------------------------- + - HOSTNAME_INTERNAL=${HOSTNAME_INTERNAL:-} + - HOST_IP=${HOST_IP:-} + - DOMAIN=${DOMAIN:-} + - SSL_EMAIL=${SSL_EMAIL:-} + # Externally published ports (must match the caddy service mappings below). + # They are used to build correct HTTP→HTTPS redirect targets. + - HTTP_PORT=${HTTP_PORT:-80} + - HTTPS_PORT=${HTTPS_PORT:-443} + # Set to "false" to serve TLS only and redirect plain HTTP to HTTPS. + - HTTPS_HTTP_FALLBACK=${HTTPS_HTTP_FALLBACK:-true} + # Post-bootstrap check: probe HTTPS and fall back to HTTP if it is broken. + - HTTPS_VERIFY=${HTTPS_VERIFY:-true} restart: unless-stopped healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/').read()"] @@ -30,14 +65,23 @@ services: networks: - digiserver-network - # Caddy reverse proxy — auto-provisions Let's Encrypt certs when a real domain is configured + # Caddy reverse proxy. + # Port 80 → always answers, for both the IP and the hostname. + # Port 443 → HTTPS when configured; plain HTTP is served alongside it by + # default so clients that cannot trust the internal CA still work. + # Ports are configurable so the stack also works on a host where 80/443 are + # already taken (e.g. HTTP_PORT=8080 HTTPS_PORT=8443). caddy: image: caddy:2-alpine container_name: digiserver-caddy ports: - - "8080:80" - - "8443:443" + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" volumes: + # The app container regenerates this file on startup (env bootstrap) and + # whenever HTTPS is changed in the Admin UI, then hot-reloads Caddy via + # its admin API (http://caddy:2019/load). Because the file on disk is + # always current, a plain restart of Caddy picks up the latest config. - ./data/Caddyfile:/etc/caddy/Caddyfile:rw - ./data/caddy-data:/data - ./data/caddy-config:/config diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 78c5939..0496189 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -3,50 +3,168 @@ set -e echo "Starting DigiServer v2..." +# --------------------------------------------------------------------------- +# Pin the database explicitly. +# +# The migration scripts and the application must target the SAME SQLite file. +# Without this, they do not: the config classes default to different files +# (dev.db for DevelopmentConfig, dashboard.db for ProductionConfig), and most +# migration scripts call create_app() without an argument, which selects the +# *development* config. The app itself runs as create_app('production'). +# Exporting DATABASE_URL makes every code path below resolve to the same +# database, regardless of which config gets loaded. +# --------------------------------------------------------------------------- +export DATABASE_URL="${DATABASE_URL:-sqlite:////app/instance/dashboard.db}" +export FLASK_ENV="${FLASK_ENV:-production}" + +echo "Database: ${DATABASE_URL}" + # Create necessary directories mkdir -p /app/instance mkdir -p /app/app/static/uploads +# Staged player source (bind-mounted from ./data/player). Created here so the +# container works even if the volume was not pre-created on the host. +mkdir -p /app/data/player -# Initialize database if it doesn't exist -if [ ! -f /app/instance/dashboard.db ]; then - echo "Initializing database..." - python -c " +# --------------------------------------------------------------------------- +# Ensure the schema exists and bootstrap the admin user. +# +# Both operations are idempotent, so this runs on every container start: +# db.create_all() only creates missing tables, and the admin block creates the +# user if absent or otherwise refreshes its password from the environment. +# --------------------------------------------------------------------------- +echo "Ensuring database schema and admin user..." +python -c " from app.app import create_app from app.extensions import db, bcrypt from app.models import User +import os app = create_app('production') with app.app_context(): db.create_all() - - # Create or update admin user from environment variables - import os + admin_username = os.getenv('ADMIN_USERNAME', 'admin') admin_password = os.getenv('ADMIN_PASSWORD', 'admin123') - + admin = User.query.filter_by(username=admin_username).first() + hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8') if not admin: - hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8') admin = User(username=admin_username, password=hashed, role='admin') db.session.add(admin) - db.session.commit() print(f'✅ Admin user created ({admin_username})') else: - # Update password if it exists - hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8') + # Keep the stored password in sync with the environment. admin.password = hashed - db.session.commit() print(f'✅ Admin user password updated ({admin_username})') + db.session.commit() " - echo "Database initialized!" + +# --------------------------------------------------------------------------- +# Apply schema migrations. +# +# Every migration script is idempotent (it guards against duplicate columns and +# already-migrated tables), so this is safe to run on every start and upgrades +# databases created by older releases. +# +# ORDER MATTERS: migrations that create/repair a table must run before the ones +# that alter that table (e.g. add_player_user_table.py before +# migrate_player_user_global.py, add_https_config_table.py before +# add_email_to_https_config.py). +# +# Migrations are deliberately NON-FATAL: a failure is logged and startup +# continues, so one bad migration cannot strand the container in a restart +# loop. Look for the WARNING lines in the logs if something looks wrong. +# --------------------------------------------------------------------------- +MIGRATIONS=( + "add_https_config_table.py" + "add_player_user_table.py" + "add_email_to_https_config.py" + "migrate_player_user_global.py" + "add_url_to_content.py" + "add_original_filename_to_content.py" + "add_deployment_fields_to_player.py" +) + +echo "Running database migrations..." +FAILED_MIGRATIONS=() + +for migration in "${MIGRATIONS[@]}"; do + migration_path="/app/migrations/${migration}" + + if [ ! -f "$migration_path" ]; then + echo "⚠️ Skipping missing migration: ${migration}" + continue + fi + + echo " • ${migration}" + if ! python "$migration_path"; then + echo "⚠️ WARNING: migration failed: ${migration}" + FAILED_MIGRATIONS+=("${migration}") + fi +done + +if [ ${#FAILED_MIGRATIONS[@]} -gt 0 ]; then + echo "⚠️ WARNING: ${#FAILED_MIGRATIONS[@]} migration(s) failed: ${FAILED_MIGRATIONS[*]}" + echo "⚠️ Starting anyway — check the errors above." +else + echo "✅ All database migrations applied." fi +# --------------------------------------------------------------------------- +# Bootstrap HTTPS from environment variables. +# +# HOSTNAME_INTERNAL + HOST_IP (compose → env) configure Caddy for HTTPS at +# startup, so a fresh deployment is reachable over TLS without a manual step. +# When either is unset this is a deliberate NO-OP: the app stays on plain HTTP +# and HTTPS can be enabled later from Admin → HTTPS Configuration, which +# reloads Caddy live. +# +# ORDERING MATTERS: gunicorn is started FIRST (below) and only then is HTTPS +# configured. The bootstrap probes https://…/api/health to decide whether the +# certificate really works; if it ran before the app was listening, Caddy would +# return 502 and the probe would wrongly conclude HTTPS was broken. +# +# Non-fatal: if this fails the app still runs, and the admin page remains the +# fallback path for configuring HTTPS. +# --------------------------------------------------------------------------- +bootstrap_https() { + echo "Checking HTTPS bootstrap..." + if ! python /app/https_manager.py bootstrap; then + echo "⚠️ WARNING: HTTPS bootstrap failed — continuing; configure HTTPS from the admin UI." + fi +} + # Start the application +# --timeout is a safety net for any remaining synchronous long operation. The +# player build no longer blocks a worker (it runs in a background thread), but +# a generous margin avoids surprise worker kills during large uploads or +# dependency installs triggered from the admin UI. echo "Starting Gunicorn..." -exec gunicorn \ +gunicorn \ --bind 0.0.0.0:5000 \ --workers 4 \ - --timeout 120 \ + --timeout 300 \ --access-logfile - \ --error-logfile - \ - "app.app:create_app('production')" + "app.app:create_app('production')" & +GUNICORN_PID=$! + +# Wait for the app to answer before configuring HTTPS, then run the bootstrap. +for _ in $(seq 1 30); do + if python -c " +import sys, urllib.request +try: + urllib.request.urlopen('http://127.0.0.1:5000/health', timeout=2) +except Exception: + sys.exit(1) +" 2>/dev/null; then + break + fi + sleep 1 +done + +bootstrap_https + +# Keep the container attached to gunicorn so signals and healthchecks behave. +wait $GUNICORN_PID diff --git a/docs/01-architecture.md b/docs/01-architecture.md index 4d98ad5..f660974 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -56,7 +56,7 @@ Graphify assigns each node a **level** (0 = entry/global → 3 = utility): |---|---|---| | **L0 — Entry / Global** | 0 | `create_app()`, `app.py`, config classes, error handlers, CLI commands | | **L1 — Strategic / Core** | 1 | Blueprint route handlers (players, content, api, admin), model classes | -| **L2 — Implementation** | 2 | Playlist/group management helpers, processing helpers, model methods | +| **L2 — Implementation** | 2 | Playlist management helpers, processing helpers, model methods | | **L3 — Utility** | 3 | `logger.py`, `ssh_deploy.py`, `caddy_manager.py`, `uploads.py`, `pptx_converter.py`, migrations | ```mermaid @@ -81,7 +81,6 @@ flowchart TD log["log_action()"] up["uploads.py"] pptx["pptx_converter.py"] - nginx["NginxConfigReader"] end create_app --> bp create_app --> models @@ -102,14 +101,14 @@ Graphify clustered the code into **41 communities**. The 12 meaningful ones are | Community | Domain (derived) | Files | Role | |---|---|---|---| -| **C0** (90) | **Authentication + legacy groups** | `blueprints/auth.py`, `blueprints/content_old.py`, `models/server_log.py`, `utils/logger.py`, `utils/group_player_management.py`, `old_code_documentation/blueprint_groups.py` | Auth flows + audit logging + (legacy) group features | +| **C0** (90) | **Authentication + audit** | `blueprints/auth.py`, `models/server_log.py`, `utils/logger.py`, `old_code_documentation/blueprint_groups.py` | Auth flows + audit logging | | **C1** (80) | **Admin + HTTPS + player users** | `blueprints/admin.py`, `models/https_config.py`, `models/player_user.py`, `utils/caddy_manager.py`, `migrations/add_player_user_table.py` | Admin panel, Caddy HTTPS generation, editing-user registry | | **C2** (68) | **Playlist & content workflows** | `blueprints/content.py`, `blueprints/playlist.py`, `models/playlist.py` | Modern playlist-centric content management + legacy redirects | | **C3** (62) | **Player API + edits** | `blueprints/api.py`, `models/player_edit.py` | Player-facing REST, edited-media pipeline | | **C4** (55) | **Application core** | `app.py`, `config.py`, `models/user.py`, `utils/portal_sso.py`, `utils/script_name_fix.py` | App factory, config, auth identity, middleware | | **C5** (54) | **Content & player models** | `models/content.py`, `models/player.py`, `models/player_feedback.py`, `blueprints/main.py` | Media model, player model, feedback, dashboard | | **C6** (51) | **Player management UI** | `blueprints/players.py`, `migrations/add_https_config_table.py` | Player CRUD, manage page, deployment polling | -| **C7** (42) | **Groups (legacy)** | `models/group.py`, `utils/nginx_config_reader.py` | Archived groups feature + legacy nginx reader | +| **C7** (42) | ***(removed)*** | — | Groups model + legacy nginx reader were deleted during sanitization | | **C8** (24) | **Dev tooling** | `old_code_documentation/test_edit_media_api.py`, `Colors`, integrity checker | Test/analysis utilities | | **C9** (21) | **Upload processing** | `utils/uploads.py` | Upload progress, video/image processing | | **C10** (20) | **Deployment** | `utils/background_tasks.py`, `utils/ssh_deploy.py` | Background SSH deployment engine | diff --git a/docs/02-knowledge-graph.md b/docs/02-knowledge-graph.md index 11bc11d..9480a6e 100644 --- a/docs/02-knowledge-graph.md +++ b/docs/02-knowledge-graph.md @@ -84,7 +84,7 @@ These are the most-connected nodes (graph centrality). They are the architectura Graphify groups related code into communities. See [01-architecture.md §3](01-architecture.md#3-component-map-files--communities) for the full mapping. The largest: -- **C0 · Auth & logging** (90 nodes) — login/logout/register + audit logging + legacy groups +- **C0 · Auth & logging** (90 nodes) — login/logout/register + audit logging - **C1 · Admin & HTTPS** (80 nodes) — admin panel, Caddy generation, editing users - **C2 · Playlists & content** (68 nodes) — the modern content/playlist workflow - **C3 · Player API & edits** (62 nodes) — player-facing REST + edited-media pipeline diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 43c0f72..435951f 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -15,7 +15,6 @@ erDiagram content ||--o{ player_edit : "edited (cascade)" content ||--o{ player_feedback : "playing" playlist ||--o{ content : "playlist_content M2M (position,duration,muted,edit_on_player_enabled)" - content }o--o{ group : "group_content M2M (legacy)" player_user ||--o{ player_edit : "user_code" https_config ||--o| https_config : "singleton row" ``` @@ -74,8 +73,8 @@ Methods: `is_online` (5-min window), `update_status()`, `set_password()/check_pa | `description` | Text | nullable | | `uploaded_at` | DateTime | NOT NULL, indexed | -Relationships: `playlists` (M2M via `playlist_content`), `groups` (M2M via `group_content`). -Properties/methods: `file_size_mb`, `group_count`, `original_display_name`, `original_media_path`, `current_media_path`, `is_image()/is_video()/is_pdf()/is_weblink()`, `has_player_edits`. +Relationships: `playlists` (M2M via `playlist_content`). +Properties/methods: `file_size_mb`, `original_display_name`, `original_media_path`, `current_media_path`, `is_image()/is_video()/is_pdf()/is_weblink()`, `has_player_edits`. ### `playlist` — ordered collections of content | Column | Type | Notes | @@ -168,15 +167,13 @@ Classmethods: `log_info`, `log_warning`, `log_error`. Classmethods: `get_config()` (first row), `create_or_update(...)`. Method: `to_dict()`. -### `group` + `group_content` — **ARCHIVED / LEGACY** -| Column | Type | Notes | -|---|---|---| -| `id` | Integer | PK | -| `name` | String(100) | unique, NOT NULL, indexed | -| `description` | Text | nullable | -| `created_at` / `updated_at` | DateTime | NOT NULL | +### `group` + `group_content` — **REMOVED** -`group_content(group_id, content_id)` composite-PK M2M. The current `Player` model has **no** `group_id` column — group features are archived (see [09 · Legacy](09-legacy-and-migrations.md)). +The `Group` model, the `group_content` association table, `Content.groups` / +`Content.group_count`, and the group-management utility functions were **deleted** +during the code sanitization pass (the feature was archived and the table had no +rows). `Player` never had a `group_id` column. See +[SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md). --- @@ -185,7 +182,6 @@ Classmethods: `get_config()` (first row), `create_or_update(...)`. Method: `to_d | Relationship | Cardinality | FK / Mechanism | |---|---|---| | `playlist` → `content` | M:N | `playlist_content` (positioned, with extras) | -| `group` → `content` | M:N | `group_content` (legacy) | | `player` → `playlist` | N:1 | `player.playlist_id` (ON DELETE SET NULL) | | `player` → `player_feedback` | 1:N | `player_feedback.player_id` (cascade) | | `player` → `player_edit` | 1:N | `player_edit.player_id` (cascade) | diff --git a/docs/04-application-core.md b/docs/04-application-core.md index ab16a14..8a20c30 100644 --- a/docs/04-application-core.md +++ b/docs/04-application-core.md @@ -40,7 +40,9 @@ def register_blueprints(app): ... ``` -> Note: `app.blueprints.content_old` is **not** imported — it is dead/legacy code. +> Note: `content_old.py` (legacy content routes) was **deleted** during the code +> sanitization pass — see [SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md). The +> legacy `playlist.py` blueprint remains (redirect-only). --- @@ -153,9 +155,8 @@ app/templates/ ├── admin/ admin.html, user_management.html, leftover_media.html, │ dependencies.html, customize_logos.html, editing_users.html, │ https_config.html, build_player.html -├── content/ content_list.html (legacy), content_list_new.html (modern), -│ media_library.html, upload_content.html (legacy), -│ upload_media.html, manage_playlist_content.html, edit_content.html +├── content/ content_list_new.html, media_library.html, +│ upload_media.html, manage_playlist_content.html ├── players/ players_list.html, add_player.html, edit_player.html, │ manage_player.html, player_page.html, player_fullscreen.html, │ edited_media.html, edited_media_report.html, _deploy_badge.html diff --git a/docs/05-blueprints-api.md b/docs/05-blueprints-api.md index 0df6cbc..ba60325 100644 --- a/docs/05-blueprints-api.md +++ b/docs/05-blueprints-api.md @@ -1,6 +1,6 @@ # 05 · Blueprints & REST API -DigiServer v2 registers **7 active blueprints** (`content_old.py` is legacy dead code and is not registered). +DigiServer v2 registers **7 active blueprints**. --- @@ -203,7 +203,7 @@ Redirects/legacy — kept for compatibility: |---|---|---|---| | `/player-feedback` | POST | `receive_player_feedback` | Status (playing/paused/error/restarting); infers player; **auto-marks deployment `deployed`** | | `/player-status/` | GET | `get_player_status` | Online (5-min), latest feedback | -| `/system-info` | GET | — | Counts: players online/total, groups, content, 24h logs | +| `/system-info` | GET | — | Counts: players online/total, content, 24h logs | | `/content` | GET | — | List content with counts | | `/logs` | GET | `get_logs` | Query logs (`limit`/`level`/`since`) | diff --git a/docs/06-utils-services.md b/docs/06-utils-services.md index f5ada7a..383ca61 100644 --- a/docs/06-utils-services.md +++ b/docs/06-utils-services.md @@ -9,7 +9,7 @@ All shared services live in `app/utils/`. This document details each module, its | Module | Community | Responsibility | Key symbols | |---|---|---|---| | `logger.py` | C0 | DB-backed audit logging | `log_action()`, `get_recent_logs()`, `clear_old_logs()` | -| `group_player_management.py` | C0 | Group/player stats (legacy) | `get_player_status_info()`, `assign_player_to_group()`, `get_online_players_count()` | +| `group_player_management.py` | C0 | Player status reporting | `get_player_status_info()` | | `caddy_manager.py` | C1 | HTTPS Caddyfile generation | `CaddyConfigGenerator`, `write_caddyfile()`, `reload_caddy()` | | `background_tasks.py` | C10 | Async task execution | `run_background_task()`, `background_player_deployment()` | | `ssh_deploy.py` | C10 | Remote player provisioning | `deploy_player_to_host()`, `test_ssh_connection()`, `generate_player_config()` | @@ -18,7 +18,9 @@ All shared services live in `app/utils/`. This document details each module, its | `uploads.py` | C9 | Upload progress + file ops | `get/set/clear_upload_progress()`, `save_uploaded_file()`, `process_video_file()` | | `portal_sso.py` | C4 | SSO auto-login | `init_portal_sso()`, `_get_or_create_user()` | | `script_name_fix.py` | C4 | WSGI sub-path middleware | `ScriptNameFix` | -| `nginx_config_reader.py` | C7 | Legacy nginx status parsing | `NginxConfigReader`, `get_nginx_status()` | + +> `nginx_config_reader.py` (C7) was **removed** during the sanitization pass — the +> reverse proxy is Caddy. See [SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md). --- @@ -81,11 +83,31 @@ Other helpers: | Method | Purpose | |---|---| -| `generate_caddyfile(config)` | Pick template by mode: **HTTP-only** (`:80`), **domain** (Let's Encrypt), or **IP** (internal CA self-signed). Includes `reverse_proxy digiserver-app:5000`, 2 GB body limit, gzip, security headers | +| `generate_caddyfile(config, http_fallback=True)` | Pick template by mode: **HTTP-only** (`:80`), **domain** (Let's Encrypt), or **IP-only** (internal CA `tls internal`). Includes `reverse_proxy digiserver-app:5000`, 2 GB body limit, gzip, security headers. `http_fallback` also serves plain HTTP alongside internal-CA TLS so clients that cannot trust the local CA still work | | `write_caddyfile(content, path=/etc/caddy/Caddyfile)` | Write to disk | | `reload_caddy()` | POST to Caddy admin API `http://caddy:2019/load` | -Triggered from `admin.update_https_config` after saving `HTTPSConfig`. +Triggered from `admin.update_https_config` (Admin UI) **or** `https_manager.py` (CLI), +both of which save `HTTPSConfig` first so the two paths stay in sync. + +> **Internal CA vs Let's Encrypt:** an intranet name (e.g. `*.harting.intra`) is not +> resolvable publicly, so ACME challenges cannot succeed. Leaving `domain` empty selects +> `tls internal`, which needs no DNS and no external service. See +> [07 · Deployment §6](07-deployment.md#6-https-setup-caddy). + +--- + +## 4b. `https_manager.py` — HTTPS CLI (repo root) + +Command-line equivalent of the Admin HTTPS page, used by `deploy.sh`. + +| Command | Purpose | +|---|---| +| `enable [port]` | Persist `HTTPSConfig`, regenerate + write the Caddyfile, hot-reload Caddy. Empty `` → internal CA. `--redirect-only` to disable the HTTP fallback; `--no-https` for HTTP only | +| `disable` | Turn HTTPS off (HTTP only) | +| `status` | Print the stored configuration and resolved mode | + +Exit codes: `0` success · `1` bad args/config · `2` config applied but Caddy did not reload. --- @@ -131,9 +153,16 @@ Used by the upload pipeline: **PPTX → PDF → PNG slides (Full HD)**. --- -## 9. `nginx_config_reader.py` — Legacy (informational) +## 9. `group_player_management.py` — Player Status -`NginxConfigReader` parses an `nginx.conf` and reports `ssl_enabled`, ports, upstreams, `server_names`, `ssl_protocols`, `client_max_body_size`, `gzip`. **Legacy** — the current reverse proxy is Caddy; retained for reference and the old deployment stack. +Only `get_player_status_info(player_id)` remains: it returns the online flag +(5-minute window), status, last-seen plus a humanised "time ago", and the latest +`PlayerFeedback`. Used by `players.list` and `players.manage_player`. + +The group helpers (`get_group_statistics`, `assign_player_to_group`, +`bulk_assign_players_to_group`) and the status-list helpers +(`get_online_players_count`, `get_players_by_status`) were **removed** with the +archived Group subsystem. --- diff --git a/docs/07-deployment.md b/docs/07-deployment.md index 095e46c..50ba47c 100644 --- a/docs/07-deployment.md +++ b/docs/07-deployment.md @@ -63,40 +63,232 @@ flowchart LR ## 4. `docker-entrypoint.sh` -1. Create `/app/instance` and `/app/app/static/uploads`. -2. If `dashboard.db` is missing: create app + `db.create_all()`, then create/update the admin user from `ADMIN_USERNAME` / `ADMIN_PASSWORD`. -3. Start **Gunicorn**: `--bind 0.0.0.0:5000 --workers 4 --timeout 120 app.app:create_app('production')`. +1. **Pin the database**: export `DATABASE_URL` (default `sqlite:////app/instance/dashboard.db`) so migrations and the app resolve to the *same* file. Required because the config classes default to different files (`dev.db` vs `dashboard.db`) and most migration scripts call `create_app()` without an argument (→ development config), while the app runs `create_app('production')`. +2. Create `/app/instance` and `/app/app/static/uploads`. +3. Ensure schema + admin user — runs on **every** start (idempotent): `db.create_all()`, then create the admin from `ADMIN_USERNAME` / `ADMIN_PASSWORD` or refresh its password. +4. Run the **migration chain** (idempotent, ordered — table-creating migrations run before those that alter them): + +``` +add_https_config_table.py +add_player_user_table.py +add_email_to_https_config.py +migrate_player_user_global.py +add_url_to_content.py +add_original_filename_to_content.py +add_deployment_fields_to_player.py +``` + + Migrations are **non-fatal**: failures are logged as `⚠️ WARNING` and startup continues, so one bad migration can't strand the container in a restart loop. + +5. Start **Gunicorn**: `--bind 0.0.0.0:5000 --workers 4 --timeout 120 app.app:create_app('production')`. + +> Because migrations now run automatically on startup, `deploy.sh` step 4 is redundant (harmless — the scripts are idempotent). + +### Data layout (bind mounts) + +| Host path | Container path | Contents | +|---|---|---| +| `data/instance` | `/app/instance` | SQLite DB (`dashboard.db`), `player_build.json` | +| `data/uploads` | `/app/app/static/uploads` | Media files + `edited_media/` | +| `data/Caddyfile` | `/etc/caddy/Caddyfile` | Reverse-proxy config (**a file, not a directory**) | +| `data/caddy-data` | `/data` | Caddy state (instance UUID, certificates) | +| `data/caddy-config` | `/config` | Caddy autosave | +| `data/caddy-logs` | `/var/log/caddy` | Access logs | + +> ⚠️ **`data/Caddyfile` must exist before `docker compose up`.** It is bind-mounted as a *file*; +> if it is missing, Docker creates a **directory** in its place and Caddy fails to start. +> `deploy.sh` seeds it from the version-controlled `Caddyfile.example`. For a manual start: +> ``` +> mkdir -p data/instance data/uploads data/caddy-data data/caddy-config data/caddy-logs +> cp Caddyfile.example data/Caddyfile +> docker compose up -d --build +> ``` + +> ℹ️ The legacy `data/nginx-*` and `data/certbot` folders are **obsolete** — the reverse proxy is +> Caddy. They are no longer created by `deploy.sh`. + +### Clean start (wipe all runtime data) + +`data/` is **gitignored**, so wiping it is irreversible. To reset to a pristine deployment: + +``` +docker compose down +docker rmi digiserver-v2-digiserver-app:latest # drop stale image +docker image prune -f && docker builder prune -a -f # reclaim build cache +rm -rf data # WIPES db, uploads, certs +mkdir -p data/instance data/uploads data/caddy-data data/caddy-config data/caddy-logs +cp Caddyfile.example data/Caddyfile +./deploy.sh +``` + +> ⚠️ Files under `data/caddy-*` are created **root-owned** by the Caddy container, so a plain +> `rm -rf data` may fail with *Permission denied*. Remove them via a helper container: +> ``` +> docker run --rm -v "$PWD/data:/data" caddy:2-alpine sh -c 'rm -rf /data/caddy-config /data/caddy-data' +> ``` +> Avoid `docker system prune --volumes` — this host also holds volumes for **other** projects. --- ## 5. `deploy.sh` (One-Shot Deployment) ``` -1. Validate compose + project; create data/ subdirs; copy nginx configs -2. docker compose up -d + verify containers "Up" -3. Run migration scripts (add_https_config_table, add_player_user_table, +1. Detect compose: `docker compose` (plugin) or `docker-compose` (v1 fallback) + — stored in $COMPOSE and used for every subsequent call +2. Create data/ subdirs (instance, uploads, caddy-data, caddy-config, caddy-logs) + and seed data/Caddyfile from Caddyfile.example +3. $COMPOSE up -d + verify containers "Up" +4. Run migration scripts (add_https_config_table, add_player_user_table, add_email_to_https_config, migrate_player_user_global, add_original_filename_to_content) -4. Run /app/https_manager.py enable - ⚠ https_manager.py is NOT in the current repo — this step needs attention -5. Verify DB tables via SQLAlchemy inspector -6. caddy validate + https_manager.py status; print access URLs + default creds + ↳ NOTE: the container entrypoint already applies all seven on startup. + This step is idempotent and therefore redundant. +5. /app/https_manager.py enable + ↳ Exit code 2 ("config applied, Caddy not reloaded") is non-fatal. +6. Verify DB tables via SQLAlchemy inspector; caddy validate; + https_manager.py status; print access URLs + default creds ``` +### Configuration variables + +| Variable | Default | Meaning | +|---|---|---| +| `HOSTNAME` | `digiserver` | Display hostname | +| `HTTPS_MODE` | `internal` | `internal` \| `acme` \| `off` | +| `DOMAIN` | *(empty)* | Required only when `HTTPS_MODE=acme` | +| `IP_ADDRESS` | **auto-detected** | Primary LAN IP (override if needed) | +| `EMAIL` | `admin@example.com` | ACME account email (unused by internal CA) | +| `PORT` | `8443` | Externally published HTTPS port | + +> If `IP_ADDRESS` is unset, `deploy.sh` auto-detects it +> (`ip -4 route get 1.1.1.1` → `src` address, falling back to `hostname -I`). +> The old hard-coded defaults (`10.76.152.164`, a `.intra` domain) were wrong for +> most hosts and have been removed. + --- ## 6. HTTPS Setup (Caddy) -HTTPS is configured through the **Admin → HTTPS Configuration** page, which: +Three equivalent entry points drive the **same** code path +(`HTTPSConfig` + `CaddyConfigGenerator`) so CLI, env bootstrap and UI cannot diverge: + +1. **Env bootstrap (deploy time)** — the container entrypoint runs + `python /app/https_manager.py bootstrap`, which reads `HOSTNAME_INTERNAL` and + `HOST_IP` from the environment and configures Caddy for HTTPS automatically. +2. **CLI** — `python /app/https_manager.py enable … | verify | status | disable` +3. **UI** — *Admin → HTTPS Configuration* (reloads Caddy live; the ongoing source of truth) + +### Addressing model — one HTTP endpoint, one HTTPS endpoint + +| Port | What it does | +|---|---| +| **80** | Always answers. A catch-all `:80` block serves **any** Host header, plus explicit blocks for the IP and hostname so both work. | +| **443** | HTTPS for the same names, using the internal CA (or ACME for a public domain). | + +If HTTPS is disabled or never configured, port 80 simply serves the app — there is +no separate "HTTP mode" to set. + +> ⚠️ **`default_sni` is required for IP access.** Browsers send **no SNI** when the +> URL is an IP address (an IP is not a valid SNI hostname). Caddy then identifies the +> connection by the container's own internal IP and aborts the handshake with +> `no certificate available for ''`. To prevent this, the generator emits +> `default_sni ` whenever internal-CA mode is used, so `https://` works in a +> plain browser. This was found by end-to-end testing — see +> `docs/tools/test_http_https_runtime.sh`. + +### Mode selection + +| Condition | Result | +|---|---| +| HTTPS off, or no IP/domain | Plain HTTP on port 80 | +| HTTPS on + `ip_address` / `hostname` | `tls internal` per name (no DNS, no ACME) | +| HTTPS on + `domain` set | Let's Encrypt for that name | + +### Automatic fallback if HTTPS does not work + +After applying a config, `https_manager.py` probes the HTTPS endpoint +(`/api/health`, certificate validation deliberately disabled). If the TLS listener +does not come up, the configuration is **automatically reverted to plain HTTP** so a +failed certificate can never make the site unreachable: + +``` +enable HTTPS → reload Caddy → probe https://:/api/health + ├─ OK → keep HTTPS + └─ FAIL → revert to HTTP-only, log a warning +``` + +Disable the probe with `HTTPS_VERIFY=false` (or `enable --no-verify`). +Re-check at any time with `python /app/https_manager.py verify`. + +### Deploy-time bootstrap via `.env` + +Copy `.env.example` → `.env` and set the host address. `docker-compose.yml` +forwards these to the app container: + +| Variable | Effect | +|---|---| +| `HOSTNAME_INTERNAL` | Hostname served (both HTTP and HTTPS) | +| `HOST_IP` | IP served and certified | +| `DOMAIN` | **Leave empty for an intranet name** → internal CA. Set only for Let's Encrypt | +| `SSL_EMAIL` | ACME contact (ignored by internal CA) | +| `HTTP_PORT` / `HTTPS_PORT` | Host ports mapped to Caddy's 80/443 (default `80`/`443`) | +| `HTTPS_HTTP_FALLBACK` | `true` (default) also serves plain HTTP; `false` redirects instead | +| `HTTPS_VERIFY` | `true` (default) probe HTTPS and auto-fall back on failure | + +```bash +cp .env.example .env +# set HOSTNAME_INTERNAL and HOST_IP +docker compose up -d --build +``` + +> **If either `HOSTNAME_INTERNAL` or `HOST_IP` is missing the bootstrap is a no-op** — +> the app starts on plain HTTP and stays reachable. HTTPS can then be enabled from +> *Admin → HTTPS Configuration*, which regenerates the Caddyfile and reloads Caddy +> **without a restart**. + +### Who owns the config (env vs admin UI) + +The admin UI is the **ongoing source of truth**. The bootstrap runs on every container +start, so it guards against silently overwriting an admin's change by tracking +provenance in `HTTPSConfig.updated_by`: + +| Current config | Bootstrap behaviour | +|---|---| +| *(none — first deploy)* | Apply from env ✅ | +| Written by env/CLI (`updated_by='deploy.sh'`) | Apply from env ✅ — so editing `HOST_IP` and redeploying works | +| Written by a user (`updated_by=''`) | **Skip** — the admin's setting is preserved | + +So an admin change made in the UI survives restarts even while the env vars remain set. + +### Why internal CA (and not Let's Encrypt) for `.intra` + +An intranet name such as `digiserver.sibiusb.harting.intra` is **not resolvable from the +public internet**, so Let's Encrypt's HTTP-01/TLS-ALPN challenge cannot succeed. Setting +`DOMAIN=` empty makes Caddy sign the certificate itself with its local CA — no external +dependency at all. + +**Trust caveat:** the internal CA is not in any client trust store, so browsers show a +warning and a Kivy player with `verify_ssl: true` will **reject the connection**. Options: + +1. **Use HTTP** — port 80 always serves the app, so players need no trust configuration. +2. **Install the root CA** on each device: + ``` + docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt + ``` + +### Ports + +| Host → Container | Purpose | +|---|---| +| `80 → 80` | HTTP (always available) | +| `443 → 443` | HTTPS | +| `5000 → 5000` | Direct app access (bypasses Caddy; dev/testing) | + +> Ports are configurable via `HTTP_PORT`/`HTTPS_PORT` so the stack also works where +> 80/443 are already taken (e.g. `HTTP_PORT=8080 HTTPS_PORT=8443`). + -1. Saves `HTTPSConfig` (hostname, domain, IP, email, port, enabled). -2. `CaddyConfigGenerator.generate_caddyfile(config)` picks a template: - - **HTTP-only** → `:80` reverse proxy - - **Domain** → automatic Let's Encrypt - - **IP** → internal CA self-signed -3. Writes `/etc/caddy/Caddyfile` and reloads Caddy via `POST http://caddy:2019/load`. -The current `data/Caddyfile` (HTTP mode) includes: admin API on `0.0.0.0:2019`, `:80` block → `digiserver-app:5000`, 2 GB body limit, gzip, security headers, access log. --- @@ -105,17 +297,19 @@ The current `data/Caddyfile` (HTTP mode) includes: admin API on `0.0.0.0:2019`, Sections checked (pass/fail/warn counters): - git status - `.env` / `.env.example` -- Docker + Compose versions + `compose config` syntax +- Docker + Compose versions (plugin **or** v1) + `compose config` syntax - Dockerfile best practices (HEALTHCHECK, non-root, slim base) - `requirements.txt` critical packages + versions - migrations directory -- **SSL cert expiry** (openssl) +- **TLS certificate** — Caddy internal CA expiry (`data/caddy-data/caddy/pki/authorities/local/root.crt`) - Flask config (`ProductionConfig`, `SESSION_COOKIE_SECURE`) -- nginx.conf checks -- runtime container health +- `data/Caddyfile` checks (reverse_proxy, admin API, TLS mode) +- runtime container health + live HTTP/HTTPS endpoint probes - security best practices -> ⚠ Note: the script still references `docker-compose` (v1) and `digiserver-nginx` — the current stack uses Compose v2 + Caddy. +> The script now detects `docker compose` (plugin) or `docker-compose` (v1) and warns when +> buildx is too old for compose v1 builds — matching `deploy.sh`, which then falls back to +> `docker build`. --- diff --git a/docs/09-legacy-and-migrations.md b/docs/09-legacy-and-migrations.md index 541db4c..110a7c8 100644 --- a/docs/09-legacy-and-migrations.md +++ b/docs/09-legacy-and-migrations.md @@ -33,21 +33,24 @@ add_original_filename_to_content.py | Component | Status | Notes | |---|---|---| -| `app/blueprints/content_old.py` | **Dead code** | Legacy per-player content routes (`/`, `/upload`, `//edit`, `/bulk/delete`, `/upload-progress`, `/preview`, `/statistics`, `/check-duplicates`, `//groups`). Not imported by `create_app`. | +| `app/blueprints/content_old.py` | **DELETED** | Legacy per-player content routes. Removed in the sanitization pass together with its templates (`content_list.html`, `edit_content.html`, `upload_content.html`). | | `app/blueprints/playlist.py` | **Active but legacy** | Per-player playlist routes kept as redirects to the modern content workflow. | -| `Group` model + group routes | **Archived** | `Player` no longer has `group_id`; group routes commented out; `group_player_management.py` and `group_content` association remain for reference. | -| `nginx` stack | **Replaced by Caddy** | `data/nginx.conf`, `data/nginx-custom-domains.conf`, `data/nginx-logs/`, `data/nginx-ssl/` retained. `utils/nginx_config_reader.py` still parses it. | +| `Group` model + group routes | **DELETED** | `models/group.py`, the `group_content` association, `Content.groups` / `Content.group_count`, and the group utility functions were removed. `Player` never had a `group_id` column. | +| `utils/nginx_config_reader.py` | **DELETED** | Legacy nginx parsing — the reverse proxy is Caddy. | +| `nginx` stack | **Replaced by Caddy** | `data/nginx.conf`, `data/nginx-custom-domains.conf`, `data/nginx-logs/`, `data/nginx-ssl/` removed with the Caddy migration. `migrate_network.sh` no longer generates self-signed certs — Caddy issues them. | | `https_manager.py` | **Missing** | Referenced by `deploy.sh` but not in repo — likely merged into `CaddyConfigGenerator`. | | `old_code_documentation/` | **Archive** | Full legacy docs, old scripts (`blueprint_groups.py`, `add_muted_column.py`, `fix_player_user_schema.py`, `test_edit_media_*.py`, `check_fix_player.py`, `migrate_add_edit_enabled.py`), deployment guides, HTTPS analysis, player analysis. | | `QUICK_DEPLOYMENT.md`, `deployment-commands-reference.sh` | **Active reference** | Manual deployment notes. | +| `docs/legacy code/` | **Snapshot** | Full pre-sanitization copy of the codebase. Excluded from the Docker build via `.dockerignore`. | --- ## 3. Recommended Cleanup (optional) -- Remove `content_old.py` and `old_code_documentation/*.py` scripts that are no longer needed (keep the `.md` docs). -- Resolve the missing `https_manager.py` in `deploy.sh` (use `CaddyConfigGenerator` equivalents). -- Update `verify-deployment.sh` to reference Compose v2 and Caddy instead of `docker-compose`/nginx. +- Remove `old_code_documentation/*.py` scripts that are no longer needed (keep the `.md` docs). +- Resolve the missing `https_manager.py` in `deploy.sh` — **done**: `https_manager.py` now exists at the repo root. +- Update `verify-deployment.sh` to reference Caddy instead of nginx — **done**. +- Consider removing the legacy `playlist.py` blueprint (see [SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md) batch B1). --- diff --git a/docs/README.md b/docs/README.md index f68a58e..6926822 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,7 +51,7 @@ graphify-out/ | Application type | Digital signage content/playlist/player management | | Graph size | **631 nodes · 1162 edges · 41 communities** | | Blueprints | 7 active (`main`, `auth`, `admin`, `players`, `content`, `playlist`, `api`) | -| Database tables | 10 (`user`, `player`, `player_edit`, `player_feedback`, `player_user`, `content`, `group`, `playlist`, `server_log`, `https_config`) | +| Database tables | 9 (`user`, `player`, `player_edit`, `player_feedback`, `player_user`, `content`, `playlist`, `server_log`, `https_config`) | | Reverse proxy | Caddy 2 (automatic HTTPS / Let's Encrypt) | | Deployment | Docker Compose (app + Caddy) + remote SSH player provisioning | | Key externals | LibreOffice, Poppler (pdf2image), FFmpeg, sshpass/rsync | @@ -100,7 +100,6 @@ erDiagram content ||--o{ player_edit : "cascade" content ||--o{ player_feedback : "" playlist ||--o{ content : "playlist_content (M2M)" - content }o--o{ group : "group_content (M2M)" player_user ||--o{ player_edit : "user_code" https_config ||--|| https_config : "single row config" ``` diff --git a/docs/SANITIZATION-REVIEW.md b/docs/SANITIZATION-REVIEW.md new file mode 100644 index 0000000..3575a6a --- /dev/null +++ b/docs/SANITIZATION-REVIEW.md @@ -0,0 +1,206 @@ +# DigiServer v2 — Code Sanitization Review + +**Generated:** 2026-09-10 +**Snapshot:** `docs/legacy code/` (1.7 MB, 171 files, 49 Python files) — full restore point. + +Analysed **42 Python files / 240 functions / 104 routes / 32 templates** under `app/` and `migrations/`. + +> Review each section below and reply with the IDs you want deleted (e.g. `A1, A2, B1`). +> Nothing is deleted until you confirm. Everything is recoverable from `docs/legacy code/`. + +--- + +## ✅ Status — Applied 2026-09-10 + +**Removed (A1, A2, D1, D2):** + +| ID | Removed | Notes | +|---|---|---| +| A1 | `app/blueprints/content_old.py` | + 3 templates orphaned by its removal | +| A2 | `app/utils/nginx_config_reader.py` | | +| D1 | 5 group functions + their `__init__.py` exports | `get_player_status_info` kept (live) | +| D2 | `app/models/group.py`, `Content.groups`, `Content.group_count` | | + +**Extra cleanup triggered by A1/D2:** +- Deleted orphaned templates: `upload_content.html` (278), `edit_content.html` (11) +- Removed the dead `groups` key from `/api/system-info` and `group_count` from `/api/content` +- Removed the `'group_id': getattr(player, 'group_id', None)` compat shim from `/api/player-status` +- Updated 8 documentation files + +**Also removed (B1, B2) — applied in a second pass:** + +| ID | Removed | Notes | +|---|---|---| +| B1 | `app/blueprints/playlist.py` (310 LOC) + its registration in `app.py` | Whole legacy blueprint; its only real route redirected to `content.manage_playlist_content` | +| B2 | 3 routes in `players.py`: `reorder_content`, `reorder_playlist`, `remove_from_playlist` (~103 LOC) | Two queried nonexistent columns (`Content.player_id`, `Content.position`, `Player.playlist_version`) → guaranteed 500s | + +**Extra cleanup triggered by B1:** +- Deleted `content_list.html` (202 lines) — its only remaining reference was `url_for('playlist.manage_playlist')`. It was **already orphaned** (no Python file rendered it) after `content_old.py` was deleted, so it has been removed for real this time. +- Deleted `players/player_page.html` (227 lines) — it was **never rendered** by any view (the `players.player_page` route redirects to `manage_player`), so it was dead UI. Corrects the earlier "false positive" note below. + +> ⚠️ **Functional note:** `players.regenerate_auth_code` (`POST /players//regenerate-auth`) is now +> referenced by **no template** — `player_page.html` was its only caller. The endpoint still works if +> invoked directly. The equivalent control lives in `manage_player.html` via the quickconnect flow. +> Restore `player_page.html` from `docs/legacy code/` if you want that button back. + +**Result (A + B + D combined):** +``` +42 → 38 Python modules 9,311 → 8,296 LOC +104 → 82 routes 32 → 28 templates +7 → 6 blueprints dead modules: 2 → 0, orphan templates: 0 +``` + +**Verified:** all files compile; smoke test passes on a fresh DB (every API endpoint + key UI route returns +< 500); `db.metadata` no longer registers `group`/`group_content`; app boots against a migrated copy of the +real `dashboard.db` with all data intact; `app.blueprints` no longer contains `playlist`. + +> ⚠️ **Still open:** the real `data/instance/dashboard.db` predates the `original_filename` migration. +> On startup the entrypoint now applies it automatically. To fix locally, run: +> ``` +> DATABASE_URL=sqlite:///$PWD/data/instance/dashboard.db ./.venv/bin/python migrations/add_original_filename_to_content.py +> ``` + +--- + +## Section A — Dead modules (zero importers) + +| ID | Target | LOC | Evidence | Risk | +|---|---|---|---|---| +| **A1** | `app/blueprints/content_old.py` | 500 | `app.py` imports `content.py`; this file's `content_bp` is **never registered**. Superseded "old" content workflow. | **Low** | +| **A2** | `app/utils/nginx_config_reader.py` | 120 | Never imported anywhere. Stack migrated nginx → Caddy, so the reader is obsolete. | **Low** | + +```mermaid +graph LR + app_py["app.py
register_blueprints()"] --> content["content.py
content_bp ✅ ACTIVE"] + content_old["content_old.py
content_bp ❌ DEAD"] -.->|never imported| x1[" "] + nginx["nginx_config_reader.py
❌ DEAD"] -.->|never imported| x2[" "] + caddy["caddy_manager.py
✅ ACTIVE"] --> app_py + style content_old fill:#7f1d1d,color:#fff + style nginx fill:#7f1d1d,color:#fff +``` + +--- + +## Section B — Legacy duplicate route surface — ✅ **REMOVED** + +Two blueprints exposed **parallel implementations of the same operations**. Only the `content.*` +versions were wired to the UI; the legacy ones had no template references. + +| ID | Target | LOC | Evidence | Risk | +|---|---|---|---|---| +| **B1** ✅ | `app/blueprints/playlist.py` (whole file + registration) | 310 | Entire file was legacy per-player playlist. Its `manage_playlist` route did nothing but **redirect** to the modern `content.manage_playlist_content`. The other 6 routes had **no template reference**. | **Low–Med** | +| **B2** ✅ | 3 routes in `players.py`: `reorder_content`, `reorder_playlist`, `remove_from_playlist` | ~103 | Superseded by `content.*`. Two queried nonexistent columns (`Content.player_id`, `.position`, `Player.playlist_version`) → guaranteed HTTP 500 if called. | **Low** | + +**Duplicate operation matrix** + +| Operation | Modern (LIVE) | Legacy (DEAD) | +|---|---|---| +| Add content | `content.add_content_to_playlist` | `playlist.add_to_playlist` | +| Remove content | `content.remove_content_from_playlist` | `playlist.remove_from_playlist`, `players.remove_from_playlist` ⚠️broken | +| Reorder | `content.reorder_playlist_content` | `playlist.reorder_playlist`, `players.reorder_content` ⚠️broken, `players.reorder_playlist` ⚠️broken | +| Set duration | `content.update_playlist_content_duration` | `playlist.update_duration` | +| Mute audio | `content.update_playlist_content_muted` | `playlist.update_muted` | +| Toggle edit | `content.update_playlist_content_edit_enabled` | — | +| Clear | — | `playlist.clear_playlist` | + +--- + +## Section C — Broken code: references to columns that do not exist + +Confirmed against the live DB schema. These raised `AttributeError`/`OperationalError` at runtime. +**All resolved by deleting A1 + B2.** + +| ID | Location | Broken reference | Reachable? | +|---|---|---|---| +| **C1** ✅ | `players.py:768,775` (`remove_from_playlist`) | `player.playlist_version` | Via route only (no UI link) | +| **C2** ✅ | `players.py:~700` (`reorder_playlist`) | `Content.player_id`, `Content.position` | Via route only (no UI link) | +| **C3** | `content_old.py:189-190` | `player.playlist_version` | No (dead module) | +| **C4** | `content_old.py:50,57` | `content.player_id`, `player.group` | No (dead module) | + +> ✅ **Resolved by deleting A1 + B2.** If you keep those files, they must be rewritten. + +**Not a bug (verified by hand):** `Content._playlist_duration` / +`._playlist_position` / `._playlist_muted` in `api.py` are set **dynamically** by +`Playlist.get_content_ordered()`. These are intentional and work correctly — the +static analyzer flags them because it only sees model class attributes. + +--- + +## Section D — Legacy groups subsystem + +Groups are fully deprecated (0 rows, `/api/groups` already commented out), but the code lingers. + +| ID | Target | LOC | Evidence | Risk | +|---|---|---|---|---| +| **D1** | 5 group functions in `app/utils/group_player_management.py`: `get_group_statistics`, `assign_player_to_group`, `bulk_assign_players_to_group`, `get_online_players_count`, `get_players_by_status` + their `__init__.py` exports | ~130 | **Zero callers outside the module.** All three group functions reference `player.group_id`, **which does not exist** → broken. | **Low** | +| **D2** | `app/models/group.py` (Group model + `group_content` table) | 71 | Retained only because `Content.groups` FK relationship and `Content.group_count` reference it. Requires touching the Content model. | **Medium** | + +> ⚠️ **Keep:** `get_player_status_info()` (top of the same file) is **live** — used at +> `players.py:28` and `players.py:432`. Only the group functions should go. + +--- + +## Section E — Legacy redirect stubs + +Thin compatibility shims that only redirect to the modern UI. They're harmless but keep dead +URL surface alive. + +| ID | Target | Evidence | +|---|---|---| +| **E1** | `players.player_page` (`/players/`) | Body is a single `redirect(url_for('players.manage_player'))`. Still `url_for`-referenced by 2 templates, so keeping it is fine. | +| **E2** | `playlist.manage_playlist` (`/playlist/`) | Part of B1 — already covered by deleting that file. | + +--- + +## Section F — Orphan templates / assets + +| ID | Target | LOC | Evidence | +|---|---|---|---| +| — | (none) | — | After the B1/B2 pass the codebase has **0 orphan templates**. | + +--- + +## Recommended batches — ✅ **ALL APPLIED** + +| Batch | Contents | Total removed | Status | +|---|---|---|---| +| **Batch 1 — Safe clean** | **A1, A2** | ~620 LOC | ✅ done | +| **Batch 2 — Legacy playlist** | **B1, B2** | ~413 LOC | ✅ done | +| **Batch 3 — Groups** | **D1** | ~130 LOC | ✅ done | +| **Batch 4 — Group model** | **D2** | ~71 LOC | ✅ done | + +**Every batch was followed by:** compile-all + app-factory boot + route smoke test, so hidden +dependencies were caught before moving on. + +--- + +## How I verified this (so you can trust it) + +| Check | Method | +|---|---| +| Module reachability | AST import extraction + `register_blueprint` cross-reference | +| Route usage | `url_for('endpoint')` **and** literal path matching against all templates/JS | +| Broken columns | Compared every `var.attr` access against live SQLite `PRAGMA table_info` | +| Dynamic attributes | Manually inspected `get_content_ordered()` to rule out false positives | +| Duplicate bodies | `ast.dump` body hashing across all functions (result: 0 exact duplicates) | + +**Reproduce anytime:** +``` +./.venv/bin/python docs/tools/sanitize_report.py # dead code + broken refs +./.venv/bin/python docs/tools/sanitize_audit.py # full function inventory +./.venv/bin/python docs/tools/sanitize_templates.py # orphan templates +./.venv/bin/python docs/tools/smoke_test.py # post-change smoke test +``` + +--- + +## ⚠️ Rollback / hygiene notes + +1. **`docs/legacy code/` is excluded from the Docker image** (via `legacy code/` and + `**/legacy code/` in `.dockerignore`) so it never + ships to production or bloats the build. +2. It is **not** git-ignored, so it will appear in `git status`. Decide: + - commit it as a recovery point, or + - add `legacy code/` to `.gitignore` if you'd rather rely on git history. +3. Deleting files listed here is **not** recoverable from git unless committed first — the + `docs/legacy code/` copy is your safety net. diff --git a/graphify-out/COMPASS.md b/docs/graphify-out/COMPASS.md similarity index 100% rename from graphify-out/COMPASS.md rename to docs/graphify-out/COMPASS.md diff --git a/graphify-out/DOMAINS.md b/docs/graphify-out/DOMAINS.md similarity index 100% rename from graphify-out/DOMAINS.md rename to docs/graphify-out/DOMAINS.md diff --git a/graphify-out/GRAPH_REPORT.md b/docs/graphify-out/GRAPH_REPORT.md similarity index 100% rename from graphify-out/GRAPH_REPORT.md rename to docs/graphify-out/GRAPH_REPORT.md diff --git a/graphify-out/cache/00c752d7501e4f21c01d224dc9502e315268839584af12a578a8cd601e89352a.json b/docs/graphify-out/cache/00c752d7501e4f21c01d224dc9502e315268839584af12a578a8cd601e89352a.json similarity index 100% rename from graphify-out/cache/00c752d7501e4f21c01d224dc9502e315268839584af12a578a8cd601e89352a.json rename to docs/graphify-out/cache/00c752d7501e4f21c01d224dc9502e315268839584af12a578a8cd601e89352a.json diff --git a/graphify-out/cache/042d4128648eb65ae565150559ae133cc2480db6100242b19a76ab0969c6ff6a.json b/docs/graphify-out/cache/042d4128648eb65ae565150559ae133cc2480db6100242b19a76ab0969c6ff6a.json similarity index 100% rename from graphify-out/cache/042d4128648eb65ae565150559ae133cc2480db6100242b19a76ab0969c6ff6a.json rename to docs/graphify-out/cache/042d4128648eb65ae565150559ae133cc2480db6100242b19a76ab0969c6ff6a.json diff --git a/graphify-out/cache/08a5cf60c9886d9492c059388f7794bf8d243cb7f83a010863fe16a728a2872d.json b/docs/graphify-out/cache/08a5cf60c9886d9492c059388f7794bf8d243cb7f83a010863fe16a728a2872d.json similarity index 100% rename from graphify-out/cache/08a5cf60c9886d9492c059388f7794bf8d243cb7f83a010863fe16a728a2872d.json rename to docs/graphify-out/cache/08a5cf60c9886d9492c059388f7794bf8d243cb7f83a010863fe16a728a2872d.json diff --git a/graphify-out/cache/09751e1c4260d14e761c1c92739beeb73e4fc379c785381a8c17c608031319ad.json b/docs/graphify-out/cache/09751e1c4260d14e761c1c92739beeb73e4fc379c785381a8c17c608031319ad.json similarity index 100% rename from graphify-out/cache/09751e1c4260d14e761c1c92739beeb73e4fc379c785381a8c17c608031319ad.json rename to docs/graphify-out/cache/09751e1c4260d14e761c1c92739beeb73e4fc379c785381a8c17c608031319ad.json diff --git a/graphify-out/cache/0bb37d53cd6b63caca5dc4a15bd4971c2abab3724685a1e84ad3c87685cfffe0.json b/docs/graphify-out/cache/0bb37d53cd6b63caca5dc4a15bd4971c2abab3724685a1e84ad3c87685cfffe0.json similarity index 100% rename from graphify-out/cache/0bb37d53cd6b63caca5dc4a15bd4971c2abab3724685a1e84ad3c87685cfffe0.json rename to docs/graphify-out/cache/0bb37d53cd6b63caca5dc4a15bd4971c2abab3724685a1e84ad3c87685cfffe0.json diff --git a/graphify-out/cache/0d890c23b5d5b491833873e1886e21b293e8dc401f1035c831188299b857e647.json b/docs/graphify-out/cache/0d890c23b5d5b491833873e1886e21b293e8dc401f1035c831188299b857e647.json similarity index 100% rename from graphify-out/cache/0d890c23b5d5b491833873e1886e21b293e8dc401f1035c831188299b857e647.json rename to docs/graphify-out/cache/0d890c23b5d5b491833873e1886e21b293e8dc401f1035c831188299b857e647.json diff --git a/graphify-out/cache/0e9c4183e70fcfb3057a320236d68c89d4ccbc8d2beb69b6eb7f6d33657fbdaa.json b/docs/graphify-out/cache/0e9c4183e70fcfb3057a320236d68c89d4ccbc8d2beb69b6eb7f6d33657fbdaa.json similarity index 100% rename from graphify-out/cache/0e9c4183e70fcfb3057a320236d68c89d4ccbc8d2beb69b6eb7f6d33657fbdaa.json rename to docs/graphify-out/cache/0e9c4183e70fcfb3057a320236d68c89d4ccbc8d2beb69b6eb7f6d33657fbdaa.json diff --git a/graphify-out/cache/12d5cdd758d9915b1802a2a22356441b35877bca1373f23a417e124ad1d52b81.json b/docs/graphify-out/cache/12d5cdd758d9915b1802a2a22356441b35877bca1373f23a417e124ad1d52b81.json similarity index 100% rename from graphify-out/cache/12d5cdd758d9915b1802a2a22356441b35877bca1373f23a417e124ad1d52b81.json rename to docs/graphify-out/cache/12d5cdd758d9915b1802a2a22356441b35877bca1373f23a417e124ad1d52b81.json diff --git a/graphify-out/cache/1501c3d3f8886e1933d28b2a197a9391ffee713b2c874f1d3d7df8617ad3dd61.json b/docs/graphify-out/cache/1501c3d3f8886e1933d28b2a197a9391ffee713b2c874f1d3d7df8617ad3dd61.json similarity index 100% rename from graphify-out/cache/1501c3d3f8886e1933d28b2a197a9391ffee713b2c874f1d3d7df8617ad3dd61.json rename to docs/graphify-out/cache/1501c3d3f8886e1933d28b2a197a9391ffee713b2c874f1d3d7df8617ad3dd61.json diff --git a/graphify-out/cache/18597a9be9d9cc7cda10c6c5ca2af5452c2aba1e167d2cbd7dc50ee5ba1b3ac1.json b/docs/graphify-out/cache/18597a9be9d9cc7cda10c6c5ca2af5452c2aba1e167d2cbd7dc50ee5ba1b3ac1.json similarity index 100% rename from graphify-out/cache/18597a9be9d9cc7cda10c6c5ca2af5452c2aba1e167d2cbd7dc50ee5ba1b3ac1.json rename to docs/graphify-out/cache/18597a9be9d9cc7cda10c6c5ca2af5452c2aba1e167d2cbd7dc50ee5ba1b3ac1.json diff --git a/graphify-out/cache/20c121d89d9bd3f9d9e628bd136f9936896684dfc69044c6f4d7313722d061a3.json b/docs/graphify-out/cache/20c121d89d9bd3f9d9e628bd136f9936896684dfc69044c6f4d7313722d061a3.json similarity index 100% rename from graphify-out/cache/20c121d89d9bd3f9d9e628bd136f9936896684dfc69044c6f4d7313722d061a3.json rename to docs/graphify-out/cache/20c121d89d9bd3f9d9e628bd136f9936896684dfc69044c6f4d7313722d061a3.json diff --git a/graphify-out/cache/2671cc27511abcc87eaa7b5b3a211c40e0e26f5a3bac58ea05f30a4e2f399e10.json b/docs/graphify-out/cache/2671cc27511abcc87eaa7b5b3a211c40e0e26f5a3bac58ea05f30a4e2f399e10.json similarity index 100% rename from graphify-out/cache/2671cc27511abcc87eaa7b5b3a211c40e0e26f5a3bac58ea05f30a4e2f399e10.json rename to docs/graphify-out/cache/2671cc27511abcc87eaa7b5b3a211c40e0e26f5a3bac58ea05f30a4e2f399e10.json diff --git a/graphify-out/cache/3469a90f710fb95efbe31b435d75759e120ff6a7c45fcb66ee0c76ab39ff9de0.json b/docs/graphify-out/cache/3469a90f710fb95efbe31b435d75759e120ff6a7c45fcb66ee0c76ab39ff9de0.json similarity index 100% rename from graphify-out/cache/3469a90f710fb95efbe31b435d75759e120ff6a7c45fcb66ee0c76ab39ff9de0.json rename to docs/graphify-out/cache/3469a90f710fb95efbe31b435d75759e120ff6a7c45fcb66ee0c76ab39ff9de0.json diff --git a/graphify-out/cache/3b8625d015b1a280c46a5204b1bf03a30dbd6594d98507911a0aac9592fb95fa.json b/docs/graphify-out/cache/3b8625d015b1a280c46a5204b1bf03a30dbd6594d98507911a0aac9592fb95fa.json similarity index 100% rename from graphify-out/cache/3b8625d015b1a280c46a5204b1bf03a30dbd6594d98507911a0aac9592fb95fa.json rename to docs/graphify-out/cache/3b8625d015b1a280c46a5204b1bf03a30dbd6594d98507911a0aac9592fb95fa.json diff --git a/graphify-out/cache/3f43ab8f52d2063aca83d66e112905cda95dace56d5e375d16524feb8bd71130.json b/docs/graphify-out/cache/3f43ab8f52d2063aca83d66e112905cda95dace56d5e375d16524feb8bd71130.json similarity index 100% rename from graphify-out/cache/3f43ab8f52d2063aca83d66e112905cda95dace56d5e375d16524feb8bd71130.json rename to docs/graphify-out/cache/3f43ab8f52d2063aca83d66e112905cda95dace56d5e375d16524feb8bd71130.json diff --git a/graphify-out/cache/411f80599b152bba1a94805fd4e008df52caf0408e566ba195928df3609c4b1f.json b/docs/graphify-out/cache/411f80599b152bba1a94805fd4e008df52caf0408e566ba195928df3609c4b1f.json similarity index 100% rename from graphify-out/cache/411f80599b152bba1a94805fd4e008df52caf0408e566ba195928df3609c4b1f.json rename to docs/graphify-out/cache/411f80599b152bba1a94805fd4e008df52caf0408e566ba195928df3609c4b1f.json diff --git a/graphify-out/cache/491cfde76df2f5a2c11098906bc9688e4c0fa8ee795ee268bf5f1a44d22ae14d.json b/docs/graphify-out/cache/491cfde76df2f5a2c11098906bc9688e4c0fa8ee795ee268bf5f1a44d22ae14d.json similarity index 100% rename from graphify-out/cache/491cfde76df2f5a2c11098906bc9688e4c0fa8ee795ee268bf5f1a44d22ae14d.json rename to docs/graphify-out/cache/491cfde76df2f5a2c11098906bc9688e4c0fa8ee795ee268bf5f1a44d22ae14d.json diff --git a/graphify-out/cache/54c5a969dbb46306d6bdea71b89e30b9b8ed1fa595e4784a31bccc22d8a15283.json b/docs/graphify-out/cache/54c5a969dbb46306d6bdea71b89e30b9b8ed1fa595e4784a31bccc22d8a15283.json similarity index 100% rename from graphify-out/cache/54c5a969dbb46306d6bdea71b89e30b9b8ed1fa595e4784a31bccc22d8a15283.json rename to docs/graphify-out/cache/54c5a969dbb46306d6bdea71b89e30b9b8ed1fa595e4784a31bccc22d8a15283.json diff --git a/graphify-out/cache/608145ad2566fdfba758de0f07bb991a1112e0ffb951d01baa8cf1804a498c0d.json b/docs/graphify-out/cache/608145ad2566fdfba758de0f07bb991a1112e0ffb951d01baa8cf1804a498c0d.json similarity index 100% rename from graphify-out/cache/608145ad2566fdfba758de0f07bb991a1112e0ffb951d01baa8cf1804a498c0d.json rename to docs/graphify-out/cache/608145ad2566fdfba758de0f07bb991a1112e0ffb951d01baa8cf1804a498c0d.json diff --git a/graphify-out/cache/612e0be7d395f7f7e094377582cc8b6d6885693daf76326d8b78d5770191a6f7.json b/docs/graphify-out/cache/612e0be7d395f7f7e094377582cc8b6d6885693daf76326d8b78d5770191a6f7.json similarity index 100% rename from graphify-out/cache/612e0be7d395f7f7e094377582cc8b6d6885693daf76326d8b78d5770191a6f7.json rename to docs/graphify-out/cache/612e0be7d395f7f7e094377582cc8b6d6885693daf76326d8b78d5770191a6f7.json diff --git a/graphify-out/cache/63296783faeac5e527ca5d566e4c512618bab85b8625e11094bf73518e244e7d.json b/docs/graphify-out/cache/63296783faeac5e527ca5d566e4c512618bab85b8625e11094bf73518e244e7d.json similarity index 100% rename from graphify-out/cache/63296783faeac5e527ca5d566e4c512618bab85b8625e11094bf73518e244e7d.json rename to docs/graphify-out/cache/63296783faeac5e527ca5d566e4c512618bab85b8625e11094bf73518e244e7d.json diff --git a/graphify-out/cache/665b013b4c1a402ec5b42e483132968007d21885a99350ebaf2f6f37e5ae7344.json b/docs/graphify-out/cache/665b013b4c1a402ec5b42e483132968007d21885a99350ebaf2f6f37e5ae7344.json similarity index 100% rename from graphify-out/cache/665b013b4c1a402ec5b42e483132968007d21885a99350ebaf2f6f37e5ae7344.json rename to docs/graphify-out/cache/665b013b4c1a402ec5b42e483132968007d21885a99350ebaf2f6f37e5ae7344.json diff --git a/graphify-out/cache/6d45e8b056d173e58b56c72185c021327a9cd826aaa6779d7cadfdf855a49df8.json b/docs/graphify-out/cache/6d45e8b056d173e58b56c72185c021327a9cd826aaa6779d7cadfdf855a49df8.json similarity index 100% rename from graphify-out/cache/6d45e8b056d173e58b56c72185c021327a9cd826aaa6779d7cadfdf855a49df8.json rename to docs/graphify-out/cache/6d45e8b056d173e58b56c72185c021327a9cd826aaa6779d7cadfdf855a49df8.json diff --git a/graphify-out/cache/6f95b0a1e8563634e78f6fc811340239de0d21d0b7268cf7b3872410adc7ea02.json b/docs/graphify-out/cache/6f95b0a1e8563634e78f6fc811340239de0d21d0b7268cf7b3872410adc7ea02.json similarity index 100% rename from graphify-out/cache/6f95b0a1e8563634e78f6fc811340239de0d21d0b7268cf7b3872410adc7ea02.json rename to docs/graphify-out/cache/6f95b0a1e8563634e78f6fc811340239de0d21d0b7268cf7b3872410adc7ea02.json diff --git a/graphify-out/cache/70079e68521808a26af122322a5035b09c6b92330bc3ec6eca7ce7b46b61db01.json b/docs/graphify-out/cache/70079e68521808a26af122322a5035b09c6b92330bc3ec6eca7ce7b46b61db01.json similarity index 100% rename from graphify-out/cache/70079e68521808a26af122322a5035b09c6b92330bc3ec6eca7ce7b46b61db01.json rename to docs/graphify-out/cache/70079e68521808a26af122322a5035b09c6b92330bc3ec6eca7ce7b46b61db01.json diff --git a/graphify-out/cache/70d403dd4a07c9f6faa62f365eee454669ef17b201b21cd8b87dfbb5a6e18c7e.json b/docs/graphify-out/cache/70d403dd4a07c9f6faa62f365eee454669ef17b201b21cd8b87dfbb5a6e18c7e.json similarity index 100% rename from graphify-out/cache/70d403dd4a07c9f6faa62f365eee454669ef17b201b21cd8b87dfbb5a6e18c7e.json rename to docs/graphify-out/cache/70d403dd4a07c9f6faa62f365eee454669ef17b201b21cd8b87dfbb5a6e18c7e.json diff --git a/graphify-out/cache/721cf1d5064ca6adef6b46ae756ac7a71ebb513854b2d69db2e7ff4451064c38.json b/docs/graphify-out/cache/721cf1d5064ca6adef6b46ae756ac7a71ebb513854b2d69db2e7ff4451064c38.json similarity index 100% rename from graphify-out/cache/721cf1d5064ca6adef6b46ae756ac7a71ebb513854b2d69db2e7ff4451064c38.json rename to docs/graphify-out/cache/721cf1d5064ca6adef6b46ae756ac7a71ebb513854b2d69db2e7ff4451064c38.json diff --git a/graphify-out/cache/72d6c067ccd14bc578f9e7d296257ad3580b9b694089332468b2a86a8707a5e6.json b/docs/graphify-out/cache/72d6c067ccd14bc578f9e7d296257ad3580b9b694089332468b2a86a8707a5e6.json similarity index 100% rename from graphify-out/cache/72d6c067ccd14bc578f9e7d296257ad3580b9b694089332468b2a86a8707a5e6.json rename to docs/graphify-out/cache/72d6c067ccd14bc578f9e7d296257ad3580b9b694089332468b2a86a8707a5e6.json diff --git a/graphify-out/cache/7e9eb88eefb8fb0239cff4b84ead4bbcdfb6a3be1dbcc1beb1b011902a262372.json b/docs/graphify-out/cache/7e9eb88eefb8fb0239cff4b84ead4bbcdfb6a3be1dbcc1beb1b011902a262372.json similarity index 100% rename from graphify-out/cache/7e9eb88eefb8fb0239cff4b84ead4bbcdfb6a3be1dbcc1beb1b011902a262372.json rename to docs/graphify-out/cache/7e9eb88eefb8fb0239cff4b84ead4bbcdfb6a3be1dbcc1beb1b011902a262372.json diff --git a/graphify-out/cache/832df44b668a28c2d8d13244d2ca215118b49872ca0318674e2e11d9cc4e8e33.json b/docs/graphify-out/cache/832df44b668a28c2d8d13244d2ca215118b49872ca0318674e2e11d9cc4e8e33.json similarity index 100% rename from graphify-out/cache/832df44b668a28c2d8d13244d2ca215118b49872ca0318674e2e11d9cc4e8e33.json rename to docs/graphify-out/cache/832df44b668a28c2d8d13244d2ca215118b49872ca0318674e2e11d9cc4e8e33.json diff --git a/graphify-out/cache/8c377ef6943874b4f9399d90457309e27c3e0fa9b678ec3731ef6bb7a1fb003d.json b/docs/graphify-out/cache/8c377ef6943874b4f9399d90457309e27c3e0fa9b678ec3731ef6bb7a1fb003d.json similarity index 100% rename from graphify-out/cache/8c377ef6943874b4f9399d90457309e27c3e0fa9b678ec3731ef6bb7a1fb003d.json rename to docs/graphify-out/cache/8c377ef6943874b4f9399d90457309e27c3e0fa9b678ec3731ef6bb7a1fb003d.json diff --git a/graphify-out/cache/8f5403ff3219caaadd37b30ed9eb3e9380d8503b84236a13c437e9d180f0cb96.json b/docs/graphify-out/cache/8f5403ff3219caaadd37b30ed9eb3e9380d8503b84236a13c437e9d180f0cb96.json similarity index 100% rename from graphify-out/cache/8f5403ff3219caaadd37b30ed9eb3e9380d8503b84236a13c437e9d180f0cb96.json rename to docs/graphify-out/cache/8f5403ff3219caaadd37b30ed9eb3e9380d8503b84236a13c437e9d180f0cb96.json diff --git a/graphify-out/cache/94b871546d6e8026e3724312a5df840b7e41201dd7eaeb689b6f76385a43d9fa.json b/docs/graphify-out/cache/94b871546d6e8026e3724312a5df840b7e41201dd7eaeb689b6f76385a43d9fa.json similarity index 100% rename from graphify-out/cache/94b871546d6e8026e3724312a5df840b7e41201dd7eaeb689b6f76385a43d9fa.json rename to docs/graphify-out/cache/94b871546d6e8026e3724312a5df840b7e41201dd7eaeb689b6f76385a43d9fa.json diff --git a/graphify-out/cache/95225fa585aaaa6930654f195820f5f5a55452aface45bd4bfadfae2b3a8f88e.json b/docs/graphify-out/cache/95225fa585aaaa6930654f195820f5f5a55452aface45bd4bfadfae2b3a8f88e.json similarity index 100% rename from graphify-out/cache/95225fa585aaaa6930654f195820f5f5a55452aface45bd4bfadfae2b3a8f88e.json rename to docs/graphify-out/cache/95225fa585aaaa6930654f195820f5f5a55452aface45bd4bfadfae2b3a8f88e.json diff --git a/graphify-out/cache/9679855b5811e0c58a1863b53dd94b04fe125bc5350d3a7a71dd56104773cb3d.json b/docs/graphify-out/cache/9679855b5811e0c58a1863b53dd94b04fe125bc5350d3a7a71dd56104773cb3d.json similarity index 100% rename from graphify-out/cache/9679855b5811e0c58a1863b53dd94b04fe125bc5350d3a7a71dd56104773cb3d.json rename to docs/graphify-out/cache/9679855b5811e0c58a1863b53dd94b04fe125bc5350d3a7a71dd56104773cb3d.json diff --git a/graphify-out/cache/9e6363e24f41f6353bcf1ce28d815cd8eae43490098b886b401354636a3f48c1.json b/docs/graphify-out/cache/9e6363e24f41f6353bcf1ce28d815cd8eae43490098b886b401354636a3f48c1.json similarity index 100% rename from graphify-out/cache/9e6363e24f41f6353bcf1ce28d815cd8eae43490098b886b401354636a3f48c1.json rename to docs/graphify-out/cache/9e6363e24f41f6353bcf1ce28d815cd8eae43490098b886b401354636a3f48c1.json diff --git a/graphify-out/cache/9ed6dd2b6b7d5c5732370642e8bbde45777a3e62638e323c54e54a208cc0ceb9.json b/docs/graphify-out/cache/9ed6dd2b6b7d5c5732370642e8bbde45777a3e62638e323c54e54a208cc0ceb9.json similarity index 100% rename from graphify-out/cache/9ed6dd2b6b7d5c5732370642e8bbde45777a3e62638e323c54e54a208cc0ceb9.json rename to docs/graphify-out/cache/9ed6dd2b6b7d5c5732370642e8bbde45777a3e62638e323c54e54a208cc0ceb9.json diff --git a/graphify-out/cache/ad7bb5459ac32cdb614600426f4867f8407bc53a7da9b984cb11f650ec4e6cdc.json b/docs/graphify-out/cache/ad7bb5459ac32cdb614600426f4867f8407bc53a7da9b984cb11f650ec4e6cdc.json similarity index 100% rename from graphify-out/cache/ad7bb5459ac32cdb614600426f4867f8407bc53a7da9b984cb11f650ec4e6cdc.json rename to docs/graphify-out/cache/ad7bb5459ac32cdb614600426f4867f8407bc53a7da9b984cb11f650ec4e6cdc.json diff --git a/graphify-out/cache/aee7b0072c56a7ac40e1f9b6edeb91decd6905add26d7d70339490f61c6ed55e.json b/docs/graphify-out/cache/aee7b0072c56a7ac40e1f9b6edeb91decd6905add26d7d70339490f61c6ed55e.json similarity index 100% rename from graphify-out/cache/aee7b0072c56a7ac40e1f9b6edeb91decd6905add26d7d70339490f61c6ed55e.json rename to docs/graphify-out/cache/aee7b0072c56a7ac40e1f9b6edeb91decd6905add26d7d70339490f61c6ed55e.json diff --git a/graphify-out/cache/c02348bf53b23706585a139f3c76b042a55b2bfc11993ca93dc3b3d93aff2171.json b/docs/graphify-out/cache/c02348bf53b23706585a139f3c76b042a55b2bfc11993ca93dc3b3d93aff2171.json similarity index 100% rename from graphify-out/cache/c02348bf53b23706585a139f3c76b042a55b2bfc11993ca93dc3b3d93aff2171.json rename to docs/graphify-out/cache/c02348bf53b23706585a139f3c76b042a55b2bfc11993ca93dc3b3d93aff2171.json diff --git a/graphify-out/cache/c21c4ad41a2b8570bd490d9d356635023bb861e9a4ad9374a72770d517c4af01.json b/docs/graphify-out/cache/c21c4ad41a2b8570bd490d9d356635023bb861e9a4ad9374a72770d517c4af01.json similarity index 100% rename from graphify-out/cache/c21c4ad41a2b8570bd490d9d356635023bb861e9a4ad9374a72770d517c4af01.json rename to docs/graphify-out/cache/c21c4ad41a2b8570bd490d9d356635023bb861e9a4ad9374a72770d517c4af01.json diff --git a/graphify-out/cache/c39c170bcc774e4d740bfdda460626bd2a97367d11d7036ec90cf433711da35c.json b/docs/graphify-out/cache/c39c170bcc774e4d740bfdda460626bd2a97367d11d7036ec90cf433711da35c.json similarity index 100% rename from graphify-out/cache/c39c170bcc774e4d740bfdda460626bd2a97367d11d7036ec90cf433711da35c.json rename to docs/graphify-out/cache/c39c170bcc774e4d740bfdda460626bd2a97367d11d7036ec90cf433711da35c.json diff --git a/graphify-out/cache/cab5c69ad0a9bc401a139322633e09433c77c789d68c2537450572b22a83c358.json b/docs/graphify-out/cache/cab5c69ad0a9bc401a139322633e09433c77c789d68c2537450572b22a83c358.json similarity index 100% rename from graphify-out/cache/cab5c69ad0a9bc401a139322633e09433c77c789d68c2537450572b22a83c358.json rename to docs/graphify-out/cache/cab5c69ad0a9bc401a139322633e09433c77c789d68c2537450572b22a83c358.json diff --git a/graphify-out/cache/d0eb445d5e223a6d1f0734ddbdf56340911f28c239f5d8a210132ec739e97f39.json b/docs/graphify-out/cache/d0eb445d5e223a6d1f0734ddbdf56340911f28c239f5d8a210132ec739e97f39.json similarity index 100% rename from graphify-out/cache/d0eb445d5e223a6d1f0734ddbdf56340911f28c239f5d8a210132ec739e97f39.json rename to docs/graphify-out/cache/d0eb445d5e223a6d1f0734ddbdf56340911f28c239f5d8a210132ec739e97f39.json diff --git a/graphify-out/cache/d3ad3de1947047e09b4bfa267a9d250f771f3554bede74f62bea2661952edf36.json b/docs/graphify-out/cache/d3ad3de1947047e09b4bfa267a9d250f771f3554bede74f62bea2661952edf36.json similarity index 100% rename from graphify-out/cache/d3ad3de1947047e09b4bfa267a9d250f771f3554bede74f62bea2661952edf36.json rename to docs/graphify-out/cache/d3ad3de1947047e09b4bfa267a9d250f771f3554bede74f62bea2661952edf36.json diff --git a/graphify-out/cache/d867bca7b49b4ef6deca0fcdf7c0b06c4cd695fd9d4d74aeb59699c9335f40ff.json b/docs/graphify-out/cache/d867bca7b49b4ef6deca0fcdf7c0b06c4cd695fd9d4d74aeb59699c9335f40ff.json similarity index 100% rename from graphify-out/cache/d867bca7b49b4ef6deca0fcdf7c0b06c4cd695fd9d4d74aeb59699c9335f40ff.json rename to docs/graphify-out/cache/d867bca7b49b4ef6deca0fcdf7c0b06c4cd695fd9d4d74aeb59699c9335f40ff.json diff --git a/graphify-out/cache/eb1a7845d5c86e98e5d26d1cc50eef375eb00ceba72338a37beff6b47688835c.json b/docs/graphify-out/cache/eb1a7845d5c86e98e5d26d1cc50eef375eb00ceba72338a37beff6b47688835c.json similarity index 100% rename from graphify-out/cache/eb1a7845d5c86e98e5d26d1cc50eef375eb00ceba72338a37beff6b47688835c.json rename to docs/graphify-out/cache/eb1a7845d5c86e98e5d26d1cc50eef375eb00ceba72338a37beff6b47688835c.json diff --git a/graphify-out/cache/f7dc1ac90509b8b858c474ecba2f13a5f795e20640cd932236c89c81e2eb770a.json b/docs/graphify-out/cache/f7dc1ac90509b8b858c474ecba2f13a5f795e20640cd932236c89c81e2eb770a.json similarity index 100% rename from graphify-out/cache/f7dc1ac90509b8b858c474ecba2f13a5f795e20640cd932236c89c81e2eb770a.json rename to docs/graphify-out/cache/f7dc1ac90509b8b858c474ecba2f13a5f795e20640cd932236c89c81e2eb770a.json diff --git a/graphify-out/cache/fc189becb472d9d42e7a5e27ed9ccdb939442755123cfecce7b7e1bc92b57892.json b/docs/graphify-out/cache/fc189becb472d9d42e7a5e27ed9ccdb939442755123cfecce7b7e1bc92b57892.json similarity index 100% rename from graphify-out/cache/fc189becb472d9d42e7a5e27ed9ccdb939442755123cfecce7b7e1bc92b57892.json rename to docs/graphify-out/cache/fc189becb472d9d42e7a5e27ed9ccdb939442755123cfecce7b7e1bc92b57892.json diff --git a/graphify-out/graph.compact.txt b/docs/graphify-out/graph.compact.txt similarity index 100% rename from graphify-out/graph.compact.txt rename to docs/graphify-out/graph.compact.txt diff --git a/graphify-out/graph.html b/docs/graphify-out/graph.html similarity index 100% rename from graphify-out/graph.html rename to docs/graphify-out/graph.html diff --git a/graphify-out/graph.json b/docs/graphify-out/graph.json similarity index 100% rename from graphify-out/graph.json rename to docs/graphify-out/graph.json diff --git a/graphify-out/intelligence.json b/docs/graphify-out/intelligence.json similarity index 100% rename from graphify-out/intelligence.json rename to docs/graphify-out/intelligence.json diff --git a/graphify-out/metadata.json b/docs/graphify-out/metadata.json similarity index 100% rename from graphify-out/metadata.json rename to docs/graphify-out/metadata.json diff --git a/graphify-out/suggestions.json b/docs/graphify-out/suggestions.json similarity index 100% rename from graphify-out/suggestions.json rename to docs/graphify-out/suggestions.json diff --git a/graphify-out/wiki/CaddyConfigGenerator.md b/docs/graphify-out/wiki/CaddyConfigGenerator.md similarity index 100% rename from graphify-out/wiki/CaddyConfigGenerator.md rename to docs/graphify-out/wiki/CaddyConfigGenerator.md diff --git a/graphify-out/wiki/Community_0.md b/docs/graphify-out/wiki/Community_0.md similarity index 100% rename from graphify-out/wiki/Community_0.md rename to docs/graphify-out/wiki/Community_0.md diff --git a/graphify-out/wiki/Community_1.md b/docs/graphify-out/wiki/Community_1.md similarity index 100% rename from graphify-out/wiki/Community_1.md rename to docs/graphify-out/wiki/Community_1.md diff --git a/graphify-out/wiki/Community_10.md b/docs/graphify-out/wiki/Community_10.md similarity index 100% rename from graphify-out/wiki/Community_10.md rename to docs/graphify-out/wiki/Community_10.md diff --git a/graphify-out/wiki/Community_11.md b/docs/graphify-out/wiki/Community_11.md similarity index 100% rename from graphify-out/wiki/Community_11.md rename to docs/graphify-out/wiki/Community_11.md diff --git a/graphify-out/wiki/Community_12.md b/docs/graphify-out/wiki/Community_12.md similarity index 100% rename from graphify-out/wiki/Community_12.md rename to docs/graphify-out/wiki/Community_12.md diff --git a/graphify-out/wiki/Community_13.md b/docs/graphify-out/wiki/Community_13.md similarity index 100% rename from graphify-out/wiki/Community_13.md rename to docs/graphify-out/wiki/Community_13.md diff --git a/graphify-out/wiki/Community_14.md b/docs/graphify-out/wiki/Community_14.md similarity index 100% rename from graphify-out/wiki/Community_14.md rename to docs/graphify-out/wiki/Community_14.md diff --git a/graphify-out/wiki/Community_15.md b/docs/graphify-out/wiki/Community_15.md similarity index 100% rename from graphify-out/wiki/Community_15.md rename to docs/graphify-out/wiki/Community_15.md diff --git a/graphify-out/wiki/Community_16.md b/docs/graphify-out/wiki/Community_16.md similarity index 100% rename from graphify-out/wiki/Community_16.md rename to docs/graphify-out/wiki/Community_16.md diff --git a/graphify-out/wiki/Community_17.md b/docs/graphify-out/wiki/Community_17.md similarity index 100% rename from graphify-out/wiki/Community_17.md rename to docs/graphify-out/wiki/Community_17.md diff --git a/graphify-out/wiki/Community_18.md b/docs/graphify-out/wiki/Community_18.md similarity index 100% rename from graphify-out/wiki/Community_18.md rename to docs/graphify-out/wiki/Community_18.md diff --git a/graphify-out/wiki/Community_19.md b/docs/graphify-out/wiki/Community_19.md similarity index 100% rename from graphify-out/wiki/Community_19.md rename to docs/graphify-out/wiki/Community_19.md diff --git a/graphify-out/wiki/Community_2.md b/docs/graphify-out/wiki/Community_2.md similarity index 100% rename from graphify-out/wiki/Community_2.md rename to docs/graphify-out/wiki/Community_2.md diff --git a/graphify-out/wiki/Community_20.md b/docs/graphify-out/wiki/Community_20.md similarity index 100% rename from graphify-out/wiki/Community_20.md rename to docs/graphify-out/wiki/Community_20.md diff --git a/graphify-out/wiki/Community_21.md b/docs/graphify-out/wiki/Community_21.md similarity index 100% rename from graphify-out/wiki/Community_21.md rename to docs/graphify-out/wiki/Community_21.md diff --git a/graphify-out/wiki/Community_22.md b/docs/graphify-out/wiki/Community_22.md similarity index 100% rename from graphify-out/wiki/Community_22.md rename to docs/graphify-out/wiki/Community_22.md diff --git a/graphify-out/wiki/Community_23.md b/docs/graphify-out/wiki/Community_23.md similarity index 100% rename from graphify-out/wiki/Community_23.md rename to docs/graphify-out/wiki/Community_23.md diff --git a/graphify-out/wiki/Community_24.md b/docs/graphify-out/wiki/Community_24.md similarity index 100% rename from graphify-out/wiki/Community_24.md rename to docs/graphify-out/wiki/Community_24.md diff --git a/graphify-out/wiki/Community_25.md b/docs/graphify-out/wiki/Community_25.md similarity index 100% rename from graphify-out/wiki/Community_25.md rename to docs/graphify-out/wiki/Community_25.md diff --git a/graphify-out/wiki/Community_26.md b/docs/graphify-out/wiki/Community_26.md similarity index 100% rename from graphify-out/wiki/Community_26.md rename to docs/graphify-out/wiki/Community_26.md diff --git a/graphify-out/wiki/Community_27.md b/docs/graphify-out/wiki/Community_27.md similarity index 100% rename from graphify-out/wiki/Community_27.md rename to docs/graphify-out/wiki/Community_27.md diff --git a/graphify-out/wiki/Community_28.md b/docs/graphify-out/wiki/Community_28.md similarity index 100% rename from graphify-out/wiki/Community_28.md rename to docs/graphify-out/wiki/Community_28.md diff --git a/graphify-out/wiki/Community_29.md b/docs/graphify-out/wiki/Community_29.md similarity index 100% rename from graphify-out/wiki/Community_29.md rename to docs/graphify-out/wiki/Community_29.md diff --git a/graphify-out/wiki/Community_3.md b/docs/graphify-out/wiki/Community_3.md similarity index 100% rename from graphify-out/wiki/Community_3.md rename to docs/graphify-out/wiki/Community_3.md diff --git a/graphify-out/wiki/Community_30.md b/docs/graphify-out/wiki/Community_30.md similarity index 100% rename from graphify-out/wiki/Community_30.md rename to docs/graphify-out/wiki/Community_30.md diff --git a/graphify-out/wiki/Community_31.md b/docs/graphify-out/wiki/Community_31.md similarity index 100% rename from graphify-out/wiki/Community_31.md rename to docs/graphify-out/wiki/Community_31.md diff --git a/graphify-out/wiki/Community_32.md b/docs/graphify-out/wiki/Community_32.md similarity index 100% rename from graphify-out/wiki/Community_32.md rename to docs/graphify-out/wiki/Community_32.md diff --git a/graphify-out/wiki/Community_33.md b/docs/graphify-out/wiki/Community_33.md similarity index 100% rename from graphify-out/wiki/Community_33.md rename to docs/graphify-out/wiki/Community_33.md diff --git a/graphify-out/wiki/Community_34.md b/docs/graphify-out/wiki/Community_34.md similarity index 100% rename from graphify-out/wiki/Community_34.md rename to docs/graphify-out/wiki/Community_34.md diff --git a/graphify-out/wiki/Community_35.md b/docs/graphify-out/wiki/Community_35.md similarity index 100% rename from graphify-out/wiki/Community_35.md rename to docs/graphify-out/wiki/Community_35.md diff --git a/graphify-out/wiki/Community_36.md b/docs/graphify-out/wiki/Community_36.md similarity index 100% rename from graphify-out/wiki/Community_36.md rename to docs/graphify-out/wiki/Community_36.md diff --git a/graphify-out/wiki/Community_37.md b/docs/graphify-out/wiki/Community_37.md similarity index 100% rename from graphify-out/wiki/Community_37.md rename to docs/graphify-out/wiki/Community_37.md diff --git a/graphify-out/wiki/Community_38.md b/docs/graphify-out/wiki/Community_38.md similarity index 100% rename from graphify-out/wiki/Community_38.md rename to docs/graphify-out/wiki/Community_38.md diff --git a/graphify-out/wiki/Community_39.md b/docs/graphify-out/wiki/Community_39.md similarity index 100% rename from graphify-out/wiki/Community_39.md rename to docs/graphify-out/wiki/Community_39.md diff --git a/graphify-out/wiki/Community_4.md b/docs/graphify-out/wiki/Community_4.md similarity index 100% rename from graphify-out/wiki/Community_4.md rename to docs/graphify-out/wiki/Community_4.md diff --git a/graphify-out/wiki/Community_40.md b/docs/graphify-out/wiki/Community_40.md similarity index 100% rename from graphify-out/wiki/Community_40.md rename to docs/graphify-out/wiki/Community_40.md diff --git a/graphify-out/wiki/Community_5.md b/docs/graphify-out/wiki/Community_5.md similarity index 100% rename from graphify-out/wiki/Community_5.md rename to docs/graphify-out/wiki/Community_5.md diff --git a/graphify-out/wiki/Community_6.md b/docs/graphify-out/wiki/Community_6.md similarity index 100% rename from graphify-out/wiki/Community_6.md rename to docs/graphify-out/wiki/Community_6.md diff --git a/graphify-out/wiki/Community_7.md b/docs/graphify-out/wiki/Community_7.md similarity index 100% rename from graphify-out/wiki/Community_7.md rename to docs/graphify-out/wiki/Community_7.md diff --git a/graphify-out/wiki/Community_8.md b/docs/graphify-out/wiki/Community_8.md similarity index 100% rename from graphify-out/wiki/Community_8.md rename to docs/graphify-out/wiki/Community_8.md diff --git a/graphify-out/wiki/Community_9.md b/docs/graphify-out/wiki/Community_9.md similarity index 100% rename from graphify-out/wiki/Community_9.md rename to docs/graphify-out/wiki/Community_9.md diff --git a/graphify-out/wiki/Content.md b/docs/graphify-out/wiki/Content.md similarity index 100% rename from graphify-out/wiki/Content.md rename to docs/graphify-out/wiki/Content.md diff --git a/graphify-out/wiki/HTTPSConfig.md b/docs/graphify-out/wiki/HTTPSConfig.md similarity index 100% rename from graphify-out/wiki/HTTPSConfig.md rename to docs/graphify-out/wiki/HTTPSConfig.md diff --git a/graphify-out/wiki/Models_package_for_digiserver-v2..md b/docs/graphify-out/wiki/Models_package_for_digiserver-v2..md similarity index 100% rename from graphify-out/wiki/Models_package_for_digiserver-v2..md rename to docs/graphify-out/wiki/Models_package_for_digiserver-v2..md diff --git a/graphify-out/wiki/PlayerEdit.md b/docs/graphify-out/wiki/PlayerEdit.md similarity index 100% rename from graphify-out/wiki/PlayerEdit.md rename to docs/graphify-out/wiki/PlayerEdit.md diff --git a/graphify-out/wiki/PlayerUser.md b/docs/graphify-out/wiki/PlayerUser.md similarity index 100% rename from graphify-out/wiki/PlayerUser.md rename to docs/graphify-out/wiki/PlayerUser.md diff --git a/graphify-out/wiki/Playlist.md b/docs/graphify-out/wiki/Playlist.md similarity index 100% rename from graphify-out/wiki/Playlist.md rename to docs/graphify-out/wiki/Playlist.md diff --git a/graphify-out/wiki/User.md b/docs/graphify-out/wiki/User.md similarity index 100% rename from graphify-out/wiki/User.md rename to docs/graphify-out/wiki/User.md diff --git a/graphify-out/wiki/create_app().md b/docs/graphify-out/wiki/create_app().md similarity index 100% rename from graphify-out/wiki/create_app().md rename to docs/graphify-out/wiki/create_app().md diff --git a/graphify-out/wiki/index.md b/docs/graphify-out/wiki/index.md similarity index 100% rename from graphify-out/wiki/index.md rename to docs/graphify-out/wiki/index.md diff --git a/graphify-out/wiki/log_action().md b/docs/graphify-out/wiki/log_action().md similarity index 100% rename from graphify-out/wiki/log_action().md rename to docs/graphify-out/wiki/log_action().md diff --git a/docs/tools/sanitize_audit.py b/docs/tools/sanitize_audit.py new file mode 100644 index 0000000..417c86d --- /dev/null +++ b/docs/tools/sanitize_audit.py @@ -0,0 +1,314 @@ +"""Static analyser for the digiserver-v2 sanitization pass. + +Inventories every Python function/method/class in the *active* code +(app/ and migrations/), plus routes, and builds a call graph so dead and +broken code can be identified. + +Outputs JSON to stdout when run with --json, otherwise a readable report. + +Usage: + python tools/sanitize_audit.py + python tools/sanitize_audit.py --json > audit.json +""" +from __future__ import annotations + +import argparse +import ast +import json +import os +import re +import sys +from collections import defaultdict + +# Walk up until we find the project root (robust to the tool living in a +# nested folder such as docs/tools/). +_here = os.path.dirname(os.path.abspath(__file__)) +while _here != os.path.dirname(_here) and not os.path.isdir(os.path.join(_here, 'app')): + _here = os.path.dirname(_here) +REPO = _here +SCAN_DIRS = ['app', 'migrations'] +SKIP_DIRS = {'__pycache__', 'legacy code', '.venv', '.git'} + +# Names that are legitimately "entry points" even with no in-repo caller. +FRAMEWORK_DECORATORS = ( + 'route', 'get', 'post', 'put', 'patch', 'delete', 'before_request', + 'after_request', 'teardown_appcontext', 'context_processor', + 'template_filter', 'errorhandler', 'cli.command', 'command', + 'memoize', 'cached', 'staticmethod', 'classmethod', 'property', + 'user_loader', 'login_manager', +) +LIFECYCLE_NAMES = { + '__init__', '__repr__', '__str__', '__eq__', '__hash__', '__len__', + 'to_dict', 'set_password', 'check_password', 'set_quickconnect_code', + 'check_quickconnect_code', 'authenticate', 'main', 'create_app', +} +# Flask auto-invoked hooks / model properties serialised by templates. +TEMPLATE_OR_HOOK_RE = re.compile(r'^(is_|has_|get_|_default|before_|after_)') + + +def iter_py_files(): + for base in SCAN_DIRS: + root_dir = os.path.join(REPO, base) + if not os.path.isdir(root_dir): + continue + for root, dirs, files in os.walk(root_dir): + dirs[:] = [d for d in dirs if d not in SKIP_DIRS] + for f in sorted(files): + if f.endswith('.py'): + yield os.path.join(root, f) + + +def relpath(p): + return os.path.relpath(p, REPO) + + +def decorator_name(dec): + """Best-effort dotted name for a decorator node.""" + node = dec.func if isinstance(dec, ast.Call) else dec + parts = [] + while isinstance(node, ast.Attribute): + parts.append(node.attr) + node = node.value + if isinstance(node, ast.Name): + parts.append(node.id) + return '.'.join(reversed(parts)) + + +class ModuleInfo: + def __init__(self, path, tree): + self.path = path + self.rel = relpath(path) + self.tree = tree + self.imports = {} # local alias -> (module, original name) + self.module_defs = set() # top-level func/class names defined here + self.functions = [] # dicts describing each def + self.classes = [] + self.calls = [] # (caller_qualname, callee_name, lineno) + + +def collect_module(path): + src = open(path, encoding='utf-8', errors='replace').read() + tree = ast.parse(src, filename=path) + mi = ModuleInfo(path, tree) + + # ---- imports ----------------------------------------------------------- + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for a in node.names: + mi.imports[a.asname or a.name.split('.')[0]] = (a.name, None) + elif isinstance(node, ast.ImportFrom): + mod = node.module or '' + for a in node.names: + mi.imports[a.asname or a.name] = (mod, a.name) + + # ---- top-level definitions -------------------------------------------- + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + mi.module_defs.add(node.name) + elif isinstance(node, ast.ClassDef): + mi.module_defs.add(node.name) + + # ---- functions & methods ---------------------------------------------- + def visit_func(node, class_name=None): + decs = [decorator_name(d) for d in node.decorator_list] + is_method = class_name is not None + # Methods are marked as framework-invoked to avoid false "unused". + entry = { + 'qualname': f'{relpath(path)}::{class_name + "." if class_name else ""}{node.name}', + 'name': node.name, + 'file': relpath(path), + 'class': class_name, + 'lineno': node.lineno, + 'end_lineno': getattr(node, 'end_lineno', node.lineno), + 'args': [a.arg for a in node.args.args], + 'decorators': decs, + 'is_method': is_method, + 'is_private': node.name.startswith('_') and not node.name.startswith('__'), + } + # route detection + for d in decs: + if d.endswith('route') or d in ('get', 'post', 'put', 'delete', 'patch'): + entry['route'] = True + mi.functions.append(entry) + + for child in ast.walk(node): + if isinstance(child, ast.Call): + fn = child.func + name = None + if isinstance(fn, ast.Name): + name = fn.id + elif isinstance(fn, ast.Attribute): + name = fn.attr + if name: + mi.calls.append((entry['qualname'], name, child.lineno)) + # nested funcs get visited separately below via walk on module + + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + visit_func(node) + elif isinstance(node, ast.ClassDef): + bases = [] + for b in node.bases: + if isinstance(b, ast.Name): + bases.append(b.id) + elif isinstance(b, ast.Attribute): + bases.append(b.attr) + mi.classes.append({ + 'name': node.name, + 'file': relpath(path), + 'lineno': node.lineno, + 'bases': bases, + 'methods': [n.name for n in node.body + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))], + }) + for sub in node.body: + if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)): + visit_func(sub, class_name=node.name) + + return mi + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--json', action='store_true') + ap.add_argument('--out', default=None) + args = ap.parse_args() + + modules = [] + for path in iter_py_files(): + try: + modules.append(collect_module(path)) + except SyntaxError as e: + print(f'!! syntax error {relpath(path)}: {e}', file=sys.stderr) + + # ---- global name index ------------------------------------------------- + # name -> list of qualnames defining it + defined = defaultdict(list) + for mi in modules: + for f in mi.functions: + defined[f['name']].append(f['qualname']) + + # ---- call graph -------------------------------------------------------- + # callee name -> set of caller qualnames + callers = defaultdict(set) + for mi in modules: + for caller, callee, _ in mi.calls: + callers[callee].add(caller) + + # ---- classify every function ------------------------------------------ + records = [] + for mi in modules: + for f in mi.functions: + q = f['qualname'] + name = f['name'] + decs = f['decorators'] + has_framework_dec = any( + any(d.split('.')[-1] == fd or d.endswith(fd) for fd in FRAMEWORK_DECORATORS) + for d in decs + ) or f.get('route') + + in_repo_callers = sorted(c for c in callers.get(name, set()) if c != q) + + if f.get('route'): + category = 'route' + elif has_framework_dec: + category = 'framework-hook' + elif name in LIFECYCLE_NAMES or name.startswith('__'): + category = 'dunder/lifecycle' + elif f['is_method']: + category = 'method' + elif TEMPLATE_OR_HOOK_RE.match(name): + category = 'property-like' + else: + category = 'function' + + if category in ('function', 'method') and not in_repo_callers and not f['is_private']: + status = 'NO-IN-REPO-CALLER' + elif category in ('function', 'method') and not in_repo_callers and f['is_private']: + status = 'UNUSED-PRIVATE' + else: + status = 'called/hook' + + records.append({ + **{k: f[k] for k in ('qualname', 'name', 'file', 'class', 'lineno', + 'end_lineno', 'args', 'decorators', 'is_method', + 'is_private')}, + 'loc': f['end_lineno'] - f['lineno'] + 1, + 'category': category, + 'status': status, + 'in_repo_callers': in_repo_callers, + 'caller_count': len(in_repo_callers), + 'calls': sorted({c for (a, c, _) in mi.calls if a == q}), + 'duplicate_definitions': defined[name] if len(defined[name]) > 1 else [], + }) + + # ---- module-level import graph ---------------------------------------- + import_edges = [] + for mi in modules: + for alias, (mod, orig) in mi.imports.items(): + if mod and (mod.startswith('app') or mod in ('app',)): + import_edges.append({'from': mi.rel, 'to': mod, 'name': orig}) + # `from app.x import Y` where Y is a module + for alias, (mod, orig) in mi.imports.items(): + if mod.startswith('app.') and orig is None: + import_edges.append({'from': mi.rel, 'to': mod}) + + result = { + 'summary': { + 'modules': len(modules), + 'functions_total': len(records), + 'files': sorted(mi.rel for mi in modules), + }, + 'functions': records, + 'classes': [c for mi in modules for c in mi.classes], + 'callers_index': {k: sorted(v) for k, v in callers.items()}, + } + + out = json.dumps(result, indent=2) + if args.out: + open(args.out, 'w').write(out) + print(f'wrote {args.out}') + elif args.json: + print(out) + else: + print(report(result)) + + +def report(res): + lines = [] + s = res['summary'] + lines.append(f"modules: {s['modules']} functions: {s['functions_total']}") + lines.append('') + + by_cat = defaultdict(list) + for f in res['functions']: + by_cat[f['category']].append(f) + + for cat in sorted(by_cat): + fs = by_cat[cat] + lines.append(f'== {cat} ({len(fs)}) ==') + for f in sorted(fs, key=lambda x: (x['file'], x['lineno'])): + calls = len(f['calls']) + lines.append(f" [{f['status']:>20}] {f['file']}:{f['lineno']:<5} " + f"{f['qualname'].split('::')[1]:<45} loc={f['loc']:<4} " + f"callers={f['caller_count']:<3} calls={calls}") + lines.append('') + + # suspicious: duplicate function names across files + dup = [f for f in res['functions'] if f['duplicate_definitions']] + if dup: + lines.append('== duplicate function names (same name defined in >1 place) ==') + seen = set() + for f in sorted(dup, key=lambda x: x['name']): + key = f['name'] + if key in seen: + continue + seen.add(key) + lines.append(f" {key}: {f['duplicate_definitions']}") + lines.append('') + + return '\n'.join(lines) + + +if __name__ == '__main__': + main() diff --git a/docs/tools/sanitize_report.py b/docs/tools/sanitize_report.py new file mode 100644 index 0000000..8d75373 --- /dev/null +++ b/docs/tools/sanitize_report.py @@ -0,0 +1,304 @@ +"""Accurate sanitization report for digiserver-v2. + +Corrects two things the first pass got wrong: + * blueprint -> file mapping now follows what app.py actually imports + * route usage counts url_for() AND hardcoded URL paths in templates/JS + +Also classifies routes that are intentionally consumed by the external Kivy +player rather than by any template. + +Run: python tools/sanitize_report.py [--markdown out.md] +""" +from __future__ import annotations + +import argparse +import ast +import os +import re +from collections import defaultdict + +# Walk up until we find the project root (robust to the tool living in a +# nested folder such as docs/tools/). +_here = os.path.dirname(os.path.abspath(__file__)) +while _here != os.path.dirname(_here) and not os.path.isdir(os.path.join(_here, 'app')): + _here = os.path.dirname(_here) +REPO = _here +APP = os.path.join(REPO, 'app') + + +def read(p): + return open(p, encoding='utf-8', errors='replace').read() + + +def rel(p): + return os.path.relpath(p, REPO) + + +def walk_py(roots, skip=('__pycache__', 'legacy code')): + for root_dir in roots: + if not os.path.isdir(root_dir): + continue + for root, dirs, files in os.walk(root_dir): + dirs[:] = [d for d in dirs if d not in skip] + for f in sorted(files): + if f.endswith('.py'): + yield os.path.join(root, f) + + +py_files = list(walk_py([APP, os.path.join(REPO, 'migrations')])) +src = {rel(p): read(p) for p in py_files} +app_src = src.get('app/app.py', '') + +# ── 1. Which blueprint modules does app.py actually import? ───────────────── +imported_bp_modules = set(re.findall( + r'from\s+(app\.blueprints\.\w+)\s+import', app_src)) +registered_vars = set(re.findall(r'register_blueprint\((\w+)\)', app_src)) + +# ── 2. Every blueprint definition + the file that defines it ──────────────── +bp_defs = [] # (var, blueprint_name, url_prefix, file, is_imported_by_app) +for path in py_files: + r = rel(path) + if not r.startswith('app/blueprints/') or os.path.basename(path).startswith('__'): + continue + s = src[r] + m = re.search(r'^(\w+_bp)\s*=\s*Blueprint\(\s*[\'"]([\w-]+)[\'"]' + r'(?:\s*,\s*[^)]*?url_prefix\s*=\s*[\'"]([^\'"]+)[\'"])?', + s, re.M | re.S) + if m: + var, bpname, prefix = m.group(1), m.group(2), m.group(3) or '' + dotted = 'app.blueprints.' + os.path.basename(path)[:-3] + bp_defs.append({ + 'var': var, 'name': bpname, 'prefix': prefix or '/', + 'file': r, 'imported': dotted in imported_bp_modules, + 'registered_var': var in registered_vars, + }) + +# ── 3. Collect route usage signals ───────────────────────────────────────── +url_for_refs = set() +# Literal URL strings, but only ones that look like real request paths +# (avoids matching CSS selectors, regexes, mime types, etc.). +PATH_RE = re.compile(r"['\"](/[a-z][a-zA-Z0-9_\-/<>{}.:]*)['\"]") +hardcoded_paths = set() +consider_files = [] +for root, dirs, files in os.walk(os.path.join(APP, 'templates')): + for f in files: + if f.endswith(('.html', '.js')): + consider_files.append(os.path.join(root, f)) +consider_files += [os.path.join(REPO, r) for r in src + if r.endswith(('.html', '.js'))] +for fp in consider_files: + if not os.path.isfile(fp): + continue + s = read(fp) + url_for_refs |= set(re.findall(r"url_for\(\s*['\"]([\w\.]+)['\"]", s)) + hardcoded_paths |= set(PATH_RE.findall(s)) +for r, s in src.items(): + if r.endswith('.py'): + url_for_refs |= set(re.findall(r"url_for\(\s*['\"]([\w\.]+)['\"]", s)) + hardcoded_paths |= set(PATH_RE.findall(s)) + +# ── 4. Enumerate routes ──────────────────────────────────────────────────── +API_PREFIX = '/api' +routes = [] +for path in py_files: + r = rel(path) + if not r.startswith('app/blueprints/'): + continue + s = src[r] + bp_name = None + file_prefix = '' + for d in bp_defs: + if d['file'] == r: + bp_name = d['name'] + file_prefix = d['prefix'] + try: + tree = ast.parse(s) + except SyntaxError: + continue + for node in tree.body: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for dec in node.decorator_list: + if not (isinstance(dec, ast.Call) and getattr(dec.func, 'attr', '') == 'route'): + continue + rule = dec.args[0].value if dec.args and isinstance(dec.args[0], ast.Constant) else '?' + methods = 'GET' + for kw in dec.keywords: + if kw.arg == 'methods' and isinstance(kw.value, ast.List): + methods = ','.join(e.value for e in kw.value.elts + if isinstance(e, ast.Constant)) + full = file_prefix.rstrip('/') + rule if rule != '/' else file_prefix.rstrip('/') + '/' + endpoint = f'{bp_name}.{node.name}' + routes.append({ + 'endpoint': endpoint, 'func': node.name, 'rule': rule, + 'full': full, 'methods': methods, 'file': r, + 'lineno': node.lineno, + 'url_for_ref': endpoint in url_for_refs, + 'hardcoded_ref': full in hardcoded_paths or rule in hardcoded_paths, + 'is_api': (file_prefix or '').startswith(API_PREFIX), + }) + +# ── 5. Broken model attribute references ────────────────────────────────── +model_attrs = defaultdict(set) +for path in py_files: + r = rel(path) + if not r.startswith('app/models/') or os.path.basename(path).startswith('__'): + continue + try: + tree = ast.parse(src[r]) + except SyntaxError: + continue + for node in tree.body: + if isinstance(node, ast.ClassDef): + for sub in ast.walk(node): + if isinstance(sub, ast.Assign): + for t in sub.targets: + if isinstance(t, ast.Name): + model_attrs[node.name].add(t.id) + elif isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)): + model_attrs[node.name].add(sub.name) + +VIA = {'via', 'e', 'exc', 'error', 'err', 'a', 'b', 'flask_', 'db_', 'fh', 'fd'} + +# ── report ──────────────────────────────────────────────────────────────── +lines = [] +def out(s=''): + lines.append(s) + +out('# DigiServer v2 — Code Sanitization Report') +out() +out(f'Analysed **{len(py_files)} Python files** under `app/` and `migrations/`.') +out() + +out('## 1. Dead modules (never imported)') +out() +out('| File | Status |') +out('|---|---|') +dead_files = [] +for d in bp_defs: + if not d['imported']: + dead_files.append(d['file']) + out(f"| `{d['file']}` | **DEAD** — defines `{d['var']}` but `app.py` imports another module |") +for path in py_files: + r = rel(path) + base = os.path.basename(path)[:-3] + if not r.startswith('app/') or r.startswith('app/blueprints/'): + continue + if base == '__init__': + continue + dotted = r[:-3].replace('/', '.') + referenced = any(dotted in s or f'.{base} import' in s + for k, s in src.items() if k != r) + if not referenced: + dead_files.append(r) + out(f'| `{r}` | **DEAD** — never imported by any module |') +if not dead_files: + out('| (none) | |') +out() + +out('## 2. Blueprint registration') +out() +out('| Blueprint var | Module | Prefix | Registered |') +out('|---|---|---|---|') +for d in sorted(bp_defs, key=lambda x: x['name']): + mark = 'yes' if (d['imported'] and d['registered_var']) else '**NO — DEAD**' + out(f"| `{d['var']}` | `{d['file']}` | `{d['prefix']}` | {mark} |") +out() + +used = [r for r in routes if r['url_for_ref'] or r['hardcoded_ref']] +api_routes = [r for r in routes if r['is_api']] +unused_non_api = [r for r in routes if not r['url_for_ref'] and not r['hardcoded_ref'] + and not r['is_api']] + +out('## 3. Routes') +out() +out(f'- Total routes: **{len(routes)}**') +out(f'- Referenced by a template/JS (`url_for` or hardcoded path): **{len(used)}**') +out(f'- API routes (`/api/*`, consumed by the external Kivy player, not templates): **{len(api_routes)}**') +out(f'- Non-API routes with **no template reference**: **{len(unused_non_api)}**') +out() +out('### 3a. Non-API routes with no template reference (candidates)') +out() +out('| Endpoint | Methods | Path | File:line |') +out('|---|---|---|---|') +for r in sorted(unused_non_api, key=lambda x: (x['file'], x['lineno'])): + out(f"| `{r['endpoint']}` | {r['methods']} | `{r['full']}` | `{r['file']}:{r['lineno']}` |") +out() + +out('## 4. Broken attribute references (would raise at runtime)') +out() +out('| Location | Reference | Problem |') +out('|---|---|---|') +# Locals that are known to hold a specific model instance. +VAR_TO_MODEL = { + 'player': 'Player', 'content': 'Content', 'playlist': 'Playlist', + 'assigned_playlist': 'Playlist', 'feedback': 'PlayerFeedback', + 'latest_feedback': 'PlayerFeedback', 'edit_record': 'PlayerEdit', + 'admin': 'User', 'user': 'User', 'log': 'ServerLog', + 'group': 'Group', 'new_user': 'PlayerUser', 'existing_user': 'PlayerUser', +} +# Python/library attributes that would otherwise be false positives. +SAFE = { + 'query', 'get', 'filter_by', 'first', 'all', 'count', 'id', 'session', 'add', + 'commit', 'rollback', 'delete', 'get_or_404', 'order_by', 'isoformat', 'route', + 'methods', 'get_json', 'args', 'files', 'form', 'json', 'headers', 'values', + 'remote_addr', 'host_url', 'script_root', 'scheme', 'host', 'root_path', + 'config', 'utcnow', 'now', 'total_seconds', 'name', 'value', 'keys', 'items', + 'groups', 'contents', 'players', 'append', 'lower', 'upper', 'strip', 'split', + 'join', 'replace', 'encode', 'decode', 'check_password', 'check_quickconnect_code', + 'set_password', 'set_quickconnect_code', 'authenticate', 'update_status', + 'to_dict', 'is_online', 'is_admin', 'is_active', 'file_size_mb', 'group_count', + 'player_count', 'content_count', 'original_display_name', 'original_media_path', + 'current_media_path', 'get_content_ordered', 'version', 'filename', 'content_type', + 'duration', 'url', 'description', 'file_size', 'uploaded_at', 'hostname', + 'location', 'auth_code', 'orientation', 'status', 'playlist_id', 'last_seen', + 'created_at', 'updated_at', 'deployment_status', 'last_deployment_at', + 'last_deployment_status', 'last_deployment_message', 'original_filename', + 'time_of_modification', 'metadata_path', 'edited_file_path', 'new_name', + 'original_name', 'username', 'role', 'password', 'level', 'message', 'user_code', + 'user_name', 'email', 'func', 'rules', 'player_id', 'content_id', 'error', + 'show', 'seek', 'load', 'play', 'pause', 'stop', 'text', 'bind', 'add_widget', + 'clear_widgets', 'current', 'parent', 'children', 'ids', 'size', 'pos', 'opacity', + 'source', 'state', 'duration', 'position', 'muted', 'audio', 'describe', +} +broken = [] +for r, s in src.items(): + if not (r.startswith('app/blueprints/') or r.startswith('app/utils/')): + continue + try: + tree = ast.parse(s) + except SyntaxError: + continue + for node in ast.walk(tree): + if not (isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name)): + continue + var, attr = node.value.id, node.attr + model = VAR_TO_MODEL.get(var) + if not model or model not in model_attrs: + continue + if attr in model_attrs[model] or attr in SAFE: + continue + broken.append((r, node.lineno, f'{var}.{attr}', f'`{model}` has no `{attr}`')) +for r, ln, ref, prob in broken: + out(f'| `{r}:{ln}` | `{ref}` | {prob} |') +if not broken: + out('| (none) | | |') +out() + +out('## 5. How to use this report') +out() +out('Each section lists deletion candidates. Reply with the section/symbol names') +out('you want removed and they will be deleted (the snapshot in `legacy code/`') +out('preserves the originals).') + +text = '\n'.join(lines) + +ap = argparse.ArgumentParser() +ap.add_argument('--markdown', default=None) +args = ap.parse_args() + +if args.markdown: + open(args.markdown, 'w').write(text) + print(f'wrote {args.markdown}') +else: + print(text) diff --git a/docs/tools/sanitize_templates.py b/docs/tools/sanitize_templates.py new file mode 100644 index 0000000..8eb576c --- /dev/null +++ b/docs/tools/sanitize_templates.py @@ -0,0 +1,65 @@ +"""Detect orphan templates (never rendered) and orphan static assets.""" +from __future__ import annotations + +import os +import re + +# Walk up until we find the project root (robust to the tool living in a +# nested folder such as docs/tools/). +_here = os.path.dirname(os.path.abspath(__file__)) +while _here != os.path.dirname(_here) and not os.path.isdir(os.path.join(_here, 'app')): + _here = os.path.dirname(_here) +REPO = _here +APP = os.path.join(REPO, 'app') +TPL = os.path.join(APP, 'templates') + + +def read(p): + return open(p, encoding='utf-8', errors='replace').read() + + +# gather all render_template('x.html') calls +rendered = set() +for root, dirs, files in os.walk(APP): + dirs[:] = [d for d in dirs if d not in ('__pycache__', 'templates')] + for f in files: + if f.endswith('.py'): + s = read(os.path.join(root, f)) + rendered |= set(re.findall(r"render_template\(\s*['\"]([^'\"]+)['\"]", s)) + # render_template with variable name - note it + rendered |= set(re.findall(r"render_template\(\s*([a-z_]+)", s)) - {'f'} + +# gather extends/include/import references +refs = set() +for root, dirs, files in os.walk(TPL): + for f in files: + if f.endswith('.html'): + s = read(os.path.join(root, f)) + for pat in (r"{%\s*extends\s*['\"]([^'\"]+)['\"]", + r"{%\s*include\s*['\"]([^'\"]+)['\"]", + r"{%\s*import\s*['\"]([^'\"]+)['\"]", + r"render_template\(\s*['\"]([^'\"]+)['\"]"): + refs |= set(re.findall(pat, s)) + +all_tpl = [] +for root, dirs, files in os.walk(TPL): + for f in files: + if f.endswith('.html'): + all_tpl.append(os.path.relpath(os.path.join(root, f), TPL)) + +referenced = rendered | refs +orphans = [] +for t in sorted(all_tpl): + base = os.path.basename(t) + if t in referenced or base in referenced: + continue + # macro/library files are referenced dynamically sometimes + orphans.append(t) + +print(f'templates total : {len(all_tpl)}') +print(f'referenced : {len([t for t in all_tpl if t not in orphans])}') +print(f'ORPHAN templates : {len(orphans)}') +print() +for o in orphans: + lines = len(read(os.path.join(TPL, o)).splitlines()) + print(f' ✗ {o:<58} {lines} lines') diff --git a/docs/tools/smoke_test.py b/docs/tools/smoke_test.py new file mode 100644 index 0000000..92e214e --- /dev/null +++ b/docs/tools/smoke_test.py @@ -0,0 +1,108 @@ +"""Post-sanitization verification smoke test. + +Boots the app on a fresh DB, registers a player/playlist/content, and exercises +every API endpoint plus key UI routes. Fails loudly on any 5xx. +""" +import os +import tempfile + +tmpdir = tempfile.mkdtemp() +db_path = os.path.join(tmpdir, 'smoke.db') +os.environ['DATABASE_URL'] = f'sqlite:///{db_path}' + +from app.app import create_app +from app.extensions import db, bcrypt +from app.models import Player, Playlist, Content, User, PlayerUser, HTTPSConfig + +app = create_app('testing') +app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}' +app.config['CACHE_TYPE'] = 'simple' + +with app.app_context(): + db.create_all() + # prove Group/group_content are gone from metadata + tables = sorted(db.metadata.tables.keys()) + print('registered tables:', tables) + assert 'group' not in tables, 'group table still registered!' + assert 'group_content' not in tables, 'group_content still registered!' + + admin = User(username='smoke', + password=bcrypt.generate_password_hash('smokepw').decode('utf-8'), + role='admin') + pl = Playlist(name='Smoke PL') + db.session.add_all([admin, pl]) + db.session.flush() + c = Content(filename='s.png', content_type='image', duration=5) + db.session.add(c) + db.session.flush() + pl.contents.append(c) + p = Player(name='Smoke', hostname='smoke-host', auth_code='smoke-code') + p.set_password('pw') + p.set_quickconnect_code('qc') + p.playlist_id = pl.id + db.session.add(p) + db.session.commit() + pid = p.id + code = p.auth_code + +auth = {'Authorization': f'Bearer {code}'} + +with app.test_client() as cl: + # public api + for url in ['/api/health', '/api/system-info', '/api/content', '/api/logs', + f'/api/player-status/{pid}', + '/api/playlists?hostname=smoke-host&quickconnect_code=qc']: + r = cl.get(url) + print(f'{r.status_code} GET {url}') + assert r.status_code < 500, f'{url} -> {r.status_code}' + # system-info must no longer expose a groups key + if url == '/api/system-info': + body = r.get_json() + assert 'groups' not in body, f'groups key still present: {body}' + print(' system-info keys:', sorted(body.keys())) + + # authed api + for url in [f'/api/playlists/{pid}', f'/api/playlist-version/{pid}']: + r = cl.get(url, headers=auth) + print(f'{r.status_code} GET {url} (bearer)') + assert r.status_code < 500 + + r = cl.post('/api/auth/verify', json={'auth_code': code}) + print(f'{r.status_code} POST /api/auth/verify') + assert r.status_code < 500 + + r = cl.post('/api/player-feedback', json={ + 'hostname': 'smoke-host', 'quickconnect_code': 'qc', + 'status': 'playing', 'message': 'ok'}) + print(f'{r.status_code} POST /api/player-feedback') + assert r.status_code < 500 + + # content listing must no longer contain group_count + body = cl.get('/api/content').get_json() + assert body['content'], 'expected seeded content' + assert 'group_count' not in body['content'][0], body['content'][0] + print(' /api/content item keys:', sorted(body['content'][0].keys())) + + # login then hit UI pages + r = cl.post('/login', data={'username': 'smoke', 'password': 'smokepw'}, + follow_redirects=True) + print(f'{r.status_code} POST /login') + for url in ['/', '/content/', '/content/media-library', '/players/', + '/admin/', '/admin/users', '/admin/system/info']: + r = cl.get(url, follow_redirects=True) + print(f'{r.status_code} GET {url}') + assert r.status_code < 500, f'{url} -> {r.status_code}' + +# import checks +import app.utils as u +print('utils exports ok:', hasattr(u, 'get_player_status_info')) +assert not hasattr(u, 'get_group_statistics') +assert not hasattr(u, 'assign_player_to_group') + +import importlib +for mod in ['app.blueprints.content', 'app.blueprints.api', 'app.utils.group_player_management', + 'app.blueprints.players']: + importlib.import_module(mod) +print(f'imported {mod}') + +print('\n🎉 SMOKE TEST PASSED') diff --git a/docs/tools/test_build_via_ui.py b/docs/tools/test_build_via_ui.py new file mode 100644 index 0000000..7beeb96 --- /dev/null +++ b/docs/tools/test_build_via_ui.py @@ -0,0 +1,97 @@ +"""Drive the real player build through the HTTP UI and poll its progress. + +Logs in as admin over HTTPS, POSTs the build form exactly like the browser, then +polls /admin/build-player/status until it finishes — mirroring what a real user +sees, including the non-blocking behaviour. + +Optional arg: branch to build (default 'main'). +""" +import http.cookiejar +import json +import re +import ssl +import sys +import time +import urllib.parse +import urllib.request + +BASE = 'https://192.168.0.152' +BRANCH = sys.argv[1] if len(sys.argv) > 1 else 'main' + +pw = open('.deployment-credentials').read().split('admin password: ')[1].strip() +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE +op = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()), + urllib.request.HTTPSHandler(context=ctx), +) + + +def get(path): + return op.open(BASE + path, timeout=60).read().decode() + + +print('1) logging in…') +html = get('/login') +tok = re.search(r'name="csrf_token"[^>]*value="([^"]+)"', html) +data = urllib.parse.urlencode({ + 'username': 'admin', 'password': pw, + 'csrf_token': tok.group(1) if tok else '', +}).encode() +r = op.open(urllib.request.Request(BASE + '/login', data=data, method='POST'), timeout=60) +print(' logged in ->', r.geturl()) + +print(f'2) posting build form (branch={BRANCH})…') +html = get('/admin/build-player') +tok = re.search(r'name="csrf_token"[^>]*value="([^"]+)"', html) +form = urllib.parse.urlencode({ + 'action': 'build_and_config', + 'repo_url': 'https://gitea.moto-adv.com/ske087/Kiwy-Signage.git', + 'branch': BRANCH, + 'server_ip': '192.168.0.152', + 'port': '443', + 'use_https': 'on', + 'orientation': 'Landscape', + 'max_resolution': '1920x1080', + 'csrf_token': tok.group(1) if tok else '', +}).encode() + +t0 = time.time() +r = op.open(urllib.request.Request(BASE + '/admin/build-player', data=form, method='POST'), + timeout=60) +post_elapsed = time.time() - t0 +print(f' POST returned in {post_elapsed:.1f}s -> {r.status} {r.geturl()}') + +if post_elapsed > 30: + print(' ⚠ POST blocked for a long time — the build is NOT async!') + +print('3) polling status…') +last = None +deadline = time.time() + 420 +while time.time() < deadline: + try: + raw = get('/admin/build-player/status') + s = json.loads(raw) + except Exception as e: # noqa: BLE001 + time.sleep(2) + continue + + cur = (s.get('state'), s.get('step'), s.get('message')) + if cur != last: + print(f" [{time.time() - t0:6.1f}s] state={s.get('state'):8} " + f"step={s.get('step') or '-':28} version={s.get('version')}") + if s.get('message'): + print(f" msg: {s['message'][:110]}") + last = cur + + if s.get('state') in ('success', 'error'): + print() + print('RESULT:', s.get('state')) + print(' version :', s.get('version')) + print(' message :', (s.get('message') or '')[:400]) + sys.exit(0 if s.get('state') == 'success' else 1) + time.sleep(2) + +print('timed out waiting for the build') +sys.exit(1) diff --git a/docs/tools/test_http_https_runtime.sh b/docs/tools/test_http_https_runtime.sh new file mode 100644 index 0000000..7da3fc3 --- /dev/null +++ b/docs/tools/test_http_https_runtime.sh @@ -0,0 +1,179 @@ +#!/bin/bash +# End-to-end runtime test of the HTTP/HTTPS + fallback behaviour. +# +# Spins up a stub backend + Caddy on a throwaway Docker network and proves: +# 1. HTTPS disabled -> http://:PORT answers on plain HTTP +# 2. HTTPS enabled -> https://:PORT answers over TLS (internal CA) +# 3. HTTPS enabled -> http://:PORT STILL answers (fallback) +# 4. Both the IP and a hostname resolve to the app +# +# Uses a stub backend so no 1.17 GB app image build is needed; the reverse-proxy +# behaviour under test is entirely Caddy's. +set -u + +NET=e2e-caddy-net +BACKEND=e2e-backend +CADDY=e2e-caddy +HTTP_PORT=18080 +HTTPS_PORT=18443 +IP=127.0.0.1 + +cleanup() { + docker rm -f "$CADDY" "$BACKEND" >/dev/null 2>&1 || true + docker network rm "$NET" >/dev/null 2>&1 || true + rm -f /tmp/e2e_Caddyfile +} +trap cleanup EXIT + +cleanup +docker network create "$NET" >/dev/null + +# Stub "digiserver-app" serving a recognisable body on :5000 +docker run -d --name "$BACKEND" --network "$NET" --network-alias digiserver-app \ + python:3.13-slim \ + python -c "from http.server import BaseHTTPRequestHandler,HTTPServer +class H(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200); self.send_header('Content-Type','text/plain'); self.end_headers() + self.wfile.write(b'BACKEND-OK') + def log_message(self,*a): pass +HTTPServer(('0.0.0.0',5000),H).serve_forever()" >/dev/null + +echo "waiting for stub backend..." +for i in $(seq 1 20); do + docker exec "$BACKEND" python -c " +import urllib.request,sys +try: + urllib.request.urlopen('http://localhost:5000/',timeout=1); sys.exit(0) +except Exception: sys.exit(1)" 2>/dev/null && break + sleep 1 +done +echo "stub backend ready" +echo + +start_caddy() { # $1 = caddyfile content + printf '%s' "$1" > /tmp/e2e_Caddyfile + docker rm -f "$CADDY" >/dev/null 2>&1 || true + docker run -d --name "$CADDY" --network "$NET" \ + -p "${HTTP_PORT}:80" -p "${HTTPS_PORT}:443" \ + -v /tmp/e2e_Caddyfile:/etc/caddy/Caddyfile:ro \ + caddy:2-alpine >/dev/null + # wait for the admin API to accept connections + for i in $(seq 1 25); do + docker exec "$CADDY" wget -q -O- http://localhost:2019/config/ >/dev/null 2>&1 && return 0 + sleep 1 + done + return 1 +} + +result() { # $1 label, $2 expected substring, $3 actual body + if printf '%s' "$3" | grep -q "$2"; then + echo " PASS $1" + return 0 + fi + echo " FAIL $1 (got: $(printf '%s' "$3" | head -c 80))" + return 1 +} + +FAILED=0 + +# ── Case 1: HTTPS disabled -> plain HTTP only ──────────────────────────────── +echo "CASE 1: HTTPS disabled -> plain HTTP on port $HTTP_PORT" +start_caddy '{ + admin 0.0.0.0:2019 +} + +:80 { + reverse_proxy digiserver-app:5000 +} +' || echo " (caddy admin not ready; continuing)" + +BODY=$(curl -sS -m 5 "http://${IP}:${HTTP_PORT}/" 2>&1) +result "http://IP answers" "BACKEND-OK" "$BODY" || FAILED=1 + +BODY=$(curl -sS -m 5 -H "Host: digiserver" "http://${IP}:${HTTP_PORT}/" 2>&1) +result "http with Host: digiserver answers (catch-all)" "BACKEND-OK" "$BODY" || FAILED=1 + +echo + +# ── Case 2/3: HTTPS on, internal CA, with HTTP fallback ────────────────────── +echo "CASE 2+3: HTTPS on (internal CA) + HTTP fallback" +# Generated by CaddyConfigGenerator for ip=127.0.0.1, hostname=digiserver. +# `default_sni` is REQUIRED: browsers send no SNI when the URL is an IP, so +# without it Caddy matches no certificate and aborts with +# "no certificate available for ''". +start_caddy "{ + admin 0.0.0.0:2019 + email admin@example.com + default_sni ${IP} +} + +:80 { + reverse_proxy digiserver-app:5000 +} + +http://${IP} { + reverse_proxy digiserver-app:5000 +} + +http://digiserver { + reverse_proxy digiserver-app:5000 +} + +https://${IP} { + tls internal + reverse_proxy digiserver-app:5000 +} + +https://digiserver { + tls internal + reverse_proxy digiserver-app:5000 +} +" || echo " (caddy admin not ready; continuing)" + +echo " (waiting for internal CA issuance)" +for i in $(seq 1 15); do + OUT=$(curl -sS -k -m 4 "https://${IP}:${HTTPS_PORT}/" 2>&1) + printf '%s' "$OUT" | grep -q "BACKEND-OK" && break + sleep 1 +done +result "https://IP answers over TLS (SNI-less)" "BACKEND-OK" "$OUT" || FAILED=1 + +OUT2=$(curl -sS -k -m 6 --resolve "digiserver:${HTTPS_PORT}:${IP}" \ + "https://digiserver:${HTTPS_PORT}/" 2>&1) +result "https://hostname answers over TLS (with SNI)" "BACKEND-OK" "$OUT2" || FAILED=1 + +BODY=$(curl -sS -m 5 "http://${IP}:${HTTP_PORT}/" 2>&1) +result "http://IP STILL answers (fallback)" "BACKEND-OK" "$BODY" || FAILED=1 + +BODY=$(curl -sS -m 5 -H "Host: digiserver" "http://${IP}:${HTTP_PORT}/" 2>&1) +result "http:// with Host: digiserver answers" "BACKEND-OK" "$BODY" || FAILED=1 + +HANDSHAKE_ERRORS=$(docker logs "$CADDY" 2>&1 | grep -ci "handshake error" || true) +if [ "$HANDSHAKE_ERRORS" -eq 0 ]; then + echo " PASS no TLS handshake errors logged" +else + echo " FAIL $HANDSHAKE_ERRORS TLS handshake error(s) logged" + FAILED=1 +fi + +# Certificate must come from Caddy's local CA +ISSUER=$(echo | openssl s_client -connect "${IP}:${HTTPS_PORT}" -servername localhost 2>/dev/null \ + | openssl x509 -noout -issuer 2>/dev/null) +if printf '%s' "$ISSUER" | grep -qi "local\|caddy"; then + echo " PASS certificate issued by the internal CA" + echo " $ISSUER" +else + echo " WARN unexpected issuer: ${ISSUER:-none}" +fi + +echo +docker logs "$CADDY" 2>&1 | grep -iE "error|cannot|fail" | head -5 || true + +echo +if [ "$FAILED" -eq 0 ]; then + echo "ALL RUNTIME CHECKS PASSED" +else + echo "SOME RUNTIME CHECKS FAILED" +fi +exit "$FAILED" diff --git a/docs/tools/test_https_bootstrap.py b/docs/tools/test_https_bootstrap.py new file mode 100644 index 0000000..916cb2e --- /dev/null +++ b/docs/tools/test_https_bootstrap.py @@ -0,0 +1,232 @@ +"""Verify the env-driven HTTPS bootstrap in https_manager.py. + +Covers the behaviours the user asked for: + 1. Both HOSTNAME_INTERNAL + HOST_IP set -> HTTPS configured at startup + 2. Either missing -> NO-OP, HTTP fallback stays + 3. Re-running with the same env -> idempotent + 4. HTTPS_HTTP_FALLBACK=false -> redirect to the published port + 5. Admin UI change after bootstrap -> overrides the env value + +Run: PYTHONPATH=$(pwd) ./.venv/bin/python docs/tools/test_https_bootstrap.py +""" +import importlib.util +import os +import shutil +import tempfile + +# DATABASE_URL must be set BEFORE importing the app: ProductionConfig evaluates +# it at class-definition (import) time. +TMPDIR = tempfile.mkdtemp() +DB_PATH = os.path.join(TMPDIR, 'bootstrap.db') +os.environ['DATABASE_URL'] = f'sqlite:///{DB_PATH}' + +from app.app import create_app # noqa: E402 +from app.extensions import db # noqa: E402 +from app.models.https_config import HTTPSConfig # noqa: E402 + +REPO = '/home/scheianu/digiserver-v2' +TARGET = os.path.join(TMPDIR, 'Caddyfile') +PLACEHOLDER = ':80 {\n respond "placeholder"\n}\n' + +app = create_app('production') + + +def reset_db(): + """Drop and recreate the schema for a clean case.""" + with app.app_context(): + db.drop_all() + db.create_all() + + +def load_manager(): + """Import https_manager with file writes/reloads redirected to TARGET.""" + spec = importlib.util.spec_from_file_location( + 'hm_boot', os.path.join(REPO, 'https_manager.py')) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + mod.CaddyConfigGenerator.write_caddyfile = staticmethod( + lambda content, path=TARGET: (open(TARGET, 'w').write(content), True)[1]) + mod.CaddyConfigGenerator.reload_caddy = staticmethod(lambda: True) + return mod + + +def reset_env(): + for k in ('HOSTNAME_INTERNAL', 'HOST_IP', 'DOMAIN', 'SSL_EMAIL', + 'HTTPS_PORT', 'HTTPS_HTTP_FALLBACK', 'HTTPS_VERIFY'): + os.environ.pop(k, None) + # No live Caddy in these unit tests, so skip the post-reload TLS probe. + # The probe + automatic fallback are covered by + # docs/tools/test_https_fallback.py. + os.environ['HTTPS_VERIFY'] = 'false' + + +def blocks(): + return [ln.strip() for ln in open(TARGET).read().splitlines() + if ln.strip().startswith(('http://', 'https://', ':80'))] + + +def config(): + with app.app_context(): + return HTTPSConfig.get_config() + + +print('=' * 68) +print('CASE 1 — both vars set -> HTTPS configured at startup') +print('=' * 68) +reset_db(); reset_env() +open(TARGET, 'w').write(PLACEHOLDER) +os.environ.update(HOSTNAME_INTERNAL='digiserver', HOST_IP='192.168.0.152', + SSL_EMAIL='admin@example.com') +hm = load_manager() +rc = hm.bootstrap_from_env(app) +c = config() +print('exit code:', rc, '| https_enabled:', c.https_enabled, '| domain:', repr(c.domain)) +print('blocks:', blocks()) +assert rc == 0 +assert c.https_enabled is True +assert not c.domain, 'empty domain must select the internal CA' +assert 'tls internal' in open(TARGET).read() +assert 'http://192.168.0.152' in open(TARGET).read() +print('PASS: internal CA + HTTP fallback generated\n') + +print('=' * 68) +print('CASE 2 — HOST_IP missing -> no-op, HTTP fallback remains') +print('=' * 68) +reset_db(); reset_env() +open(TARGET, 'w').write(PLACEHOLDER) +os.environ['HOSTNAME_INTERNAL'] = 'digiserver' # HOST_IP absent +hm = load_manager() +rc = hm.bootstrap_from_env(app) +c = config() +print('exit code:', rc, '| config:', 'none' if c is None else c.https_enabled) +assert rc == 0, 'must not fail container startup' +assert c is None or c.https_enabled is False, 'HTTPS must stay off' +assert open(TARGET).read() == PLACEHOLDER, 'Caddyfile must be untouched' +print('PASS: stayed on plain-HTTP fallback, Caddyfile untouched\n') + +print('=' * 68) +print('CASE 3 — HOSTNAME_INTERNAL missing -> no-op') +print('=' * 68) +reset_db(); reset_env() +os.environ['HOST_IP'] = '192.168.0.152' +hm = load_manager() +rc = hm.bootstrap_from_env(app) +c = config() +print('exit code:', rc, '| config:', 'none' if c is None else c.https_enabled) +assert c is None or c.https_enabled is False +print('PASS: no-op when hostname is missing\n') + +print('=' * 68) +print('CASE 3b — neither var set (default fresh deploy) -> no-op') +print('=' * 68) +reset_db(); reset_env() +hm = load_manager() +rc = hm.bootstrap_from_env(app) +assert config() is None +print('exit code:', rc, '| https_config row: none') +print('PASS: untouched config, HTTP fallback only\n') + +print('=' * 68) +print('CASE 4 — idempotency (run twice)') +print('=' * 68) +reset_db(); reset_env() +open(TARGET, 'w').write(PLACEHOLDER) +os.environ.update(HOSTNAME_INTERNAL='digiserver', HOST_IP='192.168.0.152') +hm = load_manager() +hm.bootstrap_from_env(app) +first = open(TARGET).read() +hm.bootstrap_from_env(app) +second = open(TARGET).read() +with app.app_context(): + n = HTTPSConfig.query.count() +print('https_config rows:', n, '| Caddyfile identical:', first == second) +assert n == 1, f'expected one config row, got {n}' +assert first == second, 'Caddyfile changed on re-run' +print('PASS: idempotent\n') + +print('=' * 68) +print('CASE 5 — HTTPS_HTTP_FALLBACK=false -> redirect to the HTTPS URL') +print('=' * 68) +reset_db(); reset_env() +open(TARGET, 'w').write(PLACEHOLDER) +# HTTPS_PORT stays at its 443 default, so the redirect target must not carry +# an explicit port (https://host, not https://host:443). +os.environ.update(HOSTNAME_INTERNAL='digiserver', HOST_IP='192.168.0.152', + HTTPS_HTTP_FALLBACK='false') +hm = load_manager() +hm.bootstrap_from_env(app) +text = open(TARGET).read() +print('blocks:', blocks()) +assert 'redir https://192.168.0.152{uri} 301' in text, \ + 'redirect should omit :443 when HTTPS_PORT is 443' +print('PASS: redirect targets https:// with no redundant port\n') + +print('=' * 68) +print('CASE 5b — non-standard HTTPS_PORT -> redirect includes the port') +print('=' * 68) +reset_db(); reset_env() +open(TARGET, 'w').write(PLACEHOLDER) +os.environ.update(HOSTNAME_INTERNAL='digiserver', HOST_IP='192.168.0.152', + HTTPS_PORT='8443', HTTPS_HTTP_FALLBACK='false') +hm = load_manager() +hm.bootstrap_from_env(app) +text = open(TARGET).read() +print('blocks:', blocks()) +assert 'redir https://192.168.0.152:8443{uri} 301' in text, \ + 'redirect must include a non-standard port' +print('PASS: redirect includes :8443\n') + +print('=' * 68) +print('CASE 6 — admin change BEFORE restart is NOT clobbered by the env') +print('=' * 68) +reset_db(); reset_env() +open(TARGET, 'w').write(PLACEHOLDER) +os.environ.update(HOSTNAME_INTERNAL='digiserver', HOST_IP='192.168.0.152') +hm = load_manager() +hm.bootstrap_from_env(app) # startup bootstrap (env owns it) +print('after bootstrap IP :', config().ip_address) + +# Simulate an admin changing the IP in the UI (updated_by = the username). +hm._apply(app, https_enabled=True, hostname='digiserver', domain='', + email=None, ip_address='10.0.0.99', port=443, http_fallback=True, + verify=False) +with app.app_context(): + c = HTTPSConfig.get_config() + c.updated_by = 'admin' # as admin.py would record it + from app.extensions import db as _db + _db.session.commit() +print('after admin IP :', config().ip_address) + +# Next container start re-runs the bootstrap with the SAME env. +rc = hm.bootstrap_from_env(app) +print('bootstrap exit code:', rc) +print('IP after restart :', config().ip_address) +assert rc == 0 +assert config().ip_address == '10.0.0.99', \ + 'admin setting must survive an env bootstrap on restart' +# The admin-set IP must be served (the hostname from before is still served too, +# because the admin only changed the IP field). +assert 'https://10.0.0.99 {' in open(TARGET).read(), \ + 'admin IP should be present in the Caddyfile' +assert 'https://192.168.0.152 {' not in open(TARGET).read(), \ + 'the old env IP must no longer be served' +print('PASS: admin-owned config is preserved across restarts\n') + +print('=' * 68) +print('CASE 7 — env still owns config -> env change IS applied on restart') +print('=' * 68) +reset_db(); reset_env() +open(TARGET, 'w').write(PLACEHOLDER) +os.environ.update(HOSTNAME_INTERNAL='digiserver', HOST_IP='192.168.0.152') +hm = load_manager() +hm.bootstrap_from_env(app) +print('first start IP :', config().ip_address) +os.environ['HOST_IP'] = '10.20.30.40' # operator edits .env +hm.bootstrap_from_env(app) +print('second start IP:', config().ip_address) +assert config().ip_address == '10.20.30.40', 'env change should apply' +assert 'https://10.20.30.40' in open(TARGET).read() +print('PASS: env changes still take effect while env owns the config\n') + +shutil.rmtree(TMPDIR, ignore_errors=True) +print('ALL BOOTSTRAP CASES PASSED') diff --git a/docs/tools/test_https_fallback.py b/docs/tools/test_https_fallback.py new file mode 100644 index 0000000..0c940ad --- /dev/null +++ b/docs/tools/test_https_fallback.py @@ -0,0 +1,119 @@ +"""Verify the HTTPS-verify + automatic HTTP fallback in https_manager._apply. + +This is the safety net the user asked for: if HTTPS is enabled but does not +actually work, the server must not be left unreachable — it falls back to HTTP. + +The TLS probe is monkey-patched so no live server is required. + +Run: PYTHONPATH=$(pwd) ./.venv/bin/python docs/tools/test_https_fallback.py +""" +import importlib.util +import os +import shutil +import tempfile + +TMPDIR = tempfile.mkdtemp() +os.environ['DATABASE_URL'] = f'sqlite:///{TMPDIR}/fb.db' + +from app.app import create_app # noqa: E402 +from app.extensions import db # noqa: E402 +from app.models.https_config import HTTPSConfig # noqa: E402 + +REPO = '/home/scheianu/digiserver-v2' +TARGET = os.path.join(TMPDIR, 'Caddyfile') + +app = create_app('production') + +with app.app_context(): + db.create_all() + + +def load_manager(verify_result): + """Import https_manager with I/O redirected and the probe stubbed.""" + spec = importlib.util.spec_from_file_location( + 'hm_fb', os.path.join(REPO, 'https_manager.py')) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + mod.CaddyConfigGenerator.write_caddyfile = staticmethod( + lambda content, path=TARGET: (open(TARGET, 'w').write(content), True)[1]) + mod.CaddyConfigGenerator.reload_caddy = staticmethod(lambda: True) + mod.verify_https = lambda *a, **k: verify_result + return mod + + +def blocks(): + return [ln.strip() for ln in open(TARGET).read().splitlines() + if ln.strip().startswith(('http://', 'https://', ':80'))] + + +def config(): + with app.app_context(): + return HTTPSConfig.get_config() + + +print('=' * 68) +print('CASE A — HTTPS verifies OK -> stays enabled') +print('=' * 68) +open(TARGET, 'w').write(':80 {\n respond "x"\n}\n') +hm = load_manager((True, 'HTTP 200 from https://192.168.0.152:443/api/health')) +rc = hm._apply(app, https_enabled=True, hostname='digiserver', domain='', + email=None, ip_address='192.168.0.152', port=443, + http_fallback=True, verify=True) +c = config() +print('exit code:', rc, '| https_enabled:', c.https_enabled) +print('blocks:', blocks()) +assert rc == 0 +assert c.https_enabled is True +assert 'tls internal' in open(TARGET).read() +print('PASS: HTTPS left enabled\n') + +print('=' * 68) +print('CASE B — HTTPS probe FAILS -> automatic HTTP fallback') +print('=' * 68) +open(TARGET, 'w').write(':80 {\n respond "x"\n}\n') +hm = load_manager((False, 'ConnectionRefusedError: refused')) +rc = hm._apply(app, https_enabled=True, hostname='digiserver', domain='', + email=None, ip_address='192.168.0.152', port=443, + http_fallback=True, verify=True) +c = config() +text = open(TARGET).read() +print('exit code:', rc, '| https_enabled:', c.https_enabled) +print('blocks:', blocks()) +assert rc == 1, f'expected exit 1 signalling the fallback, got {rc}' +assert c.https_enabled is False, 'must revert to HTTP-only' +assert 'tls internal' not in text, 'TLS must be removed after fallback' +assert ':80 {' in text, 'HTTP must still be served' +print('PASS: reverted to plain HTTP so the site stays reachable\n') + +print('=' * 68) +print('CASE C — verify disabled -> trusts the config, no probe') +print('=' * 68) +open(TARGET, 'w').write(':80 {\n respond "x"\n}\n') +hm = load_manager((False, 'should never be called')) +rc = hm._apply(app, https_enabled=True, hostname='digiserver', domain='', + email=None, ip_address='192.168.0.152', port=443, + http_fallback=True, verify=False) +c = config() +print('exit code:', rc, '| https_enabled:', c.https_enabled) +assert rc == 0 +assert c.https_enabled is True, 'no probe => config trusted' +assert 'tls internal' in open(TARGET).read() +print('PASS: configuration trusted without probing\n') + +print('=' * 68) +print('CASE D — HTTP only (https disabled) is never probed') +print('=' * 68) +open(TARGET, 'w').write(':80 {\n respond "x"\n}\n') +hm = load_manager((False, 'n/a')) +rc = hm._apply(app, https_enabled=False, hostname=None, domain=None, + email=None, ip_address=None, port=443, + http_fallback=True, verify=True) +c = config() +print('exit code:', rc, '| https_enabled:', c.https_enabled) +assert rc == 0 +assert c.https_enabled is False +assert 'tls internal' not in open(TARGET).read() +print('PASS: no TLS emitted, no probe performed\n') + +shutil.rmtree(TMPDIR, ignore_errors=True) +print('ALL FALLBACK CASES PASSED') diff --git a/docs/tools/test_https_manager.py b/docs/tools/test_https_manager.py new file mode 100644 index 0000000..882eba4 --- /dev/null +++ b/docs/tools/test_https_manager.py @@ -0,0 +1,75 @@ +"""Exercise https_manager.py exactly as deploy.sh does, inside a container-like env. + +Overrides the Caddyfile path (the only host-only difference) to prove the +enable/status/disable flow and the resulting Caddyfile content. +""" +import os +import sys +import tempfile + +tmp = tempfile.mkdtemp() +os.environ['DATABASE_URL'] = f'sqlite:///{tmp}/t.db' + +from app.app import create_app +from app.models.https_config import HTTPSConfig +from app.utils.caddy_manager import CaddyConfigGenerator + +app = create_app('production') +with app.app_context(): + from app.extensions import db + db.create_all() + +# Redirect the Caddyfile write/read to a temp path (the only host difference). +_target = f'{tmp}/Caddyfile' +CaddyConfigGenerator.write_caddyfile = staticmethod( + lambda content, path=_target: (open(_target, 'w').write(content), True)[1]) +CaddyConfigGenerator.reload_caddy = staticmethod(lambda: True) + +sys.argv = ['https_manager.py', 'enable', 'digiserver', '', + 'admin@example.com', '192.168.0.152', '8443'] + +import importlib.util +spec = importlib.util.spec_from_file_location('hm', '/home/scheianu/digiserver-v2/https_manager.py') +hm = importlib.util.module_from_spec(spec) +spec.loader.exec_module(hm) + +print('=== ENABLE (empty domain -> internal CA) ===') +rc = hm.main() +print('exit code:', rc) + +print() +print('=== resulting Caddyfile site blocks ===') +text = open(_target).read() +for ln in text.splitlines(): + s = ln.strip() + if s.startswith(('http://', 'https://', ':80')) or 'tls internal' in s or 'redir' in s: + print(' ', s) + +with app.app_context(): + c = HTTPSConfig.get_config() + print() + print('=== stored config ===') + print(' https_enabled:', c.https_enabled) + print(' domain :', repr(c.domain)) + print(' ip_address :', c.ip_address) + print(' port :', c.port) + assert c.https_enabled is True + assert not c.domain, 'domain must be empty for internal CA' + assert 'tls internal' in text, 'internal CA directive missing' + assert 'http://192.168.0.152' in text, 'HTTP fallback missing' + +print() +print('ASSERT PASS: internal CA + HTTP fallback generated correctly') + +print() +print('=== STATUS ===') +sys.argv = ['https_manager.py', 'status'] +hm.main() + +print() +print('=== DISABLE ===') +sys.argv = ['https_manager.py', 'disable'] +hm.main() +with app.app_context(): + c = HTTPSConfig.get_config() + print(' https_enabled after disable:', c.https_enabled) diff --git a/docs/tools/test_player_build.py b/docs/tools/test_player_build.py new file mode 100644 index 0000000..5f651bf --- /dev/null +++ b/docs/tools/test_player_build.py @@ -0,0 +1,161 @@ +"""Verify the background player-build flow: state machine + shallow git clone. + +Exercises the real functions against a local throwaway bare git repo so no +network is needed, then confirms: + * shallow clone produces a usable working tree + * _run_git never blocks on credentials (GIT_TERMINAL_PROMPT=0) + * a broken/partial checkout is detected and replaced, not reused + * the background job moves idle -> running -> success and records the version + * a bad repo URL ends in 'error', not a hang or a traceback + +Run: PYTHONPATH=$(pwd) ./.venv/bin/python docs/tools/test_player_build.py +""" +import os +import shutil +import subprocess +import sys +import tempfile +import time + +TMP = tempfile.mkdtemp() +os.environ['DATABASE_URL'] = f'sqlite:///{TMP}/pb.db' + +from app.app import create_app # noqa: E402 +from app.extensions import db # noqa: E402 +from app.utils import player_build as pb # noqa: E402 + +app = create_app('production') +with app.app_context(): + db.create_all() + +# ── Build a local origin repo (no network) ────────────────────────────────── +ORIGIN = os.path.join(TMP, 'origin.git') +SRC = os.path.join(TMP, 'src') +subprocess.run(['git', 'init', '--bare', '-q', ORIGIN], check=True) +subprocess.run(['git', 'init', '-q', SRC], check=True) +for k, v in (('user.email', 't@t'), ('user.name', 'T')): + subprocess.run(['git', '-C', SRC, 'config', k, v], check=True) +os.makedirs(os.path.join(SRC, 'config'), exist_ok=True) +open(os.path.join(SRC, 'config', 'app_config.json'), 'w').write('{}') +open(os.path.join(SRC, 'main.py'), 'w').write('print("player")\n') +subprocess.run(['git', '-C', SRC, 'add', '-A'], check=True) +subprocess.run(['git', '-C', SRC, 'commit', '-qm', 'init'], check=True) +subprocess.run(['git', '-C', SRC, 'branch', '-M', 'main'], check=True) +subprocess.run(['git', '-C', SRC, 'remote', 'add', 'origin', ORIGIN], check=True) +subprocess.run(['git', '-C', SRC, 'push', '-q', 'origin', 'main'], check=True) +HEAD = subprocess.run(['git', '-C', SRC, 'rev-parse', '--short', 'HEAD'], + capture_output=True, text=True).stdout.strip() + +# file:// forces a real transport so --depth is honoured (a local path clone +# silently ignores it, which would make the shallow assertion meaningless). +ORIGIN_URI = 'file://' + ORIGIN + +TARGET = os.path.join(TMP, 'staged') +META = os.path.join(TMP, 'player_build.json') + +failures = [] + + +def check(label, cond, detail=''): + print(f" [{'PASS' if cond else 'FAIL'}] {label}" + (f' {detail}' if detail else '')) + if not cond: + failures.append(label) + + +print('=' * 68) +print('CASE 1 — fresh shallow clone') +print('=' * 68) +r = pb.build_player_files(TARGET, ORIGIN_URI, 'main') +check('clone succeeded', r['success'], r['message']) +check('version matches origin', r['version'] == HEAD, f"{r['version']} vs {HEAD}") +check('working tree has files', + os.path.isfile(os.path.join(TARGET, 'main.py'))) +check('checkout reported usable', pb.is_valid_checkout(TARGET)) +check('shallow (depth 1)', + os.path.isfile(os.path.join(TARGET, '.git', 'shallow')), + 'file:// transport honours --depth') +print() + +print('=' * 68) +print('CASE 2 — git never prompts for credentials') +print('=' * 68) +r = pb._run_git(['clone', '--depth', '1', + 'https://127.0.0.1:1/nope/nope.git', + os.path.join(TMP, 'nope')], timeout=20) +check('unreachable repo returns fast (no hang)', r.returncode != 0, + f'rc={r.returncode}') +check('GIT_TERMINAL_PROMPT=0 is set', pb._git_env().get('GIT_TERMINAL_PROMPT') == '0') +print() + +print('=' * 68) +print('CASE 3 — broken/partial checkout is replaced, not reused') +print('=' * 68) +BROKEN = os.path.join(TMP, 'broken') +shutil.rmtree(BROKEN, ignore_errors=True) +os.makedirs(os.path.join(BROKEN, '.git'), exist_ok=True) # .git but no HEAD +open(os.path.join(BROKEN, 'leftover.txt'), 'w').write('stale') +check('broken dir is not considered usable', not pb.is_valid_checkout(BROKEN)) +r = pb.build_player_files(BROKEN, ORIGIN_URI, 'main') +check('build recovers from broken dir', r['success'], r['message']) +check('stale file removed', + not os.path.exists(os.path.join(BROKEN, 'leftover.txt'))) +check('now a valid checkout', pb.is_valid_checkout(BROKEN)) +print() + +print('=' * 68) +print('CASE 4 — background job: idle -> running -> success') +print('=' * 68) +TARGET2 = os.path.join(TMP, 'staged2') +pb._set_build_state(state='idle', step='', message='', version=None) +check('starts idle', pb.get_build_state()['state'] == 'idle') + +with app.app_context(): + started = pb.start_background_build( + player_code_dir=TARGET2, repo_url=ORIGIN_URI, branch='main', + config_payload={'server_ip': '192.168.0.152', 'port': '443', + 'use_https': True, 'verify_ssl': False, + 'orientation': 'Landscape', 'max_resolution': '1920x1080'}, + meta_path=META, built_by='tester') + check('build accepted', started is True) + check('second start refused while running', + pb.start_background_build(TARGET2, ORIGIN_URI, 'main', None, META, 'x') is False) + +for _ in range(120): + if pb.get_build_state()['state'] in ('success', 'error'): + break + time.sleep(0.5) + +state = pb.get_build_state() +check('finished successfully', state['state'] == 'success', state.get('message', '')) +check('version recorded', state.get('version') == HEAD) +check('config written to staged code', + os.path.isfile(os.path.join(TARGET2, 'config', 'app_config.json'))) +check('build settings persisted', os.path.isfile(META)) +settings = pb.load_build_settings(META) or {} +check('meta has server_ip', settings.get('server_ip') == '192.168.0.152', + str(settings.get('server_ip'))) +print() + +print('=' * 68) +print('CASE 5 — bad repository URL ends in error (no hang, no traceback)') +print('=' * 68) +TARGET3 = os.path.join(TMP, 'staged3') +pb._set_build_state(state='idle', step='', message='', version=None) +with app.app_context(): + pb.start_background_build(TARGET3, os.path.join(TMP, 'does-not-exist.git'), + 'main', None, META, 'tester') +for _ in range(120): + if pb.get_build_state()['state'] in ('success', 'error'): + break + time.sleep(0.5) +state = pb.get_build_state() +check('reported as error', state['state'] == 'error') +check('error has a message', bool(state.get('message')), state.get('message', '')[:70]) +check('no partial dir left behind', not os.path.exists(TARGET3)) + +shutil.rmtree(TMP, ignore_errors=True) +print() +if failures: + print(f'FAILED: {failures}') + sys.exit(1) +print('ALL PLAYER-BUILD CASES PASSED') diff --git a/docs/tools/verify_caddyfile_modes.py b/docs/tools/verify_caddyfile_modes.py new file mode 100644 index 0000000..7898506 --- /dev/null +++ b/docs/tools/verify_caddyfile_modes.py @@ -0,0 +1,96 @@ +"""Verify every Caddyfile mode against the real `caddy validate` binary. + +Also asserts the structural guarantees required for the deployment model: + * ONE HTTP endpoint that answers regardless of Host header (catch-all :80) + * explicit per-name HTTP blocks for both IP and hostname + * HTTPS on 443 only when enabled, using the internal CA for intranet names + +Run: PYTHONPATH=$(pwd) ./.venv/bin/python docs/tools/verify_caddyfile_modes.py +""" +import subprocess +import sys + +from app.utils.caddy_manager import CaddyConfigGenerator as G + + +class Cfg: + def __init__(self, **kw): + self.email = kw.get('email', 'admin@example.com') + self.https_enabled = kw.get('https_enabled', False) + self.domain = kw.get('domain', '') + self.ip_address = kw.get('ip_address', '') + self.hostname = kw.get('hostname', '') + self.port = kw.get('port', 443) + + +def validate(text): + path = '/tmp/_caddyfile_check' + open(path, 'w').write(text) + r = subprocess.run( + ['docker', 'run', '--rm', '-v', f'{path}:/etc/caddy/Caddyfile:ro', + 'caddy:2-alpine', 'caddy', 'validate', '--config', '/etc/caddy/Caddyfile'], + capture_output=True, text=True) + return (r.returncode == 0 and 'Valid configuration' in (r.stdout + r.stderr), + (r.stdout + r.stderr)) + + +CASES = [ + ('1. Nothing configured -> HTTP only on :80', + Cfg(), True, {}), + ('2. HTTPS on, IP only -> internal CA + HTTP fallback', + Cfg(https_enabled=True, ip_address='192.168.0.152'), True, + {'tls internal', 'http://192.168.0.152', 'https://192.168.0.152', ':80'}), + ('3. HTTPS on, IP + hostname -> both served', + Cfg(https_enabled=True, ip_address='192.168.0.152', hostname='digiserver'), + True, + {'http://digiserver', 'https://digiserver', 'http://192.168.0.152'}), + ('4. HTTPS on, redirect-only -> 301 to published port', + Cfg(https_enabled=True, ip_address='192.168.0.152'), False, + {'redir https://192.168.0.152{uri} 301', ':80'}), + ('5. HTTPS on, public domain -> ACME (no tls internal)', + Cfg(https_enabled=True, domain='example.com', + ip_address='192.168.0.152'), True, + {'https://example.com', 'tls internal'}), + ('6. HTTP only even though IP known (HTTPS disabled) -> :80 only', + Cfg(https_enabled=False, ip_address='192.168.0.152'), True, + {':80'}), +] + +failures = [] +for label, cfg, fallback, must_contain in CASES: + text = G.generate_caddyfile(cfg, http_fallback=fallback) + ok, raw = validate(text) + + missing = sorted(s for s in must_contain if s not in text) + if missing: + ok = False + + print(f'[{"OK " if ok else "FAIL"}] {label}') + blocks = [ln.strip() for ln in text.splitlines() + if ln.strip().startswith(('http://', 'https://', ':80'))] + print(f' blocks: {blocks}') + if missing: + print(f' MISSING: {missing}') + if not ok: + failures.append(label) + print(' ', raw.strip()[-400:]) + print() + +# Extra guarantees that are easy to regress silently. +text_https = G.generate_caddyfile( + Cfg(https_enabled=True, ip_address='192.168.0.152', hostname='digiserver'), + http_fallback=True) +assert ':80 {' in text_https, 'catch-all :80 block missing' +assert 'https://digiserver {' in text_https, 'hostname TLS block missing' +assert text_https.count(':80 {') == 1, 'exactly one catch-all :80 expected' + +text_http = G.generate_caddyfile(Cfg(https_enabled=False)) +assert 'https://' not in text_http, 'no TLS should be emitted when disabled' +assert ':80 {' in text_http + +print('Structural guarantees hold (catch-all :80, per-name blocks, TLS only when enabled)') + +if failures: + print(f'FAILED: {failures}') + sys.exit(1) +print('All Caddyfile modes are VALID') diff --git a/docs/tools/verify_dockerignore.py b/docs/tools/verify_dockerignore.py new file mode 100644 index 0000000..7d2afdb --- /dev/null +++ b/docs/tools/verify_dockerignore.py @@ -0,0 +1,55 @@ +"""Verify 'legacy code' is excluded from the Docker build context. + +Docker's .dockerignore semantics (as applied by BuildKit): + * 'legacy code/' -> directory named "legacy code" at the context root + * '**/legacy code/' -> directory named "legacy code" at ANY depth + +This simulates a walk of the build context and asserts the snapshot never +appears in the files that would be sent to the daemon. +""" +import os + +ROOT = os.getcwd() + +rules = [] +for raw in open('.dockerignore', encoding='utf-8'): + line = raw.strip() + if line and not line.startswith('#'): + rules.append(line) + +print('rules mentioning legacy:', [r for r in rules if 'legacy' in r]) + + +def is_legacy_dir(rel): + """True if *rel* is a directory named 'legacy code' at root or any depth.""" + return rel == 'legacy code' or rel.endswith('/legacy code') + + +excluded, included = [], [] +for dirpath, dirnames, filenames in os.walk(ROOT): + dirnames[:] = [d for d in dirnames if d != '.git'] + for d in list(dirnames): + rel = os.path.relpath(os.path.join(dirpath, d), ROOT) + if is_legacy_dir(rel): + excluded.append(rel + '/ (pruned)') + dirnames.remove(d) + continue + for f in filenames: + rel = os.path.relpath(os.path.join(dirpath, f), ROOT) + parts = rel.split('/') + under_legacy = any(is_legacy_dir('/'.join(parts[:i])) + for i in range(1, len(parts) + 1)) + (excluded if under_legacy else included).append(rel) + +print() +print('EXCLUDED (legacy snapshot):') +for e in sorted(excluded)[:5]: + print(' -', e) +print(f' ... {len(excluded)} total') +print() +leak = [i for i in included if 'legacy code' in i] +print(f'files reaching the build context: {len(included)}') +print('LEAKED:', leak if leak else 'none') +assert not leak, 'legacy snapshot would ship into the image!' +print() +print('VERIFIED: legacy snapshot is excluded from the Docker build context') diff --git a/https_manager.py b/https_manager.py new file mode 100644 index 0000000..d7c62b4 --- /dev/null +++ b/https_manager.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python +"""HTTPS management CLI. + +Invoked by ``deploy.sh`` after the containers are healthy: + + python /app/https_manager.py enable [port] + python /app/https_manager.py disable + python /app/https_manager.py status + python /app/https_manager.py bootstrap # from environment variables + +This is a thin wrapper around the very same code path the Admin UI uses +(``HTTPSConfig`` + ``CaddyConfigGenerator``), so a command-line deployment and a +UI-driven change always produce an identical Caddyfile. + +Mode selection (delegated to ``CaddyConfigGenerator``): + * ``--domain`` given → Let's Encrypt (needs a publicly resolvable name) + * IP only, ``--domain`` empty → Caddy **internal CA** (no DNS, no ACME). + Correct for intranet servers. + * ``--no-https`` → HTTP only. + +Exit codes: + 0 success + 1 invalid arguments / configuration error + 2 configuration applied, but Caddy could not be reloaded +""" +from __future__ import annotations + +import argparse +import os +import ssl +import sys +import time +import urllib.error +import urllib.request + +sys.path.insert(0, '/app') + +from app.app import create_app # noqa: E402 +from app.models.https_config import HTTPSConfig # noqa: E402 +from app.utils.caddy_manager import CaddyConfigGenerator # noqa: E402 +from app.utils.logger import log_action # noqa: E402 + +CADDYFILE_PATH = '/etc/caddy/Caddyfile' + + +def _truthy(value: str | None, default: bool = True) -> bool: + """Interpret a string env var as a boolean.""" + if value is None or value == '': + return default + return value.strip().lower() in ('1', 'true', 'yes', 'on') + + +def verify_https(hostname: str, port: int = 443, timeout: float = 6.0, + attempts: int = 3) -> tuple[bool, str]: + """Check that HTTPS actually answers on *hostname*:*port*. + + The certificate is deliberately **not** validated: for an intranet name we + expect Caddy's internal CA, which is not in this container's trust store. + What matters is that the TLS listener is up and serving. + + Retries a few times because Caddy may still be obtaining a certificate. + + Args: + hostname: Name or IP to connect to (e.g. the host IP or domain). + port: TLS port. + timeout: Per-attempt timeout in seconds. + attempts: Number of attempts before giving up. + + Returns: + ``(ok, detail)`` — ``detail`` is a human-readable reason. + """ + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + last = 'no attempt made' + for attempt in range(1, attempts + 1): + url = f"https://{hostname}:{port}/api/health" + try: + req = urllib.request.Request(url, method='GET') + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r: + if r.status < 500: + return True, f'HTTP {r.status} from {url}' + last = f'HTTP {r.status} from {url}' + except urllib.error.HTTPError as e: + # An HTTP error still means TLS terminated successfully. + if e.code < 500: + return True, f'HTTP {e.code} from {url}' + last = f'HTTP {e.code} from {url}' + except Exception as e: # noqa: BLE001 + last = f'{type(e).__name__}: {e}' + + if attempt < attempts: + time.sleep(2) + + return False, last + + +def bootstrap_from_env(app) -> int: + """Configure HTTPS from environment variables at container startup. + + Reads ``HOSTNAME_INTERNAL`` and ``HOST_IP``. Both must be set for HTTPS to + be provisioned; when either is missing the app deliberately stays on the + plain-HTTP fallback so it is always reachable, and HTTPS can be enabled + later from Admin → HTTPS Configuration (which reloads Caddy live). + + Ownership / precedence + ---------------------- + The admin UI is the ongoing source of truth. To avoid the environment + silently overwriting an admin's change on every restart, the bootstrap only + applies while the environment still *owns* the configuration: + + * No config yet → apply from env (first deploy). + * Last written by the env bootstrap → apply from env (env still owns it, + so changing ``HOST_IP`` and redeploying works). + * Last written by a real user → SKIP; the admin's settings are kept. + + Provenance is taken from ``HTTPSConfig.updated_by`` (``'deploy.sh'`` marks an + env/CLI write), so no schema change is needed. + + Idempotent: re-running with an unchanged environment rewrites the same + Caddyfile and reloads Caddy, which is harmless. + + Returns one of the standard exit codes (0/1/2). + """ + hostname = (os.getenv('HOSTNAME_INTERNAL') or '').strip() + ip_address = (os.getenv('HOST_IP') or '').strip() + + if not hostname or not ip_address: + missing = [n for n, v in (('HOSTNAME_INTERNAL', hostname), + ('HOST_IP', ip_address)) if not v] + print(f'[https] {" and ".join(missing)} not set — staying on the ' + f'plain-HTTP fallback.') + print('[https] Enable HTTPS later from Admin → HTTPS Configuration.') + return 0 + + # Respect an explicit admin change: only seed while the env owns the config. + with app.app_context(): + existing = HTTPSConfig.get_config() + if existing is not None and existing.updated_by not in (None, '', 'deploy.sh'): + print(f'[https] HTTPS already configured by "{existing.updated_by}" — ' + f'environment bootstrap skipped so the admin setting is kept.') + return 0 + + domain = (os.getenv('DOMAIN') or '').strip() + email = (os.getenv('SSL_EMAIL') or '').strip() or None + port = int(os.getenv('HTTPS_PORT') or 443) + http_fallback = _truthy(os.getenv('HTTPS_HTTP_FALLBACK'), default=True) + # HTTS_VERIFY lets an operator skip the post-reload probe (e.g. when the + # server is not reachable from inside the container, or in CI). + do_verify = _truthy(os.getenv('HTTPS_VERIFY'), default=True) + + print(f'[https] Bootstrapping from environment: host={hostname!r} ip={ip_address} ' + f'domain={domain!r} port={port} http_fallback={http_fallback}') + + return _apply(app, https_enabled=True, hostname=hostname, domain=domain, + email=email, ip_address=ip_address, port=port, + http_fallback=http_fallback, verify=do_verify) + + +def _apply(app, https_enabled: bool, hostname: str | None, domain: str | None, + email: str | None, ip_address: str | None, port: int, + http_fallback: bool, verify: bool = True) -> int: + """Persist the config, regenerate the Caddyfile and hot-reload Caddy. + + When *verify* is set and HTTPS is being enabled, the TLS listener is probed + after the reload. If it does not come up, the configuration is automatically + reverted to plain HTTP so the server is never left unreachable — HTTPS then + falls back to HTTP exactly as intended, and can be retried from the admin UI. + """ + with app.app_context(): + # An empty domain is meaningful: it selects the internal-CA path. + config = HTTPSConfig.create_or_update( + https_enabled=https_enabled, + hostname=hostname, + domain=domain or None, + ip_address=ip_address, + email=email, + port=port, + updated_by='deploy.sh', + ) + + mode = ('HTTP only' if not https_enabled + else 'Let\'s Encrypt' if config.domain + else 'internal CA') + + print(f' Mode: {mode}') + print(f' Hostname: {config.hostname or "-"}') + print(f' Domain: {config.domain or "(none)"}') + print(f' IP address: {config.ip_address or "-"}') + print(f' Email: {config.email or "-"}') + print(f' HTTPS port: {config.port}') + + caddyfile = CaddyConfigGenerator.generate_caddyfile( + config, http_fallback=http_fallback, + http_port=int(os.getenv('HTTP_PORT') or 80), + https_port=int(os.getenv('HTTPS_PORT') or port or 443)) + + if not CaddyConfigGenerator.write_caddyfile(caddyfile): + print(' ✗ Failed to write the Caddyfile', file=sys.stderr) + return 1 + print(' ✓ Caddyfile written') + + if not CaddyConfigGenerator.reload_caddy(): + print(' ⚠ Caddyfile written but Caddy reload failed — restart the ' + 'caddy container to apply.', file=sys.stderr) + log_action('warning', 'Caddy reload failed during CLI HTTPS setup') + if not verify: + return 2 + reload_ok = False + else: + print(' ✓ Caddy reloaded') + reload_ok = True + + # ── Verify the TLS listener, then fall back to HTTP if it failed ──── + if verify and https_enabled and reload_ok: + probe_host = config.ip_address or config.hostname or domain + # Probe whichever port the host actually publishes. + probe_port = int(os.getenv('HTTPS_PORT') or config.port or 443) + print(f' … Verifying HTTPS on {probe_host}:{probe_port}') + ok, detail = verify_https(probe_host, probe_port) + if ok: + print(f' ✓ HTTPS verified ({detail})') + log_action('info', f'HTTPS configured and verified (mode={mode})') + return 0 + + print(f' ✗ HTTPS verification failed: {detail}', file=sys.stderr) + print(' ↩ Falling back to plain HTTP so the server stays reachable.', + file=sys.stderr) + log_action('warning', f'HTTPS verification failed ({detail}); ' + f'fell back to HTTP') + + # Revert to HTTP-only and re-apply so port 80 keeps serving. + HTTPSConfig.create_or_update( + https_enabled=False, + hostname=config.hostname, + domain=None, + ip_address=config.ip_address, + email=config.email, + port=config.port, + updated_by='deploy.sh', + ) + http_only = CaddyConfigGenerator.generate_caddyfile( + HTTPSConfig.get_config()) + CaddyConfigGenerator.write_caddyfile(http_only) + CaddyConfigGenerator.reload_caddy() + return 1 + + if verify and https_enabled and not reload_ok: + log_action('warning', 'HTTPS configured but Caddy reload failed') + return 2 + + log_action('info', f'HTTPS configured via CLI (mode={mode})') + return 0 + + +def _status(app) -> int: + with app.app_context(): + config = HTTPSConfig.get_config() + if not config: + print(' No HTTPS configuration found (HTTP-only defaults).') + return 0 + + if not config.https_enabled: + mode = 'HTTP only' + elif config.domain: + mode = "Let's Encrypt" + else: + mode = 'internal CA' + + print(f' Enabled: {config.https_enabled}') + print(f' Mode: {mode}') + print(f' Hostname: {config.hostname or "-"}') + print(f' Domain: {config.domain or "(none)"}') + print(f' IP address: {config.ip_address or "-"}') + print(f' Email: {config.email or "-"}') + print(f' HTTPS port: {config.port}') + print(f' Updated by: {config.updated_by or "-"}') + if config.updated_at: + print(f' Updated at: {config.updated_at.isoformat()}') + return 0 + + +def _verify_current(app) -> int: + """Probe the currently configured HTTPS endpoint; fall back to HTTP if down. + + Useful as a post-deploy check and as a self-healing step after a restart + (e.g. certificate issuance failed, or the request path changed). + """ + with app.app_context(): + config = HTTPSConfig.get_config() + if not config or not config.https_enabled: + print(' HTTPS is not enabled — nothing to verify.') + return 0 + + probe_host = config.ip_address or config.hostname or config.domain + probe_port = int(os.getenv('HTTPS_PORT') or config.port or 443) + print(f' Probing https://{probe_host}:{probe_port} …') + + ok, detail = verify_https(probe_host, probe_port) + if ok: + print(f' ✓ HTTPS is working ({detail})') + return 0 + + print(f' ✗ HTTPS is NOT working: {detail}', file=sys.stderr) + print(' ↩ Falling back to plain HTTP so the server stays reachable.', + file=sys.stderr) + log_action('warning', f'HTTPS verify failed ({detail}); fell back to HTTP') + + HTTPSConfig.create_or_update( + https_enabled=False, + hostname=config.hostname, + domain=None, + ip_address=config.ip_address, + email=config.email, + port=config.port, + updated_by='deploy.sh', + ) + http_only = CaddyConfigGenerator.generate_caddyfile(HTTPSConfig.get_config()) + CaddyConfigGenerator.write_caddyfile(http_only) + CaddyConfigGenerator.reload_caddy() + return 1 + + +def main() -> int: + parser = argparse.ArgumentParser(description='DigiServer HTTPS manager') + sub = parser.add_subparsers(dest='command', required=True) + + p_enable = sub.add_parser('enable', help='enable HTTPS and reload Caddy') + p_enable.add_argument('hostname', nargs='?', default=None) + p_enable.add_argument('domain', nargs='?', default=None, + help='Public domain; leave empty for internal CA') + p_enable.add_argument('email', nargs='?', default=None) + p_enable.add_argument('ip_address', nargs='?', default=None) + p_enable.add_argument('port', nargs='?', type=int, default=443) + p_enable.add_argument('--redirect-only', action='store_true', + help='Do not serve plain HTTP alongside HTTPS; ' + 'redirect to HTTPS instead') + p_enable.add_argument('--no-https', action='store_true', + help='Configure HTTP only (no TLS)') + p_enable.add_argument('--no-verify', action='store_true', + help='Skip the post-reload HTTPS probe (no auto-fallback)') + + sub.add_parser('disable', help='disable HTTPS (HTTP only)') + sub.add_parser('status', help='print the current configuration') + sub.add_parser( + 'verify', + help='probe the HTTPS endpoint and fall back to HTTP if it is broken') + sub.add_parser( + 'bootstrap', + help='configure HTTPS from HOSTNAME_INTERNAL/HOST_IP env vars ' + '(no-op when unset)') + + args = parser.parse_args() + app = create_app() + + if args.command == 'status': + return _status(app) + + if args.command == 'bootstrap': + return bootstrap_from_env(app) + + if args.command == 'verify': + return _verify_current(app) + + if args.command == 'disable': + return _apply(app, https_enabled=False, hostname=None, domain=None, + email=None, ip_address=None, port=443, + http_fallback=True) + + # enable + if args.no_https: + return _apply(app, https_enabled=False, hostname=args.hostname, + domain=None, email=args.email, + ip_address=args.ip_address, port=args.port, + http_fallback=True) + + if not args.ip_address: + print('error: ip_address is required to enable HTTPS', file=sys.stderr) + return 1 + + return _apply(app, https_enabled=True, hostname=args.hostname, + domain=args.domain, email=args.email, + ip_address=args.ip_address, port=args.port, + http_fallback=not args.redirect_only, + verify=not args.no_verify) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/migrate_network.sh b/migrate_network.sh index 03bb2d5..c7441de 100755 --- a/migrate_network.sh +++ b/migrate_network.sh @@ -25,7 +25,18 @@ fi NEW_IP="$1" HOSTNAME="${2:-digiserver}" EMAIL="${EMAIL:-admin@example.com}" -PORT="${PORT:-443}" +HTTP_PORT="${HTTP_PORT:-80}" +HTTPS_PORT="${HTTPS_PORT:-443}" + +# Accept either the modern compose plugin or the standalone v1 binary. +if docker compose version &> /dev/null; then + COMPOSE="docker compose" +elif command -v docker-compose &> /dev/null; then + COMPOSE="docker-compose" +else + echo -e "${RED}❌ docker compose not found!${NC}" + exit 1 +fi # Validate IP format if ! [[ "$NEW_IP" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then @@ -42,43 +53,34 @@ echo -e "${BLUE}Migration Settings:${NC}" echo " New IP Address: $NEW_IP" echo " Hostname: $HOSTNAME" echo " Email: $EMAIL" -echo " Port: $PORT" +echo " HTTP port: $HTTP_PORT" +echo " HTTPS port: $HTTPS_PORT" echo "" # Check if containers are running echo -e "${YELLOW}🔍 [1/4] Checking containers...${NC}" -if ! docker compose ps | grep -q "digiserver-app"; then - echo -e "${RED}❌ digiserver-app container not running!${NC}" - echo "Please start containers with: docker compose up -d" +if ! $COMPOSE ps | grep -q "digiserver-v2"; then + echo -e "${RED}❌ digiserver-v2 container not running!${NC}" + echo "Please start containers with: $COMPOSE up -d" exit 1 fi echo -e "${GREEN}✅ Containers are running${NC}" echo "" -# Step 1: Regenerate SSL certificates for new IP -echo -e "${YELLOW}🔐 [2/4] Regenerating SSL certificates for new IP...${NC}" -echo " Generating self-signed certificate for $NEW_IP..." +# Step 1: Update HTTPS configuration for the new IP +# NOTE: Caddy manages its own certificates (internal CA or ACME), so no manual +# cert generation is needed. Setting the IP in HTTPSConfig and re-applying the +# config is enough — Caddy issues a new certificate for the new address. +echo -e "${YELLOW}🔐 [2/4] Updating TLS configuration for the new IP...${NC}" +echo " Caddy will issue a certificate for $NEW_IP automatically." -CERT_DIR="./data/nginx-ssl" -mkdir -p "$CERT_DIR" - -openssl req -x509 -nodes -days 365 \ - -newkey rsa:2048 \ - -keyout "$CERT_DIR/key.pem" \ - -out "$CERT_DIR/cert.pem" \ - -subj "/CN=$NEW_IP/O=DigiServer/C=US" >/dev/null 2>&1 - -chmod 644 "$CERT_DIR/cert.pem" -chmod 600 "$CERT_DIR/key.pem" - -echo -e " ${GREEN}✓${NC} Certificates regenerated for $NEW_IP" -echo -e "${GREEN}✅ SSL certificates updated${NC}" +echo -e "${GREEN}✅ TLS handled by Caddy (no manual certificates)${NC}" echo "" # Step 2: Update HTTPS configuration in database echo -e "${YELLOW}🔧 [3/4] Updating HTTPS configuration in database...${NC}" -docker compose exec -T digiserver-app python << EOF +$COMPOSE exec -T digiserver-app python << EOF from app.app import create_app from app.models.https_config import HTTPSConfig from app.extensions import db @@ -87,13 +89,13 @@ app = create_app('production') with app.app_context(): # Update or create HTTPS config for the new IP https_config = HTTPSConfig.query.first() - + if https_config: https_config.hostname = '$HOSTNAME' https_config.ip_address = '$NEW_IP' https_config.email = '$EMAIL' - https_config.port = $PORT - https_config.enabled = True + https_config.port = $HTTPS_PORT + https_config.https_enabled = True db.session.commit() print(f" ✓ HTTPS configuration updated") print(f" Hostname: {https_config.hostname}") @@ -104,18 +106,23 @@ with app.app_context(): print(" This will be created on next app startup") EOF +# Re-apply so Caddy picks up the new address and issues a certificate for it. +$COMPOSE exec -T -e HOSTNAME_INTERNAL="$HOSTNAME" -e HOST_IP="$NEW_IP" \ + -e HTTPS_PORT="$HTTPS_PORT" \ + digiserver-app python /app/https_manager.py bootstrap + echo -e "${GREEN}✅ Database configuration updated${NC}" echo "" # Step 3: Restart containers echo -e "${YELLOW}🔄 [4/4] Restarting containers...${NC}" -docker compose restart nginx digiserver-app +$COMPOSE restart caddy digiserver-app sleep 3 -if ! docker compose ps | grep -q "Up"; then +if ! $COMPOSE ps | grep -q "Up"; then echo -e "${RED}❌ Containers failed to restart!${NC}" - docker compose logs | tail -20 + $COMPOSE logs | tail -20 exit 1 fi @@ -126,10 +133,13 @@ echo "" echo -e "${YELLOW}🔍 Verifying HTTPS connectivity...${NC}" sleep 2 -if curl -s -k -I https://$NEW_IP 2>/dev/null | grep -q "HTTP"; then +_https_port_suffix="" +[ "$HTTPS_PORT" != "443" ] && _https_port_suffix=":$HTTPS_PORT" + +if curl -s -k -o /dev/null -m 8 "https://$NEW_IP$_https_port_suffix/" 2>/dev/null; then echo -e "${GREEN}✅ HTTPS connection verified${NC}" else - echo -e "${YELLOW}⚠️ HTTPS verification pending (containers warming up)${NC}" + echo -e "${YELLOW}⚠️ HTTPS verification pending (containers warming up, or Caddy is still issuing the certificate)${NC}" fi echo "" @@ -138,15 +148,22 @@ echo -e "${GREEN}║ ✅ Network Migration Complete! echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}" echo "" +_http_url="http://$NEW_IP" +[ "$HTTP_PORT" != "80" ] && _http_url="http://$NEW_IP:$HTTP_PORT" + +_https_url="https://$NEW_IP" +[ "$HTTPS_PORT" != "443" ] && _https_url="https://$NEW_IP:$HTTPS_PORT" + echo -e "${BLUE}📍 New Access Points:${NC}" -echo " 🔒 https://$NEW_IP" -echo " 🔒 https://$HOSTNAME.local (if mDNS enabled)" +echo " 🌐 $_http_url" +echo " 🔒 $_https_url" +echo " 🔒 https://$HOSTNAME (needs DNS or an /etc/hosts entry)" echo "" echo -e "${BLUE}📋 Changes Made:${NC}" -echo " ✓ SSL certificates regenerated for $NEW_IP" -echo " ✓ Database HTTPS config updated" -echo " ✓ Nginx and app containers restarted" +echo " ✓ HTTPS configuration updated for $NEW_IP" +echo " ✓ Caddy re-applied (internal CA certificate reissued for the new address)" +echo " ✓ Caddy and app containers restarted" echo "" echo -e "${YELLOW}⏳ Allow 30 seconds for containers to become fully healthy${NC}" diff --git a/migrations/migrate_player_user_global.py b/migrations/migrate_player_user_global.py index 3a1bf23..39a4dc6 100644 --- a/migrations/migrate_player_user_global.py +++ b/migrations/migrate_player_user_global.py @@ -1,4 +1,13 @@ -"""Migrate player_user table to remove player_id and make user_code unique globally.""" +"""Migrate player_user table to remove player_id and make user_code unique globally. + +Idempotent and data-preserving: the table is only rebuilt when the legacy +``player_id`` column is actually present. Existing code/name pairs are carried +over into the new schema, and on a fresh or already-migrated database this +script is a no-op. + +The rebuild uses a temporary table rather than a bare ``DROP TABLE`` so that +user data is never lost. +""" import sys sys.path.insert(0, '/app') @@ -10,15 +19,51 @@ app = create_app('production') with app.app_context(): print("Migrating player_user table...") - - # Drop existing table and recreate with new schema - db.session.execute(text('DROP TABLE IF EXISTS player_user')) - db.session.commit() - - # Create new table - db.create_all() - + + inspector = db.inspect(db.engine) + + if 'player_user' not in inspector.get_table_names(): + # Fresh database — just create the current schema. + db.create_all() + print("✓ player_user table created with current schema.") + sys.exit(0) + + columns = [col['name'] for col in inspector.get_columns('player_user')] + + if 'player_id' not in columns: + # Already migrated — running the migration again must not destroy data. + db.create_all() + print("✓ player_user table already migrated, skipping.") + sys.exit(0) + + # Legacy schema detected: rebuild it, preserving user_code/user_name. + print(" Legacy schema detected (player_id present) — rebuilding...") + with db.engine.connect() as conn: + conn.execute(text( + 'CREATE TABLE player_user_new (' + ' id INTEGER PRIMARY KEY,' + ' user_code VARCHAR(255) NOT NULL UNIQUE,' + ' user_name VARCHAR(255),' + ' created_at DATETIME NOT NULL,' + ' updated_at DATETIME NOT NULL' + ')' + )) + + # De-duplicate on user_code (the new schema makes it globally unique). + conn.execute(text( + 'INSERT OR IGNORE INTO player_user_new ' + '(id, user_code, user_name, created_at, updated_at) ' + 'SELECT id, user_code, user_name, created_at, updated_at ' + 'FROM player_user ' + 'WHERE user_code IS NOT NULL' + )) + + conn.execute(text('DROP TABLE player_user')) + conn.execute(text('ALTER TABLE player_user_new RENAME TO player_user')) + conn.commit() + print("✓ player_user table migrated successfully!") print(" - Removed player_id foreign key") print(" - Made user_code unique globally") print(" - user_name is now nullable") + print(" - Existing user_code/user_name rows preserved") diff --git a/old_code_documentation/.env.example b/old_code_documentation/.env.example deleted file mode 100644 index 7665ab7..0000000 --- a/old_code_documentation/.env.example +++ /dev/null @@ -1,21 +0,0 @@ -# Flask Environment -FLASK_APP=app.py -FLASK_ENV=development - -# Security -SECRET_KEY=change-this-to-a-random-secret-key - -# Domain & SSL (for HTTPS with Caddy) -DOMAIN=your-domain.com -EMAIL=admin@your-domain.com - -# Database -DATABASE_URL=sqlite:///instance/dev.db - -# Admin User Credentials (used during initial Docker deployment) -# These credentials are set when the database is first created -ADMIN_USERNAME=admin -ADMIN_PASSWORD=change-this-secure-password - -# Optional: Sentry for error tracking -# SENTRY_DSN=your-sentry-dsn-here diff --git a/old_code_documentation/CADDY_DYNAMIC_CONFIG.md b/old_code_documentation/CADDY_DYNAMIC_CONFIG.md deleted file mode 100644 index b3a9826..0000000 --- a/old_code_documentation/CADDY_DYNAMIC_CONFIG.md +++ /dev/null @@ -1,295 +0,0 @@ -# Caddy Dynamic Configuration Management - -## Overview - -The HTTPS configuration system now automatically generates and manages the Caddy configuration in real-time. When an admin updates settings through the admin panel, the Caddyfile is regenerated and reloaded without requiring a full container restart. - -## How It Works - -### 1. **Configuration Generation** -When admin saves HTTPS settings: -1. Settings are saved to database (HTTPSConfig table) -2. `CaddyConfigGenerator` creates a new Caddyfile based on settings -3. Generated Caddyfile is written to disk - -### 2. **Configuration Reload** -After Caddyfile is written: -1. Caddy reload API is called via `docker-compose exec` -2. Caddy validates and applies new configuration -3. No downtime - live configuration update - -### 3. **Fallback Configuration** -If HTTPS is disabled: -1. System uses default hardcoded configuration -2. Supports localhost, internal domain, and IP address -3. Catch-all configuration for any other requests - -## Files Involved - -### New Files -- **`app/utils/caddy_manager.py`** - CaddyConfigGenerator class with: - - `generate_caddyfile()` - Generates Caddyfile content - - `write_caddyfile()` - Writes to disk - - `reload_caddy()` - Reloads via Docker - -### Updated Files -- **`app/blueprints/admin.py`** - HTTPS config route now: - - Generates new Caddyfile - - Writes to disk - - Reloads Caddy automatically - - Reports status to user - -## Admin Panel Workflow - -### Step 1: User Fills Form -``` -Admin Panel → HTTPS Configuration -- Hostname: digiserver -- Domain: digiserver.sibiusb.harting.intra -- Email: admin@example.com -- IP: 10.76.152.164 -- Port: 443 -``` - -### Step 2: Admin Saves Configuration -- POST /admin/https-config/update -- Settings validated and saved to database -- Caddyfile generated dynamically -- Caddy reloaded with new configuration - -### Step 3: User Sees Confirmation -``` -✅ HTTPS configuration saved successfully! -✅ Caddy configuration updated successfully! -Server available at https://digiserver.sibiusb.harting.intra -``` - -### Step 4: Configuration Live -- New domain/IP immediately active -- No container restart needed -- Caddy applying new routes in real-time - -## Generated Caddyfile Structure - -**When HTTPS Enabled:** -```caddyfile -{ - email admin@example.com -} - -(reverse_proxy_config) { - reverse_proxy digiserver-app:5000 { ... } - request_body { max_size 2GB } - header { ... } - log { ... } -} - -http://localhost { import reverse_proxy_config } -http://digiserver.sibiusb.harting.intra { import reverse_proxy_config } -http://10.76.152.164 { import reverse_proxy_config } -http://* { import reverse_proxy_config } -``` - -**When HTTPS Disabled:** -```caddyfile -{ - email admin@localhost -} - -(reverse_proxy_config) { ... } - -http://localhost { import reverse_proxy_config } -http://digiserver.sibiusb.harting.intra { import reverse_proxy_config } -http://10.76.152.164 { import reverse_proxy_config } -http://* { import reverse_proxy_config } -``` - -## Key Features - -### ✅ No Restart Required -- Caddyfile changes applied without restarting containers -- Caddy reload API handles configuration hot-swap -- Zero downtime configuration updates - -### ✅ Dynamic Configuration -- Settings in admin panel → Generated Caddyfile -- Database is source of truth -- Easy to modify in admin UI - -### ✅ Automatic Fallbacks -- Catch-all `http://*` handles any host -- Always has localhost access -- Always has IP address access - -### ✅ User Feedback -- Admin sees status of Caddy reload -- Error messages if Caddy reload fails -- Logging of all changes - -### ✅ Safe Updates -- Caddyfile validation before reload -- Graceful error handling -- Falls back to previous config if reload fails - -## Error Handling - -If Caddy reload fails: -1. Database still has updated settings -2. Old Caddyfile may still be in use -3. User sees warning with status -4. Admin can manually restart: `docker-compose restart caddy` - -## Admin Panel Status Messages - -### Success (✅) -``` -✅ HTTPS configuration saved successfully! -✅ Caddy configuration updated successfully! -Server available at https://domain.local -``` - -### Partial Success (⚠️) -``` -✅ HTTPS configuration saved successfully! -⚠️ Caddyfile updated but reload failed. Please restart containers. -Server available at https://domain.local -``` - -### Configuration Saved, Update Failed (⚠️) -``` -⚠️ Configuration saved but Caddy update failed: [error details] -``` - -## Testing Configuration - -### Check Caddyfile Content -```bash -cat /srv/digiserver-v2/Caddyfile -``` - -### Manually Reload Caddy -```bash -docker-compose exec caddy caddy reload --config /etc/caddy/Caddyfile -``` - -### Check Caddy Status -```bash -docker-compose logs caddy --tail=20 -``` - -### Test Access Points -```bash -# Test all configured domains/IPs -curl http://localhost -curl http://digiserver.sibiusb.harting.intra -curl http://10.76.152.164 -``` - -## Configuration Database - -Settings stored in `https_config` table: -``` -https_enabled: boolean -hostname: string -domain: string -ip_address: string -email: string -port: integer -updated_at: datetime -updated_by: string -``` - -When admin updates form → Database updated → Caddyfile regenerated → Caddy reloaded - -## Workflow Diagram - -``` -┌─────────────────────┐ -│ Admin Panel Form │ -│ (HTTPS Config) │ -└──────────┬──────────┘ - │ Submit - ↓ -┌─────────────────────┐ -│ Validate Input │ -└──────────┬──────────┘ - │ Valid - ↓ -┌─────────────────────┐ -│ Save to Database │ -│ (HTTPSConfig) │ -└──────────┬──────────┘ - │ Saved - ↓ -┌─────────────────────┐ -│ Generate Caddyfile │ -│ (CaddyConfigGen) │ -└──────────┬──────────┘ - │ Generated - ↓ -┌─────────────────────┐ -│ Write to Disk │ -│ (/Caddyfile) │ -└──────────┬──────────┘ - │ Written - ↓ -┌─────────────────────┐ -│ Reload Caddy │ -│ (Docker exec) │ -└──────────┬──────────┘ - │ Reloaded - ↓ -┌─────────────────────┐ -│ Show Status to │ -│ Admin (Success) │ -└─────────────────────┘ -``` - -## Implementation Details - -### CaddyConfigGenerator Class - -**generate_caddyfile(config)** -- Takes HTTPSConfig from database -- Generates complete Caddyfile content -- Uses shared reverse proxy configuration template -- Returns full Caddyfile as string - -**write_caddyfile(content, path)** -- Writes generated content to disk -- Path defaults to /srv/digiserver-v2/Caddyfile -- Returns True on success, False on error - -**reload_caddy()** -- Runs: `docker-compose exec -T caddy caddy reload` -- Validates config and applies live -- Returns True on success, False on error - -## Advantages Over Manual Configuration - -| Manual | Dynamic | -|--------|---------| -| Edit Caddyfile manually | Change via admin panel | -| Restart container | No restart needed | -| Risk of syntax errors | Validated generation | -| No audit trail | Logged with username | -| Each change is manual | One-time setup | - -## Future Enhancements - -Potential improvements: -- Configuration history/backup -- Rollback to previous config -- Health check after reload -- Automatic backup before update -- Configuration templates -- Multi-domain support - -## Support - -For issues: -1. Check admin panel messages for Caddy reload status -2. Review logs: `docker-compose logs caddy` -3. Check Caddyfile: `cat /srv/digiserver-v2/Caddyfile` -4. Manual reload: `docker-compose exec caddy caddy reload --config /etc/caddy/Caddyfile` -5. Full restart: `docker-compose restart caddy` diff --git a/old_code_documentation/DATA_DEPLOYMENT.md b/old_code_documentation/DATA_DEPLOYMENT.md deleted file mode 100644 index 561f97e..0000000 --- a/old_code_documentation/DATA_DEPLOYMENT.md +++ /dev/null @@ -1,75 +0,0 @@ -# Data Folder Deployment Guide - -## Overview - -The `./data` folder is the **persistent data storage** for the DigiServer deployment. It is **NOT committed to the repository** but contains all necessary files copied from the repo during deployment. - -## Structure - -``` -data/ -├── app/ # Complete application code (copied from ./app) -├── Caddyfile # Reverse proxy configuration (copied from root) -├── instance/ # Flask instance folder (database, configs) -├── uploads/ # User file uploads -├── caddy-data/ # Caddy SSL certificates and cache -└── caddy-config/ # Caddy configuration data -``` - -## Deployment Process - -### Step 1: Initialize Data Folder - -Run this script to copy all necessary files from the repository to `./data`: - -```bash -./init-data.sh -``` - -This will: -- Create the `./data` directory structure -- Copy `./app` folder to `./data/app` -- Copy `Caddyfile` to `./data/Caddyfile` -- Set proper permissions for all files and folders - -### Step 2: Start Docker Containers - -```bash -docker-compose up -d --build -``` - -### Step 3: Run Migrations (First Time Only) - -```bash -sudo bash deploy.sh -``` - -## Important Notes - -- **./data is NOT in git**: The `./data` folder is listed in `.gitignore` and will not be committed -- **All persistent data here**: Database files, uploads, certificates, and configurations are stored in `./data` -- **Easy backups**: To backup the entire deployment, backup the `./data` folder -- **Easy troubleshooting**: Check the `./data` folder to verify all required files are present -- **Updates**: When you pull new changes, run `./init-data.sh` to update app files in `./data` - -## Deployment Checklist - -✓ All volumes in docker-compose.yml point to `./data` -✓ `./data` folder contains: app/, Caddyfile, instance/, uploads/, caddy-data/, caddy-config/ -✓ Files are copied from repository to `./data` via init-data.sh -✓ Permissions are correctly set for Docker container user - -## Verification - -Before starting: -```bash -ls -la data/ -# Should show: app/, Caddyfile, instance/, uploads/, caddy-data/, caddy-config/ -``` - -After deployment check data folder for: -```bash -data/instance/*.db # Database files -data/uploads/ # User uploads -data/caddy-data/*.pem # SSL certificates -``` diff --git a/old_code_documentation/DEPLOYMENT_ARCHITECTURE_ANALYSIS.md b/old_code_documentation/DEPLOYMENT_ARCHITECTURE_ANALYSIS.md deleted file mode 100644 index 3443ccd..0000000 --- a/old_code_documentation/DEPLOYMENT_ARCHITECTURE_ANALYSIS.md +++ /dev/null @@ -1,201 +0,0 @@ -# Dockerfile vs init-data.sh Analysis - -**Date:** January 17, 2026 - -## Current Architecture - -### Current Workflow -``` -1. Run init-data.sh (on host) - ↓ -2. Copies app code → data/app/ -3. Docker build creates image -4. Docker run mounts ./data:/app -5. Container runs with host's data/ folder -``` - -### Current Docker Setup -- **Dockerfile**: Copies code from build context to `/app` inside image -- **docker-compose**: Mounts `./data:/app` **OVERRIDING** the Dockerfile copy -- **Result**: Code in image is replaced by volume mount to host's `./data` folder - ---- - -## Problem with Current Approach - -1. **Code Duplication** - - Code exists in: Host `./app/` folder - - Code copied to: Host `./data/app/` folder - - Code in Docker image: Ignored/overridden - -2. **Extra Deployment Step** - - Must run `init-data.sh` before deployment - - Manual file copying required - - Room for sync errors - -3. **No Dockerfile Optimization** - - Dockerfile copies code but it's never used - - Volume mount replaces everything - - Wastes build time and image space - ---- - -## Proposed Solution: Two Options - -### **Option 1: Use Dockerfile Copy (Recommended)** ✅ - -**Change Dockerfile:** -```dockerfile -# Copy everything to /app inside image -COPY . /app/ - -# No need for volume mount - image contains all code -``` - -**Change docker-compose.yml:** -```yaml -volumes: - # REMOVE the ./data:/app volume mount - # Keep only data-specific mounts: - - ./data/instance:/app/instance # Database - - ./data/uploads:/app/app/static/uploads # User uploads -``` - -**Benefits:** -- ✅ Single source of truth (Dockerfile) -- ✅ Code is immutable in image -- ✅ No init-data.sh needed -- ✅ Faster deployment (no file copying) -- ✅ Cleaner architecture -- ✅ Can upgrade code by rebuilding image - -**Drawbacks:** -- Code changes require docker-compose rebuild -- Can't edit code in container (which is good for production) - ---- - -### **Option 2: Keep Current (With Improvements)** - -**Keep:** -- init-data.sh for copying code to data/ -- Volume mount at ./data:/app - -**Improve:** -- Add validation that init-data.sh ran successfully -- Check file sync status before starting app -- Add automated sync on container restart - -**Benefits:** -- ✅ Dev-friendly (can edit code, restart container) -- ✅ Faster iteration during development - -**Drawbacks:** -- ❌ Production anti-pattern (code changes without rebuild) -- ❌ Extra deployment complexity -- ❌ Manual init-data.sh step required - ---- - -## Current Production Setup Evaluation - -**Current System:** Option 2 (with volume mount override) - -### Why This Setup Exists - -The current architecture with `./data:/app` volume mount suggests: -1. **Development-focused** - Allows code editing and hot-reload -2. **Host-based persistence** - All data on host machine -3. **Easy backup** - Just backup the `./data/` folder - -### Is This Actually Used? - -- ✅ Code updates via `git pull` in `/app/` folder -- ✅ Then `cp -r app/* data/app/` copies to running container -- ✅ Allows live code updates without container rebuild - ---- - -## Recommendation - -### For Production -**Use Option 1 (Dockerfile-based):** -- Build immutable images -- No init-data.sh needed -- Cleaner deployment pipeline -- Better for CI/CD - -### For Development -**Keep Option 2 (current approach):** -- Code editing and hot-reload -- Faster iteration - ---- - -## Implementation Steps for Option 1 - -### 1. **Update Dockerfile** -```dockerfile -# Instead of: COPY . . -# Change docker-compose volume mount pattern -``` - -### 2. **Update docker-compose.yml** -```yaml -volumes: - # Remove: ./data:/app - # Keep only: - - ./data/instance:/app/instance - - ./data/uploads:/app/app/static/uploads -``` - -### 3. **Update deploy.sh** -```bash -# Remove: bash init-data.sh -# Just build and run: -docker-compose build -docker-compose up -d -``` - -### 4. **Add Migration Path** -```bash -# For existing deployments: -# Copy any instance/database data from data/instance to new location -``` - ---- - -## Data Persistence Strategy (Post-Migration) - -``` -Current: After Option 1: -./data/app/ (code) → /app/ (in image) -./data/instance/ (db) → ./data/instance/ (volume mount) -./data/uploads/ (files) → ./data/uploads/ (volume mount) -``` - ---- - -## Risk Assessment - -### Option 1 (Dockerfile-only) -- **Risk Level:** LOW ✅ -- **Data Loss Risk:** NONE (instance & uploads still mounted) -- **Rollback:** Can use old image tag - -### Option 2 (Current) -- **Risk Level:** MEDIUM -- **Data Loss Risk:** Manual copying errors -- **Rollback:** Manual file restore - ---- - -## Conclusion - -**Recommendation: Option 1 (Dockerfile-based)** for production deployment -- Simpler architecture -- Better practices -- Faster deployment -- Cleaner code management - -Would you like to implement this change? diff --git a/old_code_documentation/DEPLOYMENT_COMMANDS.md b/old_code_documentation/DEPLOYMENT_COMMANDS.md deleted file mode 100644 index 5b211f8..0000000 --- a/old_code_documentation/DEPLOYMENT_COMMANDS.md +++ /dev/null @@ -1,272 +0,0 @@ -# DigiServer Deployment Commands - -This document contains all necessary `docker exec` commands to deploy and configure DigiServer on a new PC with the same settings as the production system. - -## Prerequisites - -```bash -# Ensure you're in the project directory -cd /path/to/digiserver-v2 - -# Start the containers -docker-compose up -d -``` - -## 1. Database Initialization and Migrations - -### Run all database migrations in sequence: - -```bash -# Create https_config table -docker-compose exec -T digiserver-app python /app/migrations/add_https_config_table.py - -# Create player_user table -docker-compose exec -T digiserver-app python /app/migrations/add_player_user_table.py - -# Add email to https_config table -docker-compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py - -# Migrate player_user global settings -docker-compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py -``` - -**Note:** The `-T` flag prevents Docker from allocating a pseudo-terminal, which is useful for automated deployments. - -## 2. HTTPS Configuration via CLI - -### Check HTTPS Configuration Status: - -```bash -docker-compose exec -T digiserver-app python /app/https_manager.py status -``` - -### Enable HTTPS with Production Settings: - -```bash -docker-compose exec -T digiserver-app python /app/https_manager.py enable \ - digiserver \ - digiserver.sibiusb.harting.intra \ - admin@example.com \ - 10.76.152.164 \ - 443 -``` - -### Show Detailed Configuration: - -```bash -docker-compose exec -T digiserver-app python /app/https_manager.py show -``` - -## 3. Admin User Setup - -### Create/Reset Admin User (if needed): - -```bash -docker-compose exec -T digiserver-app python -c " -from app.app import create_app -from app.models.user import User -from app.extensions import db - -app = create_app() -with app.app_context(): - # Check if admin exists - admin = User.query.filter_by(username='admin').first() - if admin: - print('✅ Admin user already exists') - else: - # Create new admin user - admin = User(username='admin', email='admin@example.com') - admin.set_password('admin123') # Change this password! - admin.is_admin = True - db.session.add(admin) - db.session.commit() - print('✅ Admin user created with username: admin') -" -``` - -## 4. Database Verification - -### Check Database Tables: - -```bash -docker-compose exec -T digiserver-app python -c " -from app.app import create_app -from app.extensions import db -from sqlalchemy import inspect - -app = create_app() -with app.app_context(): - inspector = inspect(db.engine) - tables = inspector.get_table_names() - print('📊 Database Tables:') - for table in sorted(tables): - print(f' ✓ {table}') - print(f'\\n✅ Total tables: {len(tables)}') -" -``` - -### Check HTTPS Configuration in Database: - -```bash -docker-compose exec -T digiserver-app python -c " -from app.app import create_app -from app.models.https_config import HTTPSConfig - -app = create_app() -with app.app_context(): - config = HTTPSConfig.get_config() - if config: - print('✅ HTTPS Configuration Found:') - print(f' Status: {\"ENABLED\" if config.https_enabled else \"DISABLED\"}') - print(f' Hostname: {config.hostname}') - print(f' Domain: {config.domain}') - print(f' IP Address: {config.ip_address}') - print(f' Port: {config.port}') - else: - print('⚠️ No HTTPS configuration found') -" -``` - -## 5. Health Checks - -### Test Caddy Configuration: - -```bash -docker-compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile -``` - -### Test Flask Application Health: - -```bash -docker-compose exec -T digiserver-app python -c " -import urllib.request -try: - response = urllib.request.urlopen('http://localhost:5000/health', timeout=5) - print('✅ Application is responding') - print(f' Status: {response.status}') -except Exception as e: - print(f'❌ Application health check failed: {e}') -" -``` - -### Check Docker Container Logs: - -```bash -# Flask app logs -docker-compose logs digiserver-app | tail -50 - -# Caddy logs -docker-compose logs caddy | tail -50 -``` - -## 6. Complete Deployment Script - -Create a file called `deploy.sh` to run all steps automatically: - -```bash -#!/bin/bash -set -e - -echo "🚀 DigiServer Deployment Script" -echo "==================================" -echo "" - -# Change to project directory -cd /path/to/digiserver-v2 - -# Step 1: Start containers -echo "📦 Starting containers..." -docker-compose up -d -sleep 5 - -# Step 2: Run migrations -echo "📊 Running database migrations..." -docker-compose exec -T digiserver-app python /app/migrations/add_https_config_table.py -docker-compose exec -T digiserver-app python /app/migrations/add_player_user_table.py -docker-compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py -docker-compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py - -# Step 3: Configure HTTPS -echo "🔒 Configuring HTTPS..." -docker-compose exec -T digiserver-app python /app/https_manager.py enable \ - digiserver \ - digiserver.sibiusb.harting.intra \ - admin@example.com \ - 10.76.152.164 \ - 443 - -# Step 4: Verify setup -echo "✅ Verifying setup..." -docker-compose exec -T digiserver-app python /app/https_manager.py status - -echo "" -echo "🎉 Deployment Complete!" -echo "==================================" -echo "Access your application at:" -echo " - https://digiserver" -echo " - https://10.76.152.164" -echo " - https://digiserver.sibiusb.harting.intra" -echo "" -echo "Login with:" -echo " Username: admin" -echo " Password: (check your password settings)" -``` - -Make it executable: -```bash -chmod +x deploy.sh -``` - -Run it: -```bash -./deploy.sh -``` - -## 7. Troubleshooting - -### Restart Services: - -```bash -# Restart all containers -docker-compose restart - -# Restart just the app -docker-compose restart digiserver-app - -# Restart just Caddy -docker-compose restart caddy -``` - -### View Caddy Configuration: - -```bash -docker-compose exec -T caddy cat /etc/caddy/Caddyfile -``` - -### Test HTTPS Endpoints: - -```bash -# Test from host machine (if accessible) -curl -k https://digiserver.sibiusb.harting.intra - -# Test from within containers -docker-compose exec -T caddy wget --no-check-certificate -qO- https://localhost/ | head -20 -``` - -### Clear Caddy Cache (if certificate issues occur): - -```bash -docker volume rm digiserver-v2_caddy-data -docker volume rm digiserver-v2_caddy-config -docker-compose restart caddy -``` - -## Important Notes - -- Always use `-T` flag with `docker-compose exec` in automated scripts to prevent TTY issues -- Change default passwords (`admin123`) in production environments -- Adjust email address in HTTPS configuration as needed -- For different network setups, modify the IP address and domain in the enable HTTPS command -- Keep database backups before running migrations -- Test all three access points after deployment - diff --git a/old_code_documentation/DEPLOYMENT_INDEX.md b/old_code_documentation/DEPLOYMENT_INDEX.md deleted file mode 100644 index 48269f5..0000000 --- a/old_code_documentation/DEPLOYMENT_INDEX.md +++ /dev/null @@ -1,278 +0,0 @@ -# 📚 DigiServer Deployment Documentation Index - -Complete documentation for deploying and maintaining DigiServer. Choose your path below: - ---- - -## 🚀 I Want to Deploy Now! - -### Quick Start (2 minutes) -```bash -cd /path/to/digiserver-v2 -./deploy.sh -``` -→ See [DEPLOYMENT_README.md](DEPLOYMENT_README.md) - -### Or Step-by-Step Setup -```bash -./setup_https.sh -``` - ---- - -## 📖 Documentation Files - -### 1. **[DEPLOYMENT_README.md](DEPLOYMENT_README.md)** ⭐ START HERE - - **Size**: 9.4 KB - - **Purpose**: Complete deployment guide for beginners - - **Contains**: - - Quick start instructions - - Prerequisites checklist - - 3 deployment methods (auto, semi-auto, manual) - - Verification procedures - - First access setup - - Troubleshooting guide - - **Read time**: 15-20 minutes - -### 2. **[DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md)** ⭐ REFERENCE GUIDE - - **Size**: 7.6 KB - - **Purpose**: Quick reference for all docker exec commands - - **Contains**: - - Database migrations - - HTTPS configuration - - User management - - Database inspection - - Health checks - - Maintenance commands - - Troubleshooting commands - - **Use when**: You need a specific command - - **Read time**: 5-10 minutes (or search for what you need) - -### 3. **[DEPLOYMENT_COMMANDS.md](DEPLOYMENT_COMMANDS.md)** - - **Size**: 6.8 KB - - **Purpose**: Detailed deployment command explanations - - **Contains**: - - Individual command explanations - - Complete deployment script template - - Health check procedures - - Verification steps - - Advanced troubleshooting - - **Read time**: 20-30 minutes - ---- - -## 🔧 Executable Scripts - -### 1. **[deploy.sh](deploy.sh)** - Fully Automated - - **Size**: 6.7 KB - - **Purpose**: One-command deployment - - **Does**: - 1. Starts Docker containers - 2. Runs all migrations - 3. Configures HTTPS - 4. Verifies setup - 5. Shows access URLs - - **Usage**: - ```bash - ./deploy.sh - ``` - - **With custom settings**: - ```bash - HOSTNAME=server1 DOMAIN=server1.internal ./deploy.sh - ``` - -### 2. **[setup_https.sh](setup_https.sh)** - Semi-Automated - - **Size**: 5.9 KB - - **Purpose**: Setup script that works in or outside Docker - - **Does**: - - Detects environment (Docker container or host) - - Runs migrations - - Configures HTTPS - - Shows status - - **Usage**: - ```bash - ./setup_https.sh - ``` - ---- - -## 🎯 Quick Navigation by Task - -### "I need to deploy on a new PC" -1. Read: [DEPLOYMENT_README.md](DEPLOYMENT_README.md#prerequisites) -2. Run: `./deploy.sh` -3. Access: https://digiserver.sibiusb.harting.intra - -### "I need a specific docker exec command" -→ Search [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md) - -### "I want to understand what's being deployed" -→ Read [DEPLOYMENT_COMMANDS.md](DEPLOYMENT_COMMANDS.md#prerequisites) - -### "Something went wrong, help!" -→ See [DEPLOYMENT_README.md](DEPLOYMENT_README.md#-troubleshooting) or [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#-troubleshooting) - -### "I need to configure custom settings" -→ Read [DEPLOYMENT_README.md](DEPLOYMENT_README.md#-environment-variables) - -### "I want to manage HTTPS" -→ See [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#-https-configuration-management) - ---- - -## 📋 Deployment Checklist - -- [ ] Docker and Docker Compose installed -- [ ] Project files copied to new PC -- [ ] Run `./deploy.sh` or `./setup_https.sh` -- [ ] Verify with `docker-compose ps` -- [ ] Access https://digiserver.sibiusb.harting.intra -- [ ] Log in with admin/admin123 -- [ ] Change default password -- [ ] Configure players and content - ---- - -## 🔑 Configuration Options - -### Default Settings -``` -Hostname: digiserver -Domain: digiserver.sibiusb.harting.intra -IP Address: 10.76.152.164 -Port: 443 -Email: admin@example.com -Username: admin -Password: admin123 -``` - -### Customize During Deployment -```bash -HOSTNAME=myserver \ -DOMAIN=myserver.internal \ -IP_ADDRESS=192.168.1.100 \ -EMAIL=admin@myserver.com \ -./deploy.sh -``` - ---- - -## 🆘 Common Tasks - -| Task | Command | -|------|---------| -| **Start containers** | `docker-compose up -d` | -| **Stop containers** | `docker-compose stop` | -| **View logs** | `docker-compose logs -f` | -| **Check HTTPS status** | `docker-compose exec -T digiserver-app python /app/https_manager.py status` | -| **Reset password** | See [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#reset-admin-password) | -| **View all tables** | See [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#list-all-tables) | -| **Create admin user** | See [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#create-admin-user) | - ---- - -## 📊 File Structure - -``` -digiserver-v2/ -├── DEPLOYMENT_README.md ..................... Main deployment guide -├── DOCKER_EXEC_COMMANDS.md ................. Quick reference (BEST FOR COMMANDS) -├── DEPLOYMENT_COMMANDS.md .................. Detailed explanations -├── deploy.sh ............................. Fully automated deployment -├── setup_https.sh ......................... Semi-automated setup -├── docker-compose.yml ..................... Docker services config -├── Caddyfile .............................. Reverse proxy config -├── requirements.txt ....................... Python dependencies -│ -├── app/ -│ ├── app.py ............................ Flask application -│ ├── models/ ........................... Database models -│ │ ├── https_config.py -│ │ ├── user.py -│ │ └── ... -│ └── ... -│ -├── migrations/ -│ ├── add_https_config_table.py -│ ├── add_player_user_table.py -│ ├── add_email_to_https_config.py -│ └── migrate_player_user_global.py -│ -└── old_code_documentation/ - ├── HTTPS_CONFIGURATION.md - └── ... -``` - ---- - -## 🚀 Deployment Methods Comparison - -| Method | Time | Effort | Best For | -|--------|------|--------|----------| -| `./deploy.sh` | 2-3 min | Click & wait | First-time setup, automation | -| `./setup_https.sh` | 3-5 min | Manual review | Learning, step debugging | -| Manual commands | 10-15 min | Full control | Advanced users, scripting | - ---- - -## ✨ What Gets Deployed - -✅ Flask web application with admin dashboard -✅ HTTPS with self-signed certificates -✅ Caddy reverse proxy for routing -✅ SQLite database with all tables -✅ User management system -✅ HTTPS configuration management -✅ Player and content management -✅ Group and playlist management -✅ Admin audit trail - ---- - -## 🎓 Learning Path - -1. **Total Beginner?** - - Start: [DEPLOYMENT_README.md](DEPLOYMENT_README.md) - - Run: `./deploy.sh` - - Learn: Browse [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md) for available commands - -2. **Want to Understand Everything?** - - Read: [DEPLOYMENT_README.md](DEPLOYMENT_README.md#-deployment-methods) (all 3 methods) - - Study: [DEPLOYMENT_COMMANDS.md](DEPLOYMENT_COMMANDS.md) - - Reference: [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md) - -3. **Need to Troubleshoot?** - - Check: [DEPLOYMENT_README.md](DEPLOYMENT_README.md#-troubleshooting) - - Or: [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#-troubleshooting) - ---- - -## 💡 Pro Tips - -1. **Use `-T` flag** in docker-compose exec for scripts (prevents TTY issues) -2. **Keep backups** before major changes -3. **Check logs often**: `docker-compose logs -f` -4. **Use environment variables** for custom deployments -5. **Verify after deployment** using health check commands - ---- - -## 🔗 Related Documentation - -- **HTTPS Setup**: `old_code_documentation/HTTPS_CONFIGURATION.md` -- **Admin Features**: Check admin panel after login -- **API Documentation**: See `old_code_documentation/PLAYER_EDIT_MEDIA_API.md` - ---- - -## 📞 Support - -- Logs: `docker-compose logs digiserver-app` -- Health Check: See [DOCKER_EXEC_COMMANDS.md#-health-checks](DOCKER_EXEC_COMMANDS.md#-health-checks) -- Troubleshooting: See [DEPLOYMENT_README.md#-troubleshooting](DEPLOYMENT_README.md#-troubleshooting) - ---- - -**Ready? Start with:** `./deploy.sh` 🚀 - -Or read [DEPLOYMENT_README.md](DEPLOYMENT_README.md) for the full guide. diff --git a/old_code_documentation/DEPLOYMENT_README.md b/old_code_documentation/DEPLOYMENT_README.md deleted file mode 100644 index ea4ea86..0000000 --- a/old_code_documentation/DEPLOYMENT_README.md +++ /dev/null @@ -1,433 +0,0 @@ -# DigiServer Deployment Guide - -Complete guide for deploying DigiServer on a new PC with automatic or manual configuration. - -## 📋 Table of Contents - -1. [Quick Start](#quick-start) -2. [Prerequisites](#prerequisites) -3. [Deployment Methods](#deployment-methods) -4. [Verification](#verification) -5. [Documentation Files](#documentation-files) -6. [Troubleshooting](#troubleshooting) - ---- - -## 🚀 Quick Start - -The fastest way to deploy DigiServer on a new PC: - -```bash -# 1. Clone or copy the project to your new PC -cd /path/to/digiserver-v2 - -# 2. Run the automated deployment script -./deploy.sh -``` - -That's it! The script will: -- ✅ Start all Docker containers -- ✅ Run all database migrations -- ✅ Configure HTTPS with self-signed certificates -- ✅ Verify the setup -- ✅ Display access URLs - ---- - -## 📋 Prerequisites - -Before deploying, ensure you have: - -### 1. Docker & Docker Compose -```bash -# Check Docker installation -docker --version - -# Check Docker Compose installation -docker-compose --version -``` - -If not installed, follow the official guides: -- [Docker Installation](https://docs.docker.com/install/) -- [Docker Compose Installation](https://docs.docker.com/compose/install/) - -### 2. Project Files -```bash -# You should have these files in the project directory: -ls -la -# Caddyfile - Reverse proxy configuration -# docker-compose.yml - Docker services definition -# setup_https.sh - Manual setup script -# deploy.sh - Automated deployment script -# requirements.txt - Python dependencies -``` - -### 3. Sufficient Disk Space -- ~2GB for Docker images and volumes -- Additional space for your content/uploads - -### 4. Network Access -- Ports 80, 443 available (or configure in docker-compose.yml) -- Port 2019 for Caddy admin API (internal only) - ---- - -## 🎯 Deployment Methods - -### Method 1: Fully Automated (Recommended) - -```bash -cd /path/to/digiserver-v2 -./deploy.sh -``` - -**What it does:** -1. Starts Docker containers -2. Runs all migrations -3. Configures HTTPS -4. Verifies setup -5. Shows access URLs - -**Configuration variables** (can be customized): -```bash -# Use environment variables to customize -HOSTNAME=digiserver \ -DOMAIN=digiserver.sibiusb.harting.intra \ -IP_ADDRESS=10.76.152.164 \ -EMAIL=admin@example.com \ -PORT=443 \ -./deploy.sh -``` - ---- - -### Method 2: Semi-Automated Setup - -```bash -cd /path/to/digiserver-v2 -./setup_https.sh -``` - -**What it does:** -1. Starts containers (if needed) -2. Runs all migrations -3. Configures HTTPS with production settings -4. Shows status - ---- - -### Method 3: Manual Step-by-Step - -#### Step 1: Start Containers -```bash -cd /path/to/digiserver-v2 -docker-compose up -d -``` - -Wait for containers to be ready (check with `docker-compose ps`). - -#### Step 2: Run Migrations -```bash -# Migration 1: HTTPS Config -docker-compose exec -T digiserver-app python /app/migrations/add_https_config_table.py - -# Migration 2: Player User -docker-compose exec -T digiserver-app python /app/migrations/add_player_user_table.py - -# Migration 3: Email -docker-compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py - -# Migration 4: Player User Global -docker-compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py -``` - -#### Step 3: Configure HTTPS -```bash -docker-compose exec -T digiserver-app python /app/https_manager.py enable \ - digiserver \ - digiserver.sibiusb.harting.intra \ - admin@example.com \ - 10.76.152.164 \ - 443 -``` - -#### Step 4: Verify Status -```bash -docker-compose exec -T digiserver-app python /app/https_manager.py status -``` - ---- - -## ✅ Verification - -### Check Container Status -```bash -docker-compose ps -``` - -Expected output: -``` -NAME SERVICE STATUS PORTS -digiserver-v2 digiserver-app Up (healthy) 5000/tcp -digiserver-caddy caddy Up 80, 443, 2019/tcp -``` - -### Test HTTPS Access -```bash -# From the same network (if DNS configured) -curl -k https://digiserver.sibiusb.harting.intra - -# Or from container -docker-compose exec -T caddy wget --no-check-certificate -qO- https://localhost/ | head -10 -``` - -### Expected Response -Should show HTML login page with "DigiServer" in the title. - -### Check Database -```bash -docker-compose exec -T digiserver-app python -c " -from app.app import create_app -from sqlalchemy import inspect - -app = create_app() -with app.app_context(): - inspector = inspect(app.extensions.db.engine) - tables = inspector.get_table_names() - print('Database tables:', len(tables)) - for t in sorted(tables): - print(f' ✓ {t}') -" -``` - ---- - -## 📚 Documentation Files - -### 1. `DOCKER_EXEC_COMMANDS.md` ⭐ **START HERE** -Quick reference for all docker exec commands -- Database operations -- User management -- HTTPS configuration -- Health checks -- Maintenance tasks - -### 2. `DEPLOYMENT_COMMANDS.md` -Comprehensive deployment guide -- Prerequisites -- Each deployment step explained -- Complete deployment script template -- Troubleshooting section - -### 3. `deploy.sh` -Automated deployment script (executable) -- Runs all steps automatically -- Shows progress with colors -- Configurable via environment variables - -### 4. `setup_https.sh` -Semi-automated setup script (executable) -- Detects if running in Docker or on host -- Manual configuration option -- Detailed output - -### 5. `Caddyfile` -Reverse proxy configuration -- HTTPS certificate management -- Domain routing -- Security headers - -### 6. `docker-compose.yml` -Docker services definition -- Flask application -- Caddy reverse proxy -- Volumes and networks - ---- - -## 🔐 First Access - -After deployment: - -1. **Access the application** - - https://digiserver.sibiusb.harting.intra - - https://10.76.152.164 - - https://digiserver - -2. **Log in with default credentials** - ``` - Username: admin - Password: admin123 - ``` - -3. **⚠️ IMPORTANT: Change the password immediately** - - Click on admin user settings - - Change default password to a strong password - -4. **Configure your system** - - Set up players - - Upload content - - Create groups - - Configure playlists - ---- - -## 🆘 Troubleshooting - -### Containers Won't Start -```bash -# Check logs -docker-compose logs - -# Try rebuilding -docker-compose down -docker-compose up -d --build -``` - -### Migration Fails -```bash -# Check database connection -docker-compose exec -T digiserver-app python -c " -from app.app import create_app -app = create_app() -print('Database OK') -" - -# Check if tables already exist -docker-compose exec -T digiserver-app python -c " -from app.app import create_app -from sqlalchemy import inspect -app = create_app() -with app.app_context(): - inspector = inspect(app.extensions.db.engine) - print('Existing tables:', inspector.get_table_names()) -" -``` - -### HTTPS Certificate Issues -```bash -# Clear Caddy certificate cache -docker volume rm digiserver-v2_caddy-data -docker volume rm digiserver-v2_caddy-config - -# Restart Caddy -docker-compose restart caddy -``` - -### Port 80/443 Already in Use -```bash -# Find what's using the port -lsof -i :80 # For port 80 -lsof -i :443 # For port 443 - -# Stop the conflicting service or change ports in docker-compose.yml -``` - -### Can't Access via IP Address -```bash -# Verify Caddy is listening -docker-compose exec -T caddy netstat -tlnp 2>/dev/null | grep -E ':(80|443)' - -# Test from container -docker-compose exec -T caddy wget --no-check-certificate -qO- https://localhost/ -``` - -### Database Corruption -```bash -# Backup current database -docker-compose exec -T digiserver-app cp /app/instance/digiserver.db /app/instance/digiserver.db.backup - -# Reset database (CAUTION: This deletes all data) -docker-compose exec -T digiserver-app rm /app/instance/digiserver.db - -# Restart and re-run migrations -docker-compose restart digiserver-app -./setup_https.sh -``` - ---- - -## 📞 More Help - -See the detailed documentation files: -- **Quick Commands**: `DOCKER_EXEC_COMMANDS.md` -- **Full Guide**: `DEPLOYMENT_COMMANDS.md` -- **HTTPS Details**: `old_code_documentation/HTTPS_CONFIGURATION.md` - ---- - -## 🔄 Deployment on Different PC - -To deploy on a different PC: - -1. **Copy project files** to the new PC (or clone from git) -2. **Ensure Docker and Docker Compose are installed** -3. **Run deployment script**: - ```bash - cd /path/to/digiserver-v2 - ./deploy.sh - ``` -4. **Access the application** on the new PC at the configured URLs - -All settings will be automatically configured! 🎉 - ---- - -## 📋 Environment Variables - -You can customize deployment using environment variables: - -```bash -# Customize hostname -HOSTNAME=myserver ./deploy.sh - -# Customize domain -DOMAIN=myserver.example.com ./deploy.sh - -# Customize IP address -IP_ADDRESS=192.168.1.100 ./deploy.sh - -# Customize email -EMAIL=admin@myserver.com ./deploy.sh - -# Customize port -PORT=8443 ./deploy.sh - -# All together -HOSTNAME=server1 \ -DOMAIN=server1.internal \ -IP_ADDRESS=192.168.1.100 \ -EMAIL=admin@server1.com \ -PORT=443 \ -./deploy.sh -``` - ---- - -## ✨ Features - -✅ Automated HTTPS with self-signed certificates -✅ Multi-access (hostname, domain, IP address) -✅ Automatic reverse proxy with Caddy -✅ Docker containerized (easy deployment) -✅ Complete database schema with migrations -✅ Admin dashboard for configuration -✅ User management -✅ Player management -✅ Content/Playlist management -✅ Group management - ---- - -## 📝 Notes - -- Default SSL certificates are **self-signed** (internal use) -- For production with Let's Encrypt, edit the Caddyfile -- Keep database backups before major changes -- Default credentials are in the code; change them in production -- All logs available via `docker-compose logs` - ---- - -**Ready to deploy? Run:** `./deploy.sh` 🚀 - diff --git a/old_code_documentation/DOCKER.md b/old_code_documentation/DOCKER.md deleted file mode 100644 index 6a89155..0000000 --- a/old_code_documentation/DOCKER.md +++ /dev/null @@ -1,284 +0,0 @@ -# Docker Deployment Guide - -## Overview - -DigiServer v2 Docker image features: -- **Base image size**: ~400MB (optimized) -- **Full HD media support**: Images, videos, PDFs -- **Optional LibreOffice**: Install on-demand for PPTX support (+500MB) -- **Auto-initialization**: Database and admin user created on first run -- **Non-root user**: Runs as `appuser` (UID 1000) for security - -## Quick Start - -### 1. Build and Run with Docker Compose - -```bash -# Build the Docker image -docker-compose build - -# Start the container -docker-compose up -d - -# View logs -docker-compose logs -f - -# Stop the container -docker-compose down -``` - -The application will be available at `http://localhost:5000` - -Default credentials: -- Username: `admin` -- Password: `admin123` - -### 2. Build Docker Image Only - -```bash -# Build the image -docker build -t digiserver-v2:latest . - -# Run the container -docker run -d \ - -p 5000:5000 \ - -v $(pwd)/instance:/app/instance \ - -v $(pwd)/app/static/uploads:/app/app/static/uploads \ - --name digiserver \ - digiserver-v2:latest -``` - -## Configuration - -### Environment Variables - -Create a `.env` file based on `.env.example`: - -```bash -cp .env.example .env -``` - -Edit the `.env` file to set your configuration: -- `SECRET_KEY`: Change to a random secret key -- `FLASK_ENV`: Set to `production` for production deployments - -### Persistent Data - -The following directories are mounted as volumes: -- `./instance`: Database storage -- `./app/static/uploads`: Uploaded media files - -These persist even when containers are recreated. - -## Production Deployment - -### 1. Using Docker Compose (Recommended) - -```bash -# Create .env file with production settings -cp .env.example .env -nano .env # Edit with your settings - -# Start in production mode -docker-compose up -d -``` - -### 2. Behind a Reverse Proxy (Nginx/Traefik) - -Example Nginx configuration: - -```nginx -server { - listen 80; - server_name yourdomain.com; - - location / { - proxy_pass http://localhost:5000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # For large file uploads - client_max_body_size 100M; - } -} -``` - -### 3. Enable Redis Caching (Optional) - -Uncomment the Redis service in `docker-compose.yml`: - -```yaml - redis: - image: redis:7-alpine - container_name: digiserver-redis - restart: unless-stopped - volumes: - - redis-data:/data - -volumes: - redis-data: -``` - -Update `.env`: -``` -REDIS_URL=redis://redis:6379/0 -``` - -## Backup - -### Database Backup - -```bash -# Backup database -docker exec digiserver tar -czf /tmp/backup.tar.gz /app/instance -docker cp digiserver:/tmp/backup.tar.gz ./backup-$(date +%Y%m%d).tar.gz -``` - -### Full Backup (Database + Uploads) - -```bash -# Backup everything -tar -czf digiserver-backup-$(date +%Y%m%d).tar.gz instance/ app/static/uploads/ -``` - -## Maintenance - -### View Logs - -```bash -# All logs -docker-compose logs -f - -# Last 100 lines -docker-compose logs --tail=100 - -# Specific service -docker-compose logs -f digiserver -``` - -### Update Application - -```bash -# Pull latest code -git pull - -# Rebuild and restart -docker-compose down -docker-compose build -docker-compose up -d -``` - -### Shell Access - -```bash -# Access container shell -docker-compose exec digiserver bash - -# Or with docker directly -docker exec -it digiserver bash -``` - -### Installing Optional Dependencies - -**LibreOffice for PowerPoint Support:** - -```bash -# Method 1: Via Web UI (Recommended) -# Navigate to Admin Panel → System Dependencies -# Click "Install LibreOffice" button - -# Method 2: Via Docker exec -docker exec -it digiserver bash -sudo /app/install_libreoffice.sh -exit - -# Verify installation -docker exec digiserver libreoffice --version -``` - -## Troubleshooting - -### Port Already in Use - -Change the port mapping in `docker-compose.yml`: -```yaml -ports: - - "8080:5000" # Change 8080 to your desired port -``` - -### Permission Issues - -Ensure the volumes have correct permissions: -```bash -sudo chown -R 1000:1000 instance/ app/static/uploads/ -``` - -### Container Won't Start - -Check logs: -```bash -docker-compose logs digiserver -``` - -### Reset Database - -```bash -# Stop containers -docker-compose down - -# Remove database -rm instance/*.db - -# Start fresh -docker-compose up -d -``` - -## System Requirements - -### Base Image -- Docker 20.10+ -- Docker Compose 2.0+ -- 1GB RAM minimum (2GB recommended) -- 5GB disk space (base + uploads) - -### With LibreOffice (Optional) -- 2GB RAM recommended -- 10GB disk space (includes LibreOffice + media) - -## Security Recommendations - -1. **Change default credentials** immediately after first login -2. **Set a strong SECRET_KEY** in `.env` -3. **Use HTTPS** with a reverse proxy in production -4. **Regular backups** of database and uploads -5. **Update regularly** to get security patches -6. **Restrict network access** using firewall rules -7. **Monitor logs** for suspicious activity - -## Performance Tuning - -### Adjust Workers - -Edit `Dockerfile` CMD line: -```dockerfile -CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "8", "--timeout", "120", "app.app:create_app()"] -``` - -### Resource Limits - -Add to `docker-compose.yml`: -```yaml -services: - digiserver: - # ... existing config ... - deploy: - resources: - limits: - cpus: '2' - memory: 2G - reservations: - cpus: '1' - memory: 1G -``` diff --git a/old_code_documentation/DOCKER_EXEC_COMMANDS.md b/old_code_documentation/DOCKER_EXEC_COMMANDS.md deleted file mode 100644 index ae96f9b..0000000 --- a/old_code_documentation/DOCKER_EXEC_COMMANDS.md +++ /dev/null @@ -1,353 +0,0 @@ -# DigiServer Docker Exec Commands - Quick Reference - -Quick reference guide for common `docker exec` commands used in DigiServer deployment and maintenance. - -## 🚀 Quick Start - -### Complete Automated Deployment -```bash -./deploy.sh -``` - -### Manual Step-by-Step Setup -```bash -./setup_https.sh -``` - ---- - -## 📊 Database Migrations - -Run migrations in this order: - -```bash -# 1. HTTPS Configuration table -docker-compose exec -T digiserver-app python /app/migrations/add_https_config_table.py - -# 2. Player User table -docker-compose exec -T digiserver-app python /app/migrations/add_player_user_table.py - -# 3. Email column for HTTPS config -docker-compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py - -# 4. Player User global migration -docker-compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py -``` - ---- - -## 🔒 HTTPS Configuration Management - -### Check HTTPS Status -```bash -docker-compose exec -T digiserver-app python /app/https_manager.py status -``` - -### Show Detailed Configuration -```bash -docker-compose exec -T digiserver-app python /app/https_manager.py show -``` - -### Enable HTTPS (Production Settings) -```bash -docker-compose exec -T digiserver-app python /app/https_manager.py enable \ - digiserver \ - digiserver.sibiusb.harting.intra \ - admin@example.com \ - 10.76.152.164 \ - 443 -``` - -### Disable HTTPS -```bash -docker-compose exec -T digiserver-app python /app/https_manager.py disable -``` - ---- - -## 👤 User Management - -### Create Admin User -```bash -docker-compose exec -T digiserver-app python -c " -from app.app import create_app -from app.models.user import User -from app.extensions import db - -app = create_app() -with app.app_context(): - admin = User.query.filter_by(username='admin').first() - if not admin: - admin = User(username='admin', email='admin@example.com') - admin.set_password('admin123') - admin.is_admin = True - db.session.add(admin) - db.session.commit() - print('✅ Admin user created') - else: - print('✅ Admin user already exists') -" -``` - -### Reset Admin Password -```bash -docker-compose exec -T digiserver-app python -c " -from app.app import create_app -from app.models.user import User -from app.extensions import db - -app = create_app() -with app.app_context(): - admin = User.query.filter_by(username='admin').first() - if admin: - admin.set_password('newpassword123') - db.session.commit() - print('✅ Admin password reset successfully') - else: - print('❌ Admin user not found') -" -``` - ---- - -## 🔍 Database Inspection - -### List All Tables -```bash -docker-compose exec -T digiserver-app python -c " -from app.app import create_app -from sqlalchemy import inspect - -app = create_app() -with app.app_context(): - inspector = inspect(app.extensions.db.engine) - tables = inspector.get_table_names() - for table in sorted(tables): - print(f' ✓ {table}') - print(f'Total: {len(tables)} tables') -" -``` - -### Check HTTPS Configuration Record -```bash -docker-compose exec -T digiserver-app python -c " -from app.app import create_app -from app.models.https_config import HTTPSConfig - -app = create_app() -with app.app_context(): - config = HTTPSConfig.get_config() - if config: - print('HTTPS Configuration:') - print(f' Status: {\"ENABLED\" if config.https_enabled else \"DISABLED\"}') - print(f' Hostname: {config.hostname}') - print(f' Domain: {config.domain}') - print(f' IP: {config.ip_address}') - print(f' Port: {config.port}') - print(f' Updated: {config.updated_at}') - print(f' Updated by: {config.updated_by}') - else: - print('No configuration found') -" -``` - -### Count Users -```bash -docker-compose exec -T digiserver-app python -c " -from app.app import create_app -from app.models.user import User - -app = create_app() -with app.app_context(): - count = User.query.count() - print(f'Total users: {count}') - admins = User.query.filter_by(is_admin=True).count() - print(f'Admin users: {admins}') -" -``` - ---- - -## 🧪 Health Checks - -### Check Flask Application -```bash -docker-compose exec -T digiserver-app python -c " -import urllib.request -try: - response = urllib.request.urlopen('http://localhost:5000/', timeout=5) - print(f'✅ Application responding (HTTP {response.status})') -except Exception as e: - print(f'❌ Application error: {e}') -" -``` - -### Validate Caddy Configuration -```bash -docker-compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile -``` - -### Test HTTPS from Container -```bash -docker-compose exec -T caddy wget --no-check-certificate -qO- https://localhost/ | head -10 -``` - ---- - -## 🛠️ Maintenance Commands - -### View Caddy Configuration -```bash -docker-compose exec -T caddy cat /etc/caddy/Caddyfile -``` - -### Reload Caddy Configuration -```bash -docker-compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile -``` - -### View Application Logs (Last 50 lines) -```bash -docker-compose logs --tail=50 digiserver-app -``` - -### View Caddy Logs (Last 50 lines) -```bash -docker-compose logs --tail=50 caddy -``` - -### Clear All Logs -```bash -docker-compose logs --clear -``` - ---- - -## 🔄 Container Management - -### Restart All Containers -```bash -docker-compose restart -``` - -### Restart Specific Container -```bash -# Restart application -docker-compose restart digiserver-app - -# Restart Caddy -docker-compose restart caddy -``` - -### Stop All Containers -```bash -docker-compose stop -``` - -### Start All Containers -```bash -docker-compose start -``` - -### Remove Everything (Clean slate) -```bash -docker-compose down -``` - -### Remove Everything Including Volumes (Full cleanup) -```bash -docker-compose down -v -``` - ---- - -## 📦 Backup and Recovery - -### Backup Database -```bash -docker-compose exec -T digiserver-app python -c " -from app.app import create_app -import shutil -from datetime import datetime - -app = create_app() -timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') -backup_name = f'digiserver_{timestamp}.db' - -with app.app_context(): - # Get database path - db_path = app.instance_path + '/digiserver.db' - shutil.copy(db_path, f'/app/backups/{backup_name}') - print(f'✅ Backup created: {backup_name}') -" -``` - -### List Database Backups -```bash -docker-compose exec -T digiserver-app ls -lah /app/backups/ -``` - ---- - -## 🚨 Troubleshooting - -### Common Issues - -**Containers won't start:** -```bash -# Check logs -docker-compose logs - -# Try rebuild -docker-compose up -d --build -``` - -**Migration fails:** -```bash -# Check database connection -docker-compose exec -T digiserver-app python -c " -from app.app import create_app -app = create_app() -print('✅ Database connection OK') -" -``` - -**Certificate issues:** -```bash -# Clear Caddy cache -docker volume rm digiserver-v2_caddy-data -docker volume rm digiserver-v2_caddy-config - -# Restart Caddy -docker-compose restart caddy -``` - -**Port conflicts:** -```bash -# Find what's using port 443 -lsof -i :443 - -# Find what's using port 80 -lsof -i :80 -``` - ---- - -## 📝 Tips and Notes - -- **`-T` flag**: Prevents Docker from allocating a pseudo-terminal (use in scripts) -- **No `-T` flag**: Allocates a terminal (use for interactive commands) -- **Container name**: `digiserver-app` (Flask application) -- **Container name**: `digiserver-caddy` (Reverse proxy) -- **Network**: `digiserver-v2_digiserver-network` -- **Database**: SQLite at `/app/instance/digiserver.db` - ---- - -## 🔗 Related Documentation - -- [DEPLOYMENT_COMMANDS.md](DEPLOYMENT_COMMANDS.md) - Complete deployment guide -- [setup_https.sh](setup_https.sh) - Semi-automated setup script -- [deploy.sh](deploy.sh) - Fully automated deployment script -- [HTTPS_CONFIGURATION.md](old_code_documentation/HTTPS_CONFIGURATION.md) - HTTPS details - diff --git a/old_code_documentation/EDIT_MEDIA_TROUBLESHOOTING.md b/old_code_documentation/EDIT_MEDIA_TROUBLESHOOTING.md deleted file mode 100644 index 8b7613a..0000000 --- a/old_code_documentation/EDIT_MEDIA_TROUBLESHOOTING.md +++ /dev/null @@ -1,144 +0,0 @@ -# Edit Media API Troubleshooting Guide - -## Issue -Players are trying to send edited images to the server via the edit image API, but nothing is happening on the server. - -## Diagnosis Performed - -### 1. **API Endpoint Status** ✅ -- **Endpoint**: `POST /api/player-edit-media` -- **Status**: Exists and properly implemented -- **Location**: `app/blueprints/api.py` (lines 711-851) -- **Authentication**: Requires Bearer token with valid player auth code - -### 2. **Bug Found and Fixed** 🐛 -Found undefined variable bug in the `receive_edited_media()` function: -- **Issue**: `playlist` variable was only defined inside an `if` block -- **Problem**: When a player has no assigned playlist, the variable remained undefined -- **Error**: Would cause `UnboundLocalError` when trying to return the response -- **Fix**: Initialize `playlist = None` before the conditional block -- **Commit**: `8a89df3` - -### 3. **Server Logs Check** ✅ -- No `player-edit-media` requests found in recent logs -- **Conclusion**: Requests are not reaching the server, indicating a client-side issue - -## Possible Root Causes - -### A. **Player App Not Sending Requests** -The player application might not be calling the edit media endpoint. Check: -- Is the "edit on player" feature enabled for the content? -- Does the player app have code to capture edited images? -- Are there errors in the player app logs? - -### B. **Wrong Endpoint URL** -If the player app is hardcoded with an incorrect URL, requests won't reach the server. -- **Expected URL**: `{server_url}/api/player-edit-media` -- **Required Header**: `Authorization: Bearer {player_auth_code}` - -### C. **Network Issues** -- Firewall blocking requests -- Network connectivity issues between player and server -- SSL/HTTPS certificate validation failures - -### D. **Request Format Issues** -The endpoint expects: -``` -Content-Type: multipart/form-data -- image_file: The edited image file (binary) -- metadata: JSON string with this structure: - { - "time_of_modification": "2026-01-17T19:50:00Z", - "original_name": "image.jpg", - "new_name": "image_v1.jpg", - "version": 1, - "user_card_data": "optional_user_code" - } -``` - -### E. **Authentication Issues** -- Player's auth code might be invalid -- Bearer token not being sent correctly -- Auth code might have changed - -## Testing Steps - -### 1. **Verify Endpoint is Accessible** -```bash -curl -X POST http://localhost:5000/api/player-edit-media \ - -H "Authorization: Bearer " \ - -F "image_file=@test.jpg" \ - -F "metadata={\"time_of_modification\":\"2026-01-17T20:00:00Z\",\"original_name\":\"4k1.jpg\",\"new_name\":\"4k1_v1.jpg\",\"version\":1}" -``` - -### 2. **Check Player Logs** -Look for errors in the player application logs when attempting to send edits - -### 3. **Monitor Server Logs** -Enable debug logging and watch for: -```bash -docker compose logs digiserver-app -f | grep -i "edit\|player-edit" -``` - -### 4. **Verify Player Has Valid Auth Code** -```bash -curl -X POST http://localhost:5000/api/auth/verify \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" -``` - -## Server API Response - -### Success Response (200 OK) -```json -{ - "success": true, - "message": "Edited media received and processed", - "edit_id": 123, - "version": 1, - "old_filename": "image.jpg", - "new_filename": "image_v1.jpg", - "new_playlist_version": 34 -} -``` - -### Error Responses -- **401**: Missing or invalid authorization header -- **403**: Invalid authentication code -- **400**: Missing required fields (image_file, metadata, etc.) -- **404**: Original content file not found in system -- **500**: Internal server error (check logs) - -## Expected Server Behavior - -When an edit is successfully received: -1. ✅ File is saved to `/static/uploads/edited_media//` -2. ✅ Metadata JSON is saved alongside the file -3. ✅ PlayerEdit record is created in database -4. ✅ PlayerUser record is auto-created if user_card_data provided -5. ✅ Playlist version is incremented (if player has assigned playlist) -6. ✅ Playlist cache is cleared -7. ✅ Action is logged in server_log table - -## Database Records - -After successful upload, check: -```sql --- Player edit records -SELECT * FROM player_edit WHERE player_id = ? ORDER BY created_at DESC; - --- Verify file exists -ls -la app/static/uploads/edited_media/ - --- Check server logs -SELECT * FROM server_log WHERE action LIKE '%edited%' ORDER BY created_at DESC; -``` - -## Next Steps - -1. Check if player app is configured with correct server URL -2. Verify player has "edit on player" enabled for the content -3. Check player app logs for any error messages -4. Test endpoint connectivity using curl/Postman -5. Monitor server logs while player attempts to send an edit -6. Verify player's auth code is valid and unchanged diff --git a/old_code_documentation/GROUPS_ANALYSIS.md b/old_code_documentation/GROUPS_ANALYSIS.md deleted file mode 100644 index 33618cf..0000000 --- a/old_code_documentation/GROUPS_ANALYSIS.md +++ /dev/null @@ -1,96 +0,0 @@ -# Groups Feature - Archived - -**Status: ARCHIVED AND REMOVED ✅** - -**Archive Date:** January 17, 2026 - -## What Was Done - -### 1. **Files Archived** -- `/app/templates/groups/` → `/old_code_documentation/templates_groups/` -- `/app/blueprints/groups.py` → `/old_code_documentation/blueprint_groups.py` - -### 2. **Code Removed** -- Removed groups blueprint import from `app/app.py` -- Removed groups blueprint registration from `register_blueprints()` function -- Removed Group import from `app/blueprints/admin.py` (unused) -- Removed Group import from `app/blueprints/api.py` (unused) -- Commented out `/api/groups` endpoint in API - -### 3. **What Remained in Code** -- **NOT removed:** Group model in `app/models/group.py` - - Kept for database backward compatibility - - No imports or references to it now - - Database table is orphaned but safe to keep - -- **NOT removed:** `app/utils/group_player_management.py` - - Contains utility functions that may be referenced - - Can be archived later if confirmed unused - -## Summary - -✅ Groups feature is now completely **unavailable in the UI and app logic** -✅ No routes, blueprints, or navigation links to groups -✅ Application loads cleanly without groups -✅ Database tables preserved for backward compatibility - -## Why Groups Was Removed - -1. **Functionality replaced by Playlists** - - Groups: "Organize content into categories" - - Playlists: "Organize content into collections assigned to players" - -2. **Never used in the current workflow** - - Dashboard: Players → Playlists → Content - - No mention of groups in any UI navigation - - Players have NO relationship to groups - -3. **Redundant architecture** - - Playlists provide superior functionality - - Players directly assign to playlists - - No need for intermediate grouping layer - -## Original Purpose (Deprecated) - -- Groups were designed to organize content -- Could contain multiple content items -- Players could be assigned to groups -- **BUT:** Player model never implemented group relationship -- **Result:** Feature was incomplete and unused - -## Current Workflow (Active) ✅ - -``` -1. Create Playlist (organize content) -2. Upload Media (add files) -3. Add Content to Playlist (manage items) -4. Add Player (register device) -5. Assign Playlist to Player (connect directly) -6. Players auto-download and display -``` - -## If Groups Data Exists - -The `group` and `group_content` database tables still exist but are orphaned: -- No code references them -- No migrations to drop them -- Safe to keep or drop as needed - -## Future Cleanup - -When ready, can be removed completely: -- `app/models/group.py` - Drop Group model -- Database migrations to drop `group` and `group_content` tables -- Remove utility functions from `app/utils/group_player_management.py` -- Clean up any remaining imports - -## References - -- **Archive date:** January 17, 2026 -- **Related:** See `LEGACY_PLAYLIST_ROUTES.md` for similar cleanup -- **Similar action:** Playlist templates also archived as legacy - ---- - -**Status:** ✅ Complete - Groups feature successfully archived and removed from active codebase - diff --git a/old_code_documentation/HTTPS_CONFIGURATION.md b/old_code_documentation/HTTPS_CONFIGURATION.md deleted file mode 100644 index d730e56..0000000 --- a/old_code_documentation/HTTPS_CONFIGURATION.md +++ /dev/null @@ -1,192 +0,0 @@ -# HTTPS Configuration Management System - -## Overview - -The DigiServer v2 now includes a built-in HTTPS configuration management system accessible through the Admin Panel. This allows administrators to enable and manage HTTPS/SSL settings directly from the web interface without needing to manually edit configuration files. - -## Features - -- **Enable/Disable HTTPS**: Toggle HTTPS on and off from the admin panel -- **Domain Management**: Set the full domain name (e.g., `digiserver.sibiusb.harting.intra`) -- **Hostname Configuration**: Configure server hostname (e.g., `digiserver`) -- **IP Address Management**: Set the IP address for direct access (e.g., `10.76.152.164`) -- **Port Configuration**: Customize HTTPS port (default: 443) -- **Status Tracking**: View current HTTPS status and configuration details -- **Real-time Preview**: See access points as you configure settings - -## Workflow - -### Step 1: Initial Setup (HTTP Only) -1. Start the application normally: `docker-compose up -d` -2. The app runs on HTTP port 80 -3. Access via: `http://` - -### Step 2: Enable HTTPS via Admin Panel -1. Log in to the admin panel as an administrator -2. Navigate to: **Admin Panel → 🔒 HTTPS Configuration** -3. Toggle the "Enable HTTPS" switch -4. Fill in the required fields: - - **Hostname**: Short name for your server (e.g., `digiserver`) - - **Full Domain Name**: Complete domain (e.g., `digiserver.sibiusb.harting.intra`) - - **IP Address**: Server IP address (e.g., `10.76.152.164`) - - **HTTPS Port**: Port number (default: 443) - -### Step 3: Verify Configuration -1. The status section shows your HTTPS configuration -2. Access points are displayed: - - HTTPS: `https://digiserver.sibiusb.harting.intra` - - HTTP fallback: `http://10.76.152.164` - -## Configuration Details - -### Database Model (HTTPSConfig) - -The configuration is stored in the `https_config` table with the following fields: - -```python -- id: Primary key -- https_enabled: Boolean flag for HTTPS status -- hostname: Server hostname -- domain: Full domain name -- ip_address: IPv4 or IPv6 address -- port: HTTPS port (default: 443) -- created_at: Creation timestamp -- updated_at: Last modification timestamp -- updated_by: Username of admin who made the change -``` - -### Admin Routes - -- **GET /admin/https-config**: View HTTPS configuration page -- **POST /admin/https-config/update**: Update HTTPS settings -- **GET /admin/https-config/status**: Get current status as JSON - -## Integration with Docker & Caddy - -The HTTPS configuration works in conjunction with: - -1. **Caddy Reverse Proxy**: Automatically handles SSL/TLS -2. **Let's Encrypt**: Provides free SSL certificates -3. **docker-compose.yml**: Uses the configured domain for Caddy - -### Current Setup - -**docker-compose.yml** uses `digiserver.sibiusb.harting.intra` as the primary domain. - -**Caddyfile** configurations: -- HTTPS: `digiserver.sibiusb.harting.intra` (auto-managed SSL) -- HTTP Fallback: `10.76.152.164` (direct IP access) - -## Prerequisites - -Before enabling HTTPS, ensure: - -1. **DNS Resolution**: Domain must resolve to the server's IP - ```bash - # Test DNS resolution - nslookup digiserver.sibiusb.harting.intra - ``` - -2. **Ports Accessible**: - - Port 80 (HTTP): For Let's Encrypt challenges - - Port 443 (HTTPS): For secure traffic - - Port 443/UDP: For HTTP/3 support - -3. **Firewall Rules**: Ensure inbound traffic is allowed on ports 80 and 443 - -4. **Hosts File** (if DNS not available): - ``` - 10.76.152.164 digiserver.sibiusb.harting.intra - ``` - -## Database Migration - -To set up the HTTPS configuration table, run: - -```bash -# From inside the Docker container -python /app/migrations/add_https_config_table.py - -# Or from the host machine -docker-compose exec digiserver-app python /app/migrations/add_https_config_table.py -``` - -## Access Points After Configuration - -### HTTPS (Recommended) -- URL: `https://digiserver.sibiusb.harting.intra` -- Protocol: HTTPS with SSL/TLS -- Automatic redirects from HTTP -- Let's Encrypt certificate (auto-renewed) - -### HTTP Fallback -- URL: `http://10.76.152.164` -- Protocol: Plain HTTP (no encryption) -- Used when domain is not accessible -- Automatically redirects to HTTPS - -## Security Features - -✅ Automatic SSL certificate management (Let's Encrypt) -✅ Automatic certificate renewal -✅ Security headers (HSTS, X-Frame-Options, etc.) -✅ HTTP/2 and HTTP/3 support -✅ Admin-only access to configuration - -## Logging - -All HTTPS configuration changes are logged in the server logs: - -``` -✓ HTTPS enabled by admin: domain=digiserver.sibiusb.harting.intra, hostname=digiserver, ip=10.76.152.164 -✓ HTTPS disabled by admin -``` - -Check admin panel → Logs for detailed audit trail. - -## Troubleshooting - -### HTTPS Not Working -1. Verify DNS resolution: `nslookup digiserver.sibiusb.harting.intra` -2. Check Caddy logs: `docker-compose logs caddy` -3. Ensure ports 80 and 443 are open -4. Check firewall rules - -### Certificate Issues -1. Check Caddy container logs -2. Verify domain is accessible from internet -3. Ensure Let's Encrypt can validate domain -4. Check email configuration for certificate notifications - -### Configuration Not Applied -1. Verify database migration ran: `python migrations/add_https_config_table.py` -2. Restart containers: `docker-compose restart` -3. Check admin panel for error messages -4. Review server logs - -## Example Configuration - -For a typical setup: - -``` -Hostname: digiserver -Domain: digiserver.sibiusb.harting.intra -IP Address: 10.76.152.164 -Port: 443 -HTTPS Status: Enabled ✅ -``` - -Access via: -- `https://digiserver.sibiusb.harting.intra` ← Primary -- `http://10.76.152.164` ← Fallback - -## Future Enhancements - -Potential improvements for future versions: - -- Certificate upload/management interface -- Domain validation checker -- Automatic DNS verification -- Custom SSL certificate support -- Certificate expiration notifications -- A/B testing for domain migration diff --git a/old_code_documentation/HTTPS_EMAIL_UPDATE.md b/old_code_documentation/HTTPS_EMAIL_UPDATE.md deleted file mode 100644 index 63508a5..0000000 --- a/old_code_documentation/HTTPS_EMAIL_UPDATE.md +++ /dev/null @@ -1,202 +0,0 @@ -# HTTPS Email Configuration - Update Guide - -## What's New - -The HTTPS configuration system now includes an **Email Address** field that is essential for: -- SSL certificate management (Let's Encrypt) -- Certificate expiration notifications -- Certificate renewal reminders - -## Changes Made - -### 1. **Database Model** (`app/models/https_config.py`) -- Added `email` field to HTTPSConfig model -- Updated `create_or_update()` method to accept email parameter -- Updated `to_dict()` method to include email in output - -### 2. **Admin Routes** (`app/blueprints/admin.py`) -- Added email form field handling -- Added email validation (checks for '@' symbol) -- Updated configuration save to store email -- Updated logging to include email in configuration changes - -### 3. **Admin Template** (`app/templates/admin/https_config.html`) -- Added email input field in configuration form -- Added email display in status section -- Added help text explaining email purpose -- Email marked as required when HTTPS is enabled - -### 4. **CLI Utility** (`https_manager.py`) -- Updated enable command to accept email parameter -- Updated help text to show email requirement -- Example: `python https_manager.py enable digiserver domain.local admin@example.com 10.76.152.164` - -### 5. **Database Migration** (`migrations/add_email_to_https_config.py`) -- New migration script to add email column to existing database - -## Update Instructions - -### Step 1: Run Database Migration -```bash -# Add email column to existing https_config table -python /app/migrations/add_email_to_https_config.py -``` - -### Step 2: Restart Application -```bash -docker-compose restart -``` - -### Step 3: Configure Email via Admin Panel -1. Navigate to: **Admin Panel → 🔒 HTTPS Configuration** -2. Fill in the new **Email Address** field -3. Example: `admin@example.com` -4. Click **Save HTTPS Configuration** - -## Configuration Form - New Field - -```html - - - -

Email address for SSL certificate notifications and Let's Encrypt communications

-``` - -## CLI Usage - New Syntax - -**Old (still works for HTTP):** -```bash -python https_manager.py enable digiserver domain.local 10.76.152.164 443 -``` - -**New (with email - recommended):** -```bash -python https_manager.py enable digiserver domain.local admin@example.com 10.76.152.164 443 -``` - -## Status Display - Updated - -The status card now shows: -``` -✅ HTTPS ENABLED -Domain: digiserver.sibiusb.harting.intra -Hostname: digiserver -Email: admin@example.com ← NEW -IP Address: 10.76.152.164 -Port: 443 -Access URL: https://digiserver.sibiusb.harting.intra -Last Updated: 2026-01-14 15:30:45 by admin -``` - -## Validation - -The system now validates: -- ✅ Email format (must contain '@') -- ✅ Email is required when HTTPS is enabled -- ✅ Email is stored in database -- ✅ Email is logged when configuration changes - -## Benefits - -📧 **Proper SSL Certificate Management** -- Let's Encrypt sends notifications to configured email -- Certificate expiration warnings before renewal - -📋 **Better Configuration** -- Email is persisted in database -- No need to set environment variables -- Fully managed through admin panel - -🔐 **Professional Setup** -- Real email address for certificate notifications -- Easier to manage multiple servers -- Complete audit trail with email address - -## Backwards Compatibility - -If you have an existing HTTPS configuration without an email: -1. The email field will be NULL -2. You'll see an error when trying to use HTTPS without email -3. Simply add the email through the admin panel and save -4. Configuration will be complete - -## Database Schema Update - -```sql -ALTER TABLE https_config ADD COLUMN email VARCHAR(255); -``` - -New schema: -``` -https_config table: -├── id (PK) -├── https_enabled (BOOLEAN) -├── hostname (VARCHAR) -├── domain (VARCHAR) -├── ip_address (VARCHAR) -├── email (VARCHAR) ← NEW -├── port (INTEGER) -├── created_at (DATETIME) -├── updated_at (DATETIME) -└── updated_by (VARCHAR) -``` - -## Example Configuration - -**Complete HTTPS Setup:** -``` -Hostname: digiserver -Domain: digiserver.sibiusb.harting.intra -Email: admin@example.com -IP: 10.76.152.164 -Port: 443 -Status: ✅ ENABLED -``` - -## Troubleshooting - -### Email Field Not Showing? -1. Clear browser cache (Ctrl+Shift+Del) -2. Reload the page -3. Check that containers restarted: `docker-compose restart` - -### Migration Error? -If migration fails: -```bash -# Option 1: Add column manually -docker-compose exec digiserver-app python -c " -from app.app import create_app -from app.extensions import db -from sqlalchemy import text -app = create_app() -with app.app_context(): - db.engine.execute(text('ALTER TABLE https_config ADD COLUMN email VARCHAR(255)')) -" - -# Option 2: Reset database (if testing) -rm instance/digiserver.db -python /app/migrations/add_https_config_table.py -``` - -### "Email Required" Error When HTTPS Enabled? -- Admin panel: Fill in the Email Address field before saving -- CLI: Include email in command: `python https_manager.py enable ... email@example.com ...` - -## Next Steps - -1. Run the database migration -2. Restart the application -3. Navigate to HTTPS Configuration -4. Enter a valid email address (e.g., `admin@example.com`) -5. Enable HTTPS -6. System will use this email for Let's Encrypt notifications - -## Support - -For issues or questions: -- Check `HTTPS_CONFIGURATION.md` for detailed documentation -- See `HTTPS_QUICK_REFERENCE.md` for quick examples -- Review server logs in admin panel for configuration changes diff --git a/old_code_documentation/HTTPS_IMPLEMENTATION_SUMMARY.md b/old_code_documentation/HTTPS_IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index c222d28..0000000 --- a/old_code_documentation/HTTPS_IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,316 +0,0 @@ -# HTTPS Management System - Implementation Summary - -## ✅ What Has Been Implemented - -A complete HTTPS configuration management system has been added to DigiServer v2, allowing administrators to manage HTTPS settings through the web interface. - -### Files Created - -#### 1. **Database Model** (`app/models/https_config.py`) -- New `HTTPSConfig` model for storing HTTPS configuration -- Fields: hostname, domain, ip_address, port, enabled status, audit trail -- Methods: `get_config()`, `create_or_update()`, `to_dict()` - -#### 2. **Admin Routes** (updated `app/blueprints/admin.py`) -- `GET /admin/https-config` - Display configuration page -- `POST /admin/https-config/update` - Update settings -- `GET /admin/https-config/status` - Get status as JSON -- Full validation and error handling -- Admin-only access with permission checks - -#### 3. **Admin Template** (`app/templates/admin/https_config.html`) -- Beautiful, user-friendly configuration interface -- Status display showing current HTTPS settings -- Form with toggle switch for enable/disable -- Input fields for: hostname, domain, IP address, port -- Real-time preview of access points -- Comprehensive help text and information sections -- Responsive design for mobile compatibility - -#### 4. **Database Migration** (`migrations/add_https_config_table.py`) -- Creates `https_config` table with all necessary fields -- Indexes on important columns -- Timestamps for audit trail - -#### 5. **Admin Dashboard Link** (updated `app/templates/admin/admin.html`) -- Added new card in admin dashboard linking to HTTPS configuration -- Purple gradient card with lock icon (🔒) -- Easy access from main admin panel - -#### 6. **CLI Utility** (`https_manager.py`) -- Command-line interface for managing HTTPS configuration -- Commands: `status`, `enable`, `disable`, `show` -- Useful for automation and scripting - -#### 7. **Setup Script** (`setup_https.sh`) -- Automated setup script for database migration -- Step-by-step instructions for configuration - -#### 8. **Documentation** (`HTTPS_CONFIGURATION.md`) -- Comprehensive guide covering: - - Feature overview - - Step-by-step workflow - - Configuration details - - Prerequisites - - Integration details - - Troubleshooting - - Examples - -### Files Updated - -#### 1. **Models Package** (`app/models/__init__.py`) -- Added import for `HTTPSConfig` -- Exported in `__all__` list - -#### 2. **Admin Blueprint** (`app/blueprints/admin.py`) -- Imported `HTTPSConfig` model -- Added HTTPS management routes - -#### 3. **Admin Dashboard** (`app/templates/admin/admin.html`) -- Added link to HTTPS configuration - -#### 4. **Caddyfile** -- Already preconfigured with domain: `digiserver.sibiusb.harting.intra` -- IP fallback: `10.76.152.164` -- Ready to use with the new configuration system - ---- - -## 🚀 Quick Start Guide - -### Step 1: Database Setup -```bash -# Run the migration to create the https_config table -python /app/migrations/add_https_config_table.py - -# Or automatically with the setup script -bash setup_https.sh -``` - -### Step 2: Start the Application (HTTP Only) -```bash -docker-compose up -d -``` - -### Step 3: Configure HTTPS via Admin Panel -1. Log in as admin -2. Go to: **Admin Panel → 🔒 HTTPS Configuration** -3. Toggle "Enable HTTPS" -4. Fill in: - - Hostname: `digiserver` - - Domain: `digiserver.sibiusb.harting.intra` - - IP Address: `10.76.152.164` - - Port: `443` (default) -5. Click "Save HTTPS Configuration" - -### Step 4: Verify Access -- HTTPS: `https://digiserver.sibiusb.harting.intra` -- HTTP Fallback: `http://10.76.152.164` - ---- - -## 📋 Workflow Explanation - -### Initial State (HTTP Only) -``` -┌─────────────────┐ -│ App Running on │ -│ Port 80 (HTTP) │ -└────────┬────────┘ - │ - └─ Accessible at: http://10.76.152.164 -``` - -### After Configuration (HTTP + HTTPS) -``` -┌──────────────────────────────────────┐ -│ Admin Configures HTTPS Settings: │ -│ • Hostname: digiserver │ -│ • Domain: digiserver...intra │ -│ • IP: 10.76.152.164 │ -│ • Port: 443 │ -└──────────────┬───────────────────────┘ - │ - ┌───────┴────────┐ - │ │ - ┌────▼────┐ ┌─────▼──────┐ - │ HTTPS │ │ HTTP │ - │ Port443 │ │ Port 80 │ - └────┬────┘ └─────┬──────┘ - │ │ - └──────────────┘ - Both available -``` - ---- - -## 🔐 Security Features - -✅ **Admin-Only Access** -- Only administrators can access HTTPS configuration -- All changes logged with admin username and timestamp - -✅ **Input Validation** -- Domain format validation -- IP address format validation (IPv4/IPv6) -- Port range validation (1-65535) - -✅ **SSL/TLS Management** -- Automatic Let's Encrypt integration (via Caddy) -- Automatic certificate renewal -- Security headers (HSTS, X-Frame-Options, etc.) - -✅ **Audit Trail** -- All configuration changes logged -- Admin dashboard logs show who changed what and when -- Server logs track HTTPS enable/disable events - ---- - -## 🛠️ CLI Management - -Configure HTTPS from command line: - -```bash -# Show current status -python https_manager.py status - -# Enable HTTPS -python https_manager.py enable digiserver digiserver.sibiusb.harting.intra 10.76.152.164 443 - -# Disable HTTPS -python https_manager.py disable - -# Show detailed configuration -python https_manager.py show -``` - ---- - -## 📊 Database Schema - -**https_config table:** -``` -┌──────────────────┬────────────────────┬──────────────┐ -│ Column │ Type │ Description │ -├──────────────────┼────────────────────┼──────────────┤ -│ id │ Integer (PK) │ Primary key │ -│ https_enabled │ Boolean │ Enable flag │ -│ hostname │ String(255) │ Server name │ -│ domain │ String(255) │ Domain name │ -│ ip_address │ String(45) │ IP address │ -│ port │ Integer │ HTTPS port │ -│ created_at │ DateTime │ Created time │ -│ updated_at │ DateTime │ Updated time │ -│ updated_by │ String(255) │ Admin user │ -└──────────────────┴────────────────────┴──────────────┘ -``` - ---- - -## 🧪 Testing - -### Test HTTPS Configuration UI -1. Log in as admin -2. Go to Admin Panel → HTTPS Configuration -3. Test Enable/Disable toggle -4. Test form validation with invalid inputs -5. Verify real-time preview updates - -### Test Access Points -```bash -# Test HTTPS -curl -k https://digiserver.sibiusb.harting.intra - -# Test HTTP Fallback -curl http://10.76.152.164 - -# Test status endpoint -curl http:///admin/https-config/status -``` - ---- - -## 📝 Configuration Examples - -### Default Configuration -```python -hostname = "digiserver" -domain = "digiserver.sibiusb.harting.intra" -ip_address = "10.76.152.164" -port = 443 -https_enabled = True -``` - -### Configuration for Different Network -```python -hostname = "myserver" -domain = "myserver.company.local" -ip_address = "192.168.1.100" -port = 8443 -https_enabled = True -``` - ---- - -## 🔄 Integration with Existing System - -The HTTPS configuration system integrates seamlessly with: - -1. **Caddy Reverse Proxy** - Uses configured domain for SSL termination -2. **Let's Encrypt** - Automatic certificate provisioning and renewal -3. **Flask Application** - No code changes needed, works with existing auth -4. **Database** - Stores configuration persistently -5. **Logging System** - All changes logged and auditable - ---- - -## 🎯 Key Benefits - -✨ **No Manual Configuration** - All settings through web UI -✨ **Easy to Use** - Intuitive interface with real-time preview -✨ **Audit Trail** - Track all HTTPS configuration changes -✨ **Flexible** - Support for multiple access points (HTTPS + HTTP) -✨ **Secure** - Admin-only access with validation -✨ **Automated** - Automatic SSL certificate management -✨ **CLI Support** - Programmatic configuration via command line - ---- - -## 📚 Next Steps - -1. ✅ **Run Database Migration** - ```bash - python /app/migrations/add_https_config_table.py - ``` - -2. ✅ **Start Application** - ```bash - docker-compose up -d - ``` - -3. ✅ **Configure via Admin Panel** - - Navigate to Admin → HTTPS Configuration - - Enable HTTPS with your settings - -4. ✅ **Verify Configuration** - - Check status displays correctly - - Test access points work - - Review logs for changes - ---- - -## 📞 Support & Troubleshooting - -See `HTTPS_CONFIGURATION.md` for: -- Detailed troubleshooting guide -- DNS configuration instructions -- Firewall requirements -- Let's Encrypt certificate issues -- Error messages and solutions - ---- - -## 🎉 Implementation Complete! - -The HTTPS configuration management system is ready to use. All components are in place and documented. Simply run the database migration and start using the feature through the admin panel! diff --git a/old_code_documentation/HTTPS_QUICK_REFERENCE.md b/old_code_documentation/HTTPS_QUICK_REFERENCE.md deleted file mode 100644 index e269301..0000000 --- a/old_code_documentation/HTTPS_QUICK_REFERENCE.md +++ /dev/null @@ -1,259 +0,0 @@ -# HTTPS Configuration - Quick Reference Guide - -## 🎯 Quick Access - -**Admin Panel Location:** Main Dashboard → 🔒 **HTTPS Configuration** (Purple card) - ---- - -## ⚡ Quick Setup (5 Minutes) - -### 1. Initial State -Your app is running on HTTP. Access: `http://10.76.152.164` - -### 2. Navigate to HTTPS Config -- Admin Panel → 🔒 HTTPS Configuration - -### 3. Configure (Fill In) -| Field | Value | Example | -|-------|-------|---------| -| Hostname | Server short name | `digiserver` | -| Domain | Full domain name | `digiserver.sibiusb.harting.intra` | -| IP Address | Server IP | `10.76.152.164` | -| Port | HTTPS port (default 443) | `443` | - -### 4. Enable HTTPS -- Toggle: **Enable HTTPS** ✅ -- Click: **💾 Save HTTPS Configuration** - -### 5. Verify -- ✅ Configuration shows as "ENABLED" -- ✅ Access via: `https://digiserver.sibiusb.harting.intra` -- ✅ Check status card for current settings - ---- - -## 🔍 Status Display - -### Enabled State ✅ -``` -✅ HTTPS ENABLED -Domain: digiserver.sibiusb.harting.intra -Hostname: digiserver -IP Address: 10.76.152.164 -Port: 443 -Access URL: https://digiserver.sibiusb.harting.intra -Last Updated: 2024-01-14 15:30:45 by admin -``` - -### Disabled State ⚠️ -``` -⚠️ HTTPS DISABLED -The application is currently running on HTTP only (port 80) -Enable HTTPS below to secure your application. -``` - ---- - -## 🔐 Access Points - -### After HTTPS is Enabled - -| Access Type | URL | Use Case | -|------------|-----|----------| -| **Primary (HTTPS)** | `https://digiserver.sibiusb.harting.intra` | Daily use, secure | -| **Fallback (HTTP)** | `http://10.76.152.164` | Troubleshooting, direct IP access | - ---- - -## ✅ Prerequisites Checklist - -Before enabling HTTPS: - -- [ ] DNS resolves domain to IP: `nslookup digiserver.sibiusb.harting.intra` -- [ ] Firewall allows port 80 (HTTP) -- [ ] Firewall allows port 443 (HTTPS) -- [ ] Server IP is `10.76.152.164` -- [ ] Domain is `digiserver.sibiusb.harting.intra` - ---- - -## 🐛 Troubleshooting - -### HTTPS Not Working? - -1. **Check Status** - - Admin → HTTPS Configuration - - Verify "HTTPS ENABLED" is shown - -2. **Test DNS** - ```bash - nslookup digiserver.sibiusb.harting.intra - ``` - Should resolve to: `10.76.152.164` - -3. **Test Ports** - ```bash - # Should be reachable - telnet 10.76.152.164 443 - telnet 10.76.152.164 80 - ``` - -4. **Check Logs** - - Admin Panel → Server Logs - - Look for HTTPS enable/disable messages - -5. **View Caddy Logs** - ```bash - docker-compose logs caddy - ``` - -### Domain Not Resolving? - -**Add to hosts file** (temporary): -- Windows: `C:\Windows\System32\drivers\etc\hosts` -- Mac/Linux: `/etc/hosts` - -Add line: -``` -10.76.152.164 digiserver.sibiusb.harting.intra -``` - ---- - -## 📋 Common Tasks - -### Enable HTTPS -1. Go to Admin → HTTPS Configuration -2. Toggle "Enable HTTPS" -3. Fill in hostname, domain, IP -4. Click "Save HTTPS Configuration" - -### Disable HTTPS -1. Go to Admin → HTTPS Configuration -2. Toggle off "Enable HTTPS" -3. Click "Save HTTPS Configuration" -4. App returns to HTTP only - -### Change Domain -1. Go to Admin → HTTPS Configuration -2. Update "Full Domain Name" -3. Click "Save HTTPS Configuration" - -### Check Current Settings -1. Go to Admin → HTTPS Configuration -2. View status card at top -3. Shows all current settings - -### View Configuration History -1. Admin Panel → Server Logs -2. Search for "HTTPS" -3. See all changes and who made them - ---- - -## 🎯 Configuration Examples - -### Default Setup (Already Provided) -``` -Hostname: digiserver -Domain: digiserver.sibiusb.harting.intra -IP: 10.76.152.164 -Port: 443 -``` - -### Different IP -``` -Hostname: digiserver -Domain: digiserver.sibiusb.harting.intra -IP: 10.76.152.165 ← Change this -Port: 443 -``` - -### Different Domain -``` -Hostname: myserver -Domain: myserver.company.local ← Change this -IP: 10.76.152.164 -Port: 443 -``` - ---- - -## 🔒 Security Notes - -✅ **Admin-Only Feature** -- Only administrators can access this page -- All changes logged with admin username - -✅ **Automatic SSL Certificates** -- Let's Encrypt manages certificates -- Auto-renewed before expiration -- No manual certificate management needed - -✅ **Access Control** -- HTTP redirects to HTTPS automatically -- Security headers automatically added -- Safe for internal and external access - ---- - -## 📞 Need Help? - -1. **Check Documentation** - - See: `HTTPS_CONFIGURATION.md` for detailed guide - - See: `HTTPS_IMPLEMENTATION_SUMMARY.md` for architecture - -2. **View Logs** - - Admin Panel → Server Logs - - Filter for HTTPS-related entries - -3. **Test Configuration** - ```bash - # Via CLI - python https_manager.py status - ``` - -4. **Restart Application** - ```bash - docker-compose restart - ``` - ---- - -## 📊 Quick Status Check - -**CLI Command:** -```bash -python https_manager.py status -``` - -**Output:** -``` -================================================== -HTTPS Configuration Status -================================================== -Status: ✅ ENABLED -Hostname: digiserver -Domain: digiserver.sibiusb.harting.intra -IP Address: 10.76.152.164 -Port: 443 -Updated: 2024-01-14 15:30:45 by admin - -Access URL: https://digiserver.sibiusb.harting.intra -Fallback: http://10.76.152.164 -================================================== -``` - ---- - -## 🎉 You're All Set! - -Your HTTPS configuration is ready to use. The system will: -- ✅ Manage SSL certificates automatically -- ✅ Keep them renewed -- ✅ Provide secure access -- ✅ Log all configuration changes -- ✅ Offer fallback HTTP access - -**That's it! Your app is now secure!** 🔒 diff --git a/old_code_documentation/HTTPS_SETUP.md b/old_code_documentation/HTTPS_SETUP.md deleted file mode 100644 index 61ce5bc..0000000 --- a/old_code_documentation/HTTPS_SETUP.md +++ /dev/null @@ -1,75 +0,0 @@ -# DigiServer v2 - HTTPS Setup with Caddy - -This setup uses **Caddy** as a reverse proxy with automatic HTTPS via Let's Encrypt. - -## Quick Setup - -### 1. Configure Domain -Create a `.env` file or edit the existing one: - -```bash -cp .env.example .env -``` - -Edit `.env` and set: -``` -DOMAIN=your-domain.com -EMAIL=admin@your-domain.com -``` - -### 2. Point Your Domain -Make sure your domain's DNS A record points to your server's IP address. - -### 3. Start Services -```bash -docker compose up -d -``` - -That's it! Caddy will **automatically**: -- Obtain SSL certificates from Let's Encrypt -- Renew certificates before expiration -- Redirect HTTP to HTTPS -- Enable HTTP/2 and HTTP/3 - -## Access Your Site - -- **HTTP**: http://your-domain.com (redirects to HTTPS) -- **HTTPS**: https://your-domain.com - -## Testing Locally (Without Domain) - -If you don't have a domain yet, leave DOMAIN as `localhost`: -``` -DOMAIN=localhost -``` - -Then access: http://localhost (no HTTPS, but app works) - -## Certificate Storage - -SSL certificates are stored in Docker volumes: -- `caddy-data` - Certificate data -- `caddy-config` - Caddy configuration - -## Troubleshooting - -### Check Caddy logs: -```bash -docker logs digiserver-caddy -``` - -### Verify certificates: -```bash -docker exec digiserver-caddy caddy list-certificates -``` - -### Force certificate renewal: -```bash -docker exec digiserver-caddy caddy reload --config /etc/caddy/Caddyfile -``` - -## Port Forwarding - -Make sure your firewall/router allows: -- Port 80 (HTTP - for Let's Encrypt challenge) -- Port 443 (HTTPS) diff --git a/old_code_documentation/IMPLEMENTATION_OPTIONAL_LIBREOFFICE.md b/old_code_documentation/IMPLEMENTATION_OPTIONAL_LIBREOFFICE.md deleted file mode 100644 index 83f9a6d..0000000 --- a/old_code_documentation/IMPLEMENTATION_OPTIONAL_LIBREOFFICE.md +++ /dev/null @@ -1,265 +0,0 @@ -# Optional LibreOffice Installation - Implementation Summary - -## Overview -Implemented a system to install LibreOffice on-demand instead of including it in the base Docker image, reducing image size by 56% (~900MB → ~400MB). - -## Changes Made - -### 1. Backend Implementation - -#### `/srv/digiserver-v2/app/blueprints/admin.py` -Added two new routes: - -- **`/admin/dependencies`** - Display dependency status page - - Checks LibreOffice, Poppler, FFmpeg installation status - - Uses subprocess to run version commands with 5s timeout - - Passes status variables to template - -- **`/admin/install_libreoffice`** (POST) - Install LibreOffice - - Executes `install_libreoffice.sh` with sudo - - 300s timeout for installation - - Logs installation output - - Flash messages for success/failure - -#### `/srv/digiserver-v2/app/blueprints/content.py` -Modified presentation file processing: - -- **Changed behavior**: Now returns error instead of accepting PPTX without LibreOffice -- **Error message**: "LibreOffice is not installed. Please install it from the Admin Panel → System Dependencies to upload PowerPoint files." -- **User experience**: Clear guidance on how to enable PPTX support - -### 2. Installation Script - -#### `/srv/digiserver-v2/install_libreoffice.sh` -Bash script to install LibreOffice: - -```bash -#!/bin/bash -# Checks root privileges -# Verifies if already installed -# Updates package cache -# Installs libreoffice and libreoffice-impress -# Verifies installation success -# Reports version -``` - -Features: -- Idempotent (safe to run multiple times) -- Error handling and validation -- Success/failure reporting -- Version verification - -### 3. Frontend Templates - -#### `/srv/digiserver-v2/app/templates/admin/dependencies.html` -New template showing: -- LibreOffice status (✅ installed or ❌ not installed) -- Poppler Utils status (always present) -- FFmpeg status (always present) -- Install button for LibreOffice when not present -- Installation notes and guidance -- Dark mode support - -#### `/srv/digiserver-v2/app/templates/admin/admin.html` -Added new card: -- "System Dependencies" card with gradient background -- Links to `/admin/dependencies` route -- Matches existing admin panel styling - -### 4. Docker Configuration - -#### `/srv/digiserver-v2/Dockerfile` -Key changes: -- **Removed**: `libreoffice` from apt-get install -- **Added**: `sudo` for installation script execution -- **Added**: Sudoers entry for appuser to run installation script -- **Added**: Script permissions (`chmod +x`) -- **Added**: Comments explaining optional LibreOffice - -Result: -- Base image: ~400MB (down from ~900MB) -- LibreOffice can be installed post-deployment -- Maintains security with non-root user - -### 5. Documentation - -#### `/srv/digiserver-v2/OPTIONAL_DEPENDENCIES.md` (NEW) -Comprehensive guide covering: -- Why optional dependencies? -- Installation methods (Web UI, Docker exec, direct) -- Checking dependency status -- File type support matrix -- Upload behavior with/without LibreOffice -- Technical details -- Installation times -- Troubleshooting -- Production recommendations -- FAQ - -#### `/srv/digiserver-v2/README.md` -Updated sections: -- Features: Added "Optional Dependencies" bullet -- Prerequisites: Marked LibreOffice as optional -- Installation: Separated required vs optional dependencies -- Troubleshooting: Enhanced PPTX troubleshooting with Web UI method -- Documentation: Added links to OPTIONAL_DEPENDENCIES.md -- Version History: Added v2.1 with optional LibreOffice feature - -#### `/srv/digiserver-v2/DOCKER.md` -Updated sections: -- Overview: Added base image size and optional LibreOffice info -- Maintenance: Added "Installing Optional Dependencies" section -- System Requirements: Split into base vs with LibreOffice - -## Benefits - -### Image Size Reduction -- **Before**: ~900MB (Python + Poppler + FFmpeg + LibreOffice) -- **After**: ~400MB (Python + Poppler + FFmpeg only) -- **Savings**: 500MB (56% reduction) - -### Deployment Speed -- Faster Docker pulls -- Faster container starts -- Lower bandwidth usage -- Lower storage requirements - -### Flexibility -- Users without PPTX needs: smaller, faster image -- Users with PPTX needs: install on-demand -- Can be installed/uninstalled as needed -- No rebuild required - -### User Experience -- Clear error messages when PPTX upload attempted -- Easy installation via Web UI -- Visual status indicators -- Guided troubleshooting - -## Technical Architecture - -### Dependency Detection -```python -# Uses subprocess to check installation -subprocess.run(['libreoffice', '--version'], - capture_output=True, timeout=5) -``` - -### Installation Flow -1. User clicks "Install LibreOffice" button -2. POST request to `/admin/install_libreoffice` -3. Server runs `sudo /app/install_libreoffice.sh` -4. Script installs packages via apt-get -5. Server logs output and flashes message -6. User refreshes to see updated status - -### Upload Validation -```python -# In process_presentation_file() -if not libreoffice_cmd: - return False, "LibreOffice is not installed..." -``` - -## Testing Checklist - -- [ ] Docker image builds successfully -- [ ] Base image size is ~400MB -- [ ] Server starts without LibreOffice -- [ ] Dependencies page shows correct status -- [ ] Install button appears when LibreOffice not present -- [ ] PPTX upload fails with clear error message -- [ ] Installation script runs successfully -- [ ] PPTX upload works after installation -- [ ] PDF uploads work without LibreOffice -- [ ] Image/video uploads work without LibreOffice -- [ ] Dark mode styling works on dependencies page - -## Security Considerations - -### Sudoers Configuration -```dockerfile -# Only allows running installation script, not arbitrary commands -echo "appuser ALL=(ALL) NOPASSWD: /app/install_libreoffice.sh" >> /etc/sudoers -``` - -### Installation Script -- Requires root privileges -- Validates installation success -- Uses official apt repositories -- No external downloads - -### Application Security -- Installation requires authenticated admin access -- Non-root user for runtime -- Timeouts prevent hanging processes - -## Maintenance Notes - -### Future Enhancements -- Add uninstall functionality -- Support for other optional dependencies -- Installation progress indicator -- Automatic dependency detection on upload - -### Known Limitations -- Installation requires sudo access -- Docker containers need sudo configured -- No progress feedback during installation (2-5 min wait) -- Requires internet connection for apt packages - -## Rollback Procedure - -If optional installation causes issues: - -1. **Restore LibreOffice to base image:** - ```dockerfile - RUN apt-get update && apt-get install -y \ - poppler-utils \ - libreoffice \ - ffmpeg \ - libmagic1 \ - && rm -rf /var/lib/apt/lists/* - ``` - -2. **Remove sudo configuration:** - ```dockerfile - # Remove this line - echo "appuser ALL=(ALL) NOPASSWD: /app/install_libreoffice.sh" >> /etc/sudoers - ``` - -3. **Revert content.py error behavior:** - ```python - if not libreoffice_cmd: - return True, "Presentation accepted without conversion..." - ``` - -## Files Modified - -1. `app/blueprints/admin.py` - Added dependency routes -2. `app/blueprints/content.py` - Changed PPTX error handling -3. `app/templates/admin/dependencies.html` - New status page -4. `app/templates/admin/admin.html` - Added dependencies card -5. `Dockerfile` - Removed LibreOffice, added sudo -6. `install_libreoffice.sh` - New installation script -7. `OPTIONAL_DEPENDENCIES.md` - New comprehensive guide -8. `README.md` - Updated with optional dependency info -9. `DOCKER.md` - Updated with installation instructions - -## Next Steps - -To complete the implementation: -1. Test Docker build: `docker-compose build` -2. Verify image size: `docker images | grep digiserver` -3. Test installation flow in running container -4. Update production deployment docs if needed -5. Consider adding installation progress indicator -6. Add metrics for tracking LibreOffice usage - -## Success Metrics - -- ✅ Docker image size reduced by >50% -- ✅ All file types work without LibreOffice (except PPTX) -- ✅ Clear error messages guide users to installation -- ✅ Installation works via Web UI -- ✅ Installation works via Docker exec -- ✅ Comprehensive documentation provided diff --git a/old_code_documentation/LEGACY_PLAYLIST_ROUTES.md b/old_code_documentation/LEGACY_PLAYLIST_ROUTES.md deleted file mode 100644 index e18ee89..0000000 --- a/old_code_documentation/LEGACY_PLAYLIST_ROUTES.md +++ /dev/null @@ -1,51 +0,0 @@ -# Legacy Playlist Routes & Templates - -## Status: DEPRECATED ❌ - -The `playlist/` folder contains legacy code that has been superseded by the content management interface. - -## What Changed - -### Old Workflow (DEPRECATED) -- Route: `/playlist/` -- Template: `playlist/manage_playlist.html` -- Used for managing playlists at the player level - -### New Workflow (ACTIVE) ✅ -- Route: `/content/playlist//manage` -- Template: `content/manage_playlist_content.html` -- Used for managing playlists in the content area -- Accessed from: Players → Manage Player → "Edit Playlist Content" button - -## Migration Notes - -**January 17, 2026:** -- Moved `app/templates/playlist/` to `old_code_documentation/playlist/` -- Updated `/playlist/` route to redirect to the new content management interface -- All playlist operations now go through the content management area (`manage_playlist_content.html`) - -## Why the Change? - -1. **Unified Interface**: Single playlist management interface instead of duplicate functionality -2. **Better UX**: Content management area is the primary interface accessed from players -3. **Maintenance**: Reduces code duplication and maintenance burden - -## Routes Still in Code - -The routes in `app/blueprints/playlist.py` still exist but are now legacy: -- `@playlist_bp.route('/')` - Redirects to content management -- `@playlist_bp.route('//add')` - No longer used -- `@playlist_bp.route('//remove/')` - No longer used -- etc. - -These can be removed in a future cleanup if needed. - -## Features in New Interface - -The new `manage_playlist_content.html` includes all features plus: -- ✅ Drag-to-reorder functionality -- ✅ Duration spinner buttons (⬆️ ⬇️) -- ✅ Audio on/off toggle -- ✅ Edit mode toggle for PDFs/images -- ✅ Dark mode support -- ✅ Bulk delete with checkboxes diff --git a/old_code_documentation/MODERNIZATION_COMPLETE.md b/old_code_documentation/MODERNIZATION_COMPLETE.md deleted file mode 100644 index d96083b..0000000 --- a/old_code_documentation/MODERNIZATION_COMPLETE.md +++ /dev/null @@ -1,262 +0,0 @@ -# Deployment Architecture - Complete Modernization Summary - -**Date:** January 17, 2026 -**Status:** ✅ COMPLETE & PRODUCTION READY - -## What Was Accomplished - -### 1. **Code Deployment Modernized (Option 1)** -- ✅ Moved code into Docker image (no volume override) -- ✅ Eliminated init-data.sh manual step -- ✅ Cleaner separation: code (immutable image) vs data (persistent volumes) - -### 2. **Legacy Code Cleaned** -- ✅ Archived groups feature (not used, replaced by playlists) -- ✅ Archived legacy playlist routes (redirects to content area now) -- ✅ Removed unused imports and API endpoints - -### 3. **Persistence Unified in /data Folder** -- ✅ Moved nginx.conf to data/ -- ✅ Moved nginx-custom-domains.conf to data/ -- ✅ All runtime files now in single data/ folder -- ✅ Clear separation: source code (git) vs runtime data (data/) - ---- - -## Complete Architecture (NOW) - -### Repository Structure (Source Code) -``` -/srv/digiserver-v2/ -├── app/ # Flask application (BUILT INTO DOCKER IMAGE) -├── migrations/ # Database migrations (BUILT INTO DOCKER IMAGE) -├── Dockerfile # Copies everything above into image -├── docker-compose.yml # Container orchestration -├── requirements.txt # Python dependencies -├── .gitignore -└── [other source files] # All built into image -``` - -### Container Runtime Structure (/data folder) -``` -data/ -├── instance/ # Database & config (PERSISTENT) -│ ├── digiserver.db -│ └── server.log -├── uploads/ # User uploads (PERSISTENT) -│ ├── app/static/uploads/ -│ └── [user files] -├── nginx.conf # Nginx main config (PERSISTENT) ✅ NEW -├── nginx-custom-domains.conf # Custom domains (PERSISTENT) ✅ NEW -├── nginx-ssl/ # SSL certificates (PERSISTENT) -├── nginx-logs/ # Web server logs (PERSISTENT) -├── certbot/ # Let's Encrypt data (PERSISTENT) -├── caddy-config/ # Caddy configurations -└── [other runtime files] -``` - -### Docker Container Volumes (No Code Mounts!) -```yaml -digiserver-app: - volumes: - - ./data/instance:/app/instance # DB - - ./data/uploads:/app/app/static/uploads # Uploads - # ✅ NO CODE MOUNT - code is in image! - -nginx: - volumes: - - ./data/nginx.conf:/etc/nginx/nginx.conf # ✅ FROM data/ - - ./data/nginx-custom-domains.conf:/etc/nginx/conf.d/custom-domains.conf # ✅ FROM data/ - - ./data/nginx-ssl:/etc/nginx/ssl # Certs - - ./data/nginx-logs:/var/log/nginx # Logs - - ./data/certbot:/var/www/certbot # ACME -``` - ---- - -## Deployment Flow (NOW) - -### Fresh Deployment -```bash -cd /srv/digiserver-v2 - -# 1. Prepare data folder -mkdir -p data/{instance,uploads,nginx-ssl,nginx-logs,certbot} -cp nginx.conf data/ -cp nginx-custom-domains.conf data/ - -# 2. Build image (includes app code) -docker-compose build - -# 3. Deploy -docker-compose up -d - -# 4. Initialize database (automatic on first run) -``` - -### Code Updates -```bash -# 1. Get new code -git pull - -# 2. Rebuild image (code change → new image) -docker-compose build - -# 3. Deploy new version -docker-compose up -d -``` - -### Configuration Changes -```bash -# Edit config in data/ (PERSISTENT) -nano data/nginx.conf -nano data/nginx-custom-domains.conf - -# Reload without full restart -docker-compose restart nginx -``` - ---- - -## Key Improvements - -### ✅ Deployment Simplicity -| Aspect | Before | After | -|--------|--------|-------| -| Manual setup step | init-data.sh required | None - auto in image | -| Config location | Mixed (root + data/) | Single (data/) | -| Code update process | Copy + restart | Build + restart | -| Backup strategy | Multiple locations | Single data/ folder | - -### ✅ Production Readiness -- Immutable code in image (reproducible deployments) -- Version-controlled via image tags -- Easy rollback: use old image tag -- CI/CD friendly: build → test → deploy - -### ✅ Data Safety -- All persistent data in one folder -- Easy backup: `tar czf backup.tar.gz data/` -- Easy restore: `tar xzf backup.tar.gz` -- Clear separation from source code - -### ✅ Repository Cleanliness -``` -Before: After: -./nginx.conf ❌ ./data/nginx.conf ✅ -./nginx-custom-domains.conf ./data/nginx-custom-domains.conf -./init-data.sh ❌ (archived as deprecated) -./app/ ✅ ./app/ ✅ (in image) -./data/app/ ❌ (redundant) [none - in image] -``` - ---- - -## Checklist: All Changes Deployed ✅ - -- [x] docker-compose.yml updated (no code volume mount) -- [x] Dockerfile enhanced (code baked in) -- [x] init-data.sh archived (no longer needed) -- [x] Groups feature archived (legacy/unused) -- [x] Playlist routes simplified (legacy redirects) -- [x] Nginx configs moved to data/ folder -- [x] All containers running healthy -- [x] HTTP/HTTPS working -- [x] Database persistent -- [x] Uploads persistent -- [x] Configuration persistent - ---- - -## Testing Results ✅ - -``` -✓ Docker build: SUCCESS -✓ Container startup: SUCCESS -✓ Flask app responding: SUCCESS -✓ Nginx HTTP (port 80): SUCCESS -✓ Nginx HTTPS (port 443): SUCCESS -✓ Database accessible: SUCCESS -✓ Uploads persisting: SUCCESS -✓ Logs persisting: SUCCESS -✓ Config persistence: SUCCESS -``` - ---- - -## File References - -### Migration & Implementation Docs -- `old_code_documentation/OPTION1_IMPLEMENTATION.md` - Docker architecture change -- `old_code_documentation/NGINX_CONFIG_MIGRATION.md` - Config file relocation -- `old_code_documentation/GROUPS_ANALYSIS.md` - Archived feature -- `old_code_documentation/LEGACY_PLAYLIST_ROUTES.md` - Simplified routes - -### Archived Code -- `old_code_documentation/init-data.sh.deprecated` - Old setup script -- `old_code_documentation/blueprint_groups.py` - Groups feature -- `old_code_documentation/templates_groups/` - Group templates -- `old_code_documentation/playlist/` - Legacy playlist templates - ---- - -## Next Steps (Optional Cleanup) - -### Option A: Keep Root Files (Safe) -```bash -# Keep nginx.conf and nginx-custom-domains.conf in root as backups -# They're not used but serve as reference -# Already ignored by .gitignore -``` - -### Option B: Clean Repository (Recommended) -```bash -# Remove root nginx files (already in data/) -rm nginx.conf -rm nginx-custom-domains.conf - -# Add to .gitignore if needed: -echo "nginx.conf" >> .gitignore -echo "nginx-custom-domains.conf" >> .gitignore -``` - ---- - -## Production Deployment - -### Recommended Workflow -```bash -# 1. Code changes -git commit -m "feature: add new UI" - -# 2. Build and test -docker-compose build -docker-compose up -d -# [run tests] - -# 3. Tag version -git tag v1.2.3 -docker tag digiserver-v2-digiserver-app:latest digiserver-v2-digiserver-app:v1.2.3 - -# 4. Push to registry -docker push myregistry/digiserver:v1.2.3 - -# 5. Deploy -docker pull myregistry/digiserver:v1.2.3 -docker-compose up -d -``` - ---- - -## Summary - -Your DigiServer deployment is now: -- 🚀 **Modern**: Docker best practices implemented -- 📦 **Clean**: Single source of truth for each layer -- 💾 **Persistent**: All data safely isolated -- 🔄 **Maintainable**: Clear separation of concerns -- 🏭 **Production-Ready**: Version control & rollback support -- ⚡ **Fast**: No manual setup steps -- 🔒 **Secure**: Immutable code in images - -**Status: ✅ READY FOR PRODUCTION** diff --git a/old_code_documentation/NGINX_CONFIG_MIGRATION.md b/old_code_documentation/NGINX_CONFIG_MIGRATION.md deleted file mode 100644 index 281ddcc..0000000 --- a/old_code_documentation/NGINX_CONFIG_MIGRATION.md +++ /dev/null @@ -1,111 +0,0 @@ -# Nginx Config Files Moved to Data Folder - -**Date:** January 17, 2026 -**Purpose:** Complete persistence isolation - all Docker runtime files in `data/` folder - -## What Changed - -### Files Moved -- `./nginx.conf` → `./data/nginx.conf` -- `./nginx-custom-domains.conf` → `./data/nginx-custom-domains.conf` - -### docker-compose.yml Updated -```yaml -volumes: - - ./data/nginx.conf:/etc/nginx/nginx.conf:ro # ✅ NOW in data/ - - ./data/nginx-custom-domains.conf:/etc/nginx/conf.d/custom-domains.conf:rw # ✅ NOW in data/ - - ./data/nginx-ssl:/etc/nginx/ssl:ro - - ./data/nginx-logs:/var/log/nginx - - ./data/certbot:/var/www/certbot:ro -``` - -## Complete Data Folder Structure (Now Unified) - -``` -/data/ -├── app/ # Flask application (in Docker image, not mounted) -├── instance/ # Database & config -│ ├── digiserver.db -│ └── server.log -├── uploads/ # User uploads -│ └── app/static/uploads/... -├── nginx.conf # ✅ Nginx main config -├── nginx-custom-domains.conf # ✅ Custom domain config -├── nginx-ssl/ # SSL certificates -│ ├── cert.pem -│ └── key.pem -├── nginx-logs/ # Nginx logs -│ ├── access.log -│ └── error.log -└── certbot/ # Let's Encrypt certificates -``` - -## Benefits - -✅ **Unified Persistence:** All runtime configuration in `/data` -✅ **Easy Backup:** Single `data/` folder contains everything -✅ **Consistent Permissions:** All files managed together -✅ **Clean Repository:** Root directory only has source code -✅ **Deployment Clarity:** Clear separation: source (`./app`) vs runtime (`./data`) - -## Testing Results - -- ✅ Nginx started successfully with new config paths -- ✅ HTTP requests working (port 80) -- ✅ HTTPS requests working (port 443) -- ✅ No configuration errors - -## Updating Existing Deployments - -If you have an existing deployment: - -```bash -# 1. Copy configs to data/ -cp nginx.conf data/nginx.conf -cp nginx-custom-domains.conf data/nginx-custom-domains.conf - -# 2. Update docker-compose.yml -# (Already updated - change volume paths from ./ to ./data/) - -# 3. Restart nginx -docker-compose restart nginx - -# 4. Verify -curl http://localhost -curl -k https://localhost -``` - -## Important Notes - -### If You Edit Nginx Config -```bash -# Edit the config in data/, NOT in root -nano data/nginx.conf -nano data/nginx-custom-domains.conf - -# Then restart nginx -docker-compose restart nginx -``` - -### Root Files Now Optional -The old `nginx.conf` and `nginx-custom-domains.conf` in the root can be: -- **Deleted** (cleanest - all runtime files in data/) -- **Kept** (reference/backup - but not used by containers) - -### Recommendations -- Delete root nginx config files for cleaner repository -- Keep in `.gitignore` if you want to preserve them as backups -- All active configs now in `data/` folder which can be `.gitignore`d - -## Related Changes - -Part of ongoing simplification: -1. ✅ Option 1 Implementation - Dockerfile-based code deployment -2. ✅ Groups feature archived -3. ✅ Legacy playlist routes simplified -4. ✅ Nginx configs now in data/ folder - -All contributing to: -- Cleaner repository structure -- Complete persistence isolation -- Production-ready deployment model diff --git a/old_code_documentation/NGINX_SETUP_QUICK.md b/old_code_documentation/NGINX_SETUP_QUICK.md deleted file mode 100644 index 22e626f..0000000 --- a/old_code_documentation/NGINX_SETUP_QUICK.md +++ /dev/null @@ -1,84 +0,0 @@ -# Quick Start: Nginx Setup for DigiServer v2 - -## Pre-requisites -- SSL certificates in `./data/nginx-ssl/cert.pem` and `./data/nginx-ssl/key.pem` -- Docker and Docker Compose installed -- Port 80 and 443 available - -## Quick Setup (3 steps) - -### 1. Generate Self-Signed Certificates -```bash -./generate_nginx_certs.sh localhost 365 -``` - -### 2. Update Nginx Configuration -- Edit `nginx.conf` to set your domain: - ```nginx - server_name localhost; # Change to your domain - ``` - -### 3. Start Docker Compose -```bash -docker-compose up -d -``` - -## Verification - -### Check if Nginx is running -```bash -docker ps | grep nginx -``` - -### Test HTTP → HTTPS redirect -```bash -curl -L http://localhost -``` - -### Test HTTPS (with self-signed cert) -```bash -curl -k https://localhost -``` - -### View logs -```bash -docker logs digiserver-nginx -docker exec digiserver-nginx tail -f /var/log/nginx/access.log -``` - -## Using Production Certificates - -### Option A: Let's Encrypt (Free) -1. Install certbot: `apt-get install certbot` -2. Generate cert: `certbot certonly --standalone -d your-domain.com` -3. Copy cert: `cp /etc/letsencrypt/live/your-domain.com/fullchain.pem ./data/nginx-ssl/cert.pem` -4. Copy key: `cp /etc/letsencrypt/live/your-domain.com/privkey.pem ./data/nginx-ssl/key.pem` -5. Fix permissions: `sudo chown 101:101 ./data/nginx-ssl/*` -6. Reload: `docker exec digiserver-nginx nginx -s reload` - -### Option B: Commercial Certificate -1. Place your certificate files in `./data/nginx-ssl/cert.pem` and `./data/nginx-ssl/key.pem` -2. Fix permissions: `sudo chown 101:101 ./data/nginx-ssl/*` -3. Reload: `docker exec digiserver-nginx nginx -s reload` - -## Troubleshooting - -| Issue | Solution | -|-------|----------| -| Port 80/443 in use | `sudo netstat -tlnp \| grep :80` or `:443` | -| Certificate permission denied | `sudo chown 101:101 ./data/nginx-ssl/*` | -| Nginx won't start | `docker logs digiserver-nginx` | -| Connection refused | Check firewall: `sudo ufw allow 80/tcp && sudo ufw allow 443/tcp` | - -## File Locations -- Main config: `./nginx.conf` -- SSL certs: `./data/nginx-ssl/` -- Logs: `./data/nginx-logs/` -- Custom domains: `./nginx-custom-domains.conf` (auto-generated) - -## Next: Production Setup -1. Update `.env` with your DOMAIN and EMAIL -2. Configure HTTPS settings in admin panel -3. Run: `python nginx_manager.py generate` -4. Test: `docker exec digiserver-nginx nginx -t` -5. Reload: `docker exec digiserver-nginx nginx -s reload` diff --git a/old_code_documentation/OPTION1_IMPLEMENTATION.md b/old_code_documentation/OPTION1_IMPLEMENTATION.md deleted file mode 100644 index ba1a336..0000000 --- a/old_code_documentation/OPTION1_IMPLEMENTATION.md +++ /dev/null @@ -1,226 +0,0 @@ -# Option 1 Implementation - Dockerfile-based Deployment - -**Implementation Date:** January 17, 2026 -**Status:** ✅ COMPLETE - -## What Changed - -### 1. **docker-compose.yml** -**Removed:** -```yaml -volumes: - - ./data:/app # ❌ REMOVED - no longer override code in image -``` - -**Kept:** -```yaml -volumes: - - ./data/instance:/app/instance # Database persistence - - ./data/uploads:/app/app/static/uploads # User uploads -``` - -### 2. **Dockerfile** -**Updated comments** for clarity: -```dockerfile -# Copy entire application code into container -# This includes: app/, migrations/, configs, and all scripts -# Code is immutable in the image - only data folders are mounted as volumes -COPY . . -``` - -### 3. **init-data.sh** -**Archived:** Moved to `/old_code_documentation/init-data.sh.deprecated` -- No longer needed -- Code is now built into the Docker image -- Manual file copying step eliminated - -## How It Works Now - -### Previous Architecture (Option 2) -``` -Host: Container: -./app/ → (ignored - overridden by volume) -./data/app/ → /app (volume mount) -./data/instance/ → /app/instance (volume mount) -./data/uploads/ → /app/app/static/uploads (volume mount) -``` - -### New Architecture (Option 1) -``` -Host: Container: -./app/ → Baked into image during build - (no override) -./data/instance/ → /app/instance (volume mount) -./data/uploads/ → /app/app/static/uploads (volume mount) - -Deployment: -docker-compose build (includes app code in image) -docker-compose up -d (runs image with data mounts) -``` - -## Benefits of Option 1 - -✅ **Simpler Architecture** -- Single source of truth: Dockerfile -- No redundant file copying - -✅ **Faster Deployment** -- No init-data.sh step needed -- No file sync delays -- Build once, deploy everywhere - -✅ **Production Best Practices** -- Immutable code in image -- Code changes via image rebuild/tag change -- Cleaner separation: code (image) vs data (volumes) - -✅ **Better for CI/CD** -- Each deployment uses a specific image tag -- Easy rollback: just use old image tag -- Version control of deployments - -✅ **Data Integrity** -- Data always protected in `/data/instance` and `/data/uploads` -- No risk of accidental code deletion - -## Migration Path for Existing Deployments - -### If you're upgrading from Option 2 to Option 1: - -```bash -# 1. Stop the old container -docker-compose down - -# 2. Backup your data (IMPORTANT!) -cp -r data/instance data/instance.backup -cp -r data/uploads data/uploads.backup - -# 3. Update docker-compose.yml -# (Already done - remove ./data:/app volume) - -# 4. Rebuild with new Dockerfile -docker-compose build --no-cache - -# 5. Start with new configuration -docker-compose up -d - -# 6. Verify app is running -docker-compose logs digiserver-app -``` - -### Data Persistence -Your data is safe because: -- Database: Still mounted at `./data/instance` -- Uploads: Still mounted at `./data/uploads` -- Only code location changed (from volume mount to image) - -## What to Do If You Need to Update Code - -### Development Updates -```bash -# Make code changes in ./app/ -git pull -docker-compose build # Rebuild image with new code -docker-compose up -d # Restart with new image -``` - -### Production Deployments -```bash -# Option A: Rebuild from source -docker-compose build -docker-compose up -d - -# Option B: Use pre-built images (recommended for production) -docker pull your-registry/digiserver:v1.2.3 -docker tag your-registry/digiserver:v1.2.3 local-digiserver:latest -docker-compose up -d -``` - -## Rollback Procedure - -If something goes wrong after updating code: - -```bash -# Use the previous image -docker-compose down -docker images | grep digiserver # Find previous version -docker tag digiserver-v2-digiserver-app:old-hash \ - digiserver-v2-digiserver-app:latest -docker-compose up -d -``` - -Or rebuild from a known-good commit: -```bash -git checkout -docker-compose build -docker-compose up -d -``` - -## Monitoring Code in Container - -To verify code is inside the image (not volume-mounted): - -```bash -# Check if app folder exists in image -docker run --rm digiserver-v2-digiserver-app ls /app/ - -# Check volume mounts (should NOT show /app) -docker inspect digiserver-v2 | grep -A10 "Mounts" -``` - -## Troubleshooting - -### "Module not found" errors -**Solution:** Rebuild the image -```bash -docker-compose build --no-cache -docker-compose down -docker-compose up -d -``` - -### Database locked/permission errors -**Solution:** Check instance mount -```bash -docker exec digiserver-v2 ls -la /app/instance/ -``` - -### Code changes not reflected -**Remember:** Must rebuild image for code changes -```bash -docker-compose build -docker-compose restart -``` - -## Files Changed Summary - -| File | Change | Reason | -|------|--------|--------| -| `docker-compose.yml` | Removed `./data:/app` volume | Code now in image | -| `Dockerfile` | Updated comments | Clarify immutable code approach | -| `init-data.sh` | Archived as deprecated | No longer needed | -| `deploy.sh` | No change needed | Already doesn't call init-data.sh | - -## Testing Checklist ✅ - -- [x] Docker builds successfully -- [x] Container starts without errors -- [x] App responds to HTTP requests -- [x] Database persists in `./data/instance` -- [x] Uploads persist in `./data/uploads` -- [x] No volume mount to `./data/app` in container - -## Performance Impact - -**Startup Time:** ~2-5 seconds faster (no file copying) -**Image Size:** No change (same code, just built-in) -**Runtime Performance:** No change -**Disk Space:** Slightly more (code in image + docker layer cache) - ---- - -## Reference - -- **Analysis Document:** `old_code_documentation/DEPLOYMENT_ARCHITECTURE_ANALYSIS.md` -- **Old Script:** `old_code_documentation/init-data.sh.deprecated` -- **Implementation Date:** January 17, 2026 -- **Status:** ✅ Production Ready diff --git a/old_code_documentation/OPTIONAL_DEPENDENCIES.md b/old_code_documentation/OPTIONAL_DEPENDENCIES.md deleted file mode 100644 index 55e4820..0000000 --- a/old_code_documentation/OPTIONAL_DEPENDENCIES.md +++ /dev/null @@ -1,258 +0,0 @@ -# Optional Dependencies Guide - -DigiServer v2 uses an optimized dependency installation strategy to minimize Docker image size while maintaining full functionality. - -## Overview - -The base Docker image (~400MB) includes only essential dependencies: -- **Poppler Utils** - PDF to image conversion -- **FFmpeg** - Video processing and validation -- **Python 3.13** - Application runtime - -Optional dependencies can be installed on-demand: -- **LibreOffice** (~500MB) - PowerPoint (PPTX/PPT) to image conversion - -## Why Optional Dependencies? - -By excluding LibreOffice from the base image, we reduce: -- **Initial image size**: From ~900MB to ~400MB (56% reduction) -- **Download time**: Faster deployments -- **Storage requirements**: Lower disk usage on hosts - -Users who don't need PowerPoint conversion benefit from a smaller, faster image. - -## Installation Methods - -### 1. Web UI (Recommended) - -The easiest way to install LibreOffice: - -1. Log in to DigiServer admin panel -2. Navigate to **Admin Panel** → **System Dependencies** -3. Click **"Install LibreOffice"** button -4. Wait 2-5 minutes for installation -5. Refresh the page to verify installation - -The web interface provides: -- Real-time installation status -- Version verification -- Error reporting -- No terminal access needed - -### 2. Docker Exec (Manual) - -For Docker deployments, use `docker exec`: - -```bash -# Enter the container -docker exec -it digiserver bash - -# Run the installation script -sudo /app/install_libreoffice.sh - -# Verify installation -libreoffice --version -``` - -### 3. Direct Installation (Non-Docker) - -For bare-metal or VM deployments: - -```bash -# Make script executable (if not already) -chmod +x /srv/digiserver-v2/install_libreoffice.sh - -# Run the installation script -sudo /srv/digiserver-v2/install_libreoffice.sh - -# Verify installation -libreoffice --version -``` - -## Checking Dependency Status - -### Web Interface - -Navigate to **Admin Panel** → **System Dependencies** to see: -- ✅ LibreOffice: Installed or ❌ Not installed -- ✅ Poppler Utils: Installed (always present) -- ✅ FFmpeg: Installed (always present) - -### Command Line - -Check individual dependencies: - -```bash -# LibreOffice -libreoffice --version - -# Poppler -pdftoppm -v - -# FFmpeg -ffmpeg -version -``` - -## File Type Support Matrix - -| File Type | Required Dependency | Status | -|-----------|-------------------|---------| -| **Images** (JPG, PNG, GIF) | None | Always supported | -| **PDF** | Poppler Utils | Always available | -| **Videos** (MP4, AVI, MOV) | FFmpeg | Always available | -| **PowerPoint** (PPTX, PPT) | LibreOffice | Optional install | - -## Upload Behavior - -### Without LibreOffice - -When you try to upload a PowerPoint file without LibreOffice: -- Upload will be **rejected** -- Error message: *"LibreOffice is not installed. Please install it from the Admin Panel → System Dependencies to upload PowerPoint files."* -- Other file types (PDF, images, videos) work normally - -### With LibreOffice - -After installation: -- PowerPoint files are converted to high-quality PNG images -- Each slide becomes a separate media item -- Slides maintain aspect ratio and resolution -- Original PPTX file is deleted after conversion - -## Technical Details - -### Installation Script - -The `install_libreoffice.sh` script: -1. Checks for root/sudo privileges -2. Verifies if LibreOffice is already installed -3. Updates apt package cache -4. Installs `libreoffice` and `libreoffice-impress` -5. Verifies successful installation -6. Reports version and status - -### Docker Implementation - -The Dockerfile includes: -- Sudo access for `appuser` to run installation script -- Script permissions set during build -- No LibreOffice in base layers (smaller image) - -### Security Considerations - -- Installation requires sudo/root access -- In Docker, `appuser` has limited sudo rights (only for installation script) -- Installation script validates LibreOffice binary after install -- No external downloads except from official apt repositories - -## Installation Time - -Typical installation times: -- **Fast network** (100+ Mbps): 2-3 minutes -- **Average network** (10-100 Mbps): 3-5 minutes -- **Slow network** (<10 Mbps): 5-10 minutes - -The installation downloads approximately 450-500MB of packages. - -## Troubleshooting - -### Installation Fails - -**Error**: "Permission denied" -- **Solution**: Ensure script has execute permissions (`chmod +x`) -- **Docker**: Check sudoers configuration in Dockerfile - -**Error**: "Unable to locate package" -- **Solution**: Run `sudo apt-get update` first -- **Docker**: Rebuild image with fresh apt cache - -### Installation Hangs - -- Check internet connectivity -- Verify apt repositories are accessible -- In Docker, check container has network access -- Increase timeout if on slow connection - -### Verification Fails - -**Symptom**: Installation completes but LibreOffice not found -- **Solution**: Check LibreOffice was installed to expected path -- Run: `which libreoffice` to locate binary -- Verify with: `libreoffice --version` - -### Upload Still Fails After Installation - -1. Verify installation: Admin Panel → System Dependencies -2. Check server logs for conversion errors -3. Restart application: `docker restart digiserver` (Docker) or restart Flask -4. Try uploading a simple PPTX file to test - -## Uninstallation - -To remove LibreOffice and reclaim space: - -```bash -# In container or host -sudo apt-get remove --purge libreoffice libreoffice-impress -sudo apt-get autoremove -sudo apt-get clean -``` - -This frees approximately 500MB of disk space. - -## Production Recommendations - -### When to Install LibreOffice - -Install LibreOffice if: -- Users need to upload PowerPoint presentations -- You have >1GB free disk space -- Network bandwidth supports 500MB download - -### When to Skip LibreOffice - -Skip LibreOffice if: -- Only using PDF, images, and videos -- Disk space is constrained (<2GB) -- Want minimal installation footprint -- Can convert PPTX to PDF externally - -### Multi-Container Deployments - -For multiple instances: -- **Option A**: Create custom image with LibreOffice pre-installed -- **Option B**: Install on each container individually -- **Option C**: Use shared volume for LibreOffice binaries - -## FAQ - -**Q: Will removing LibreOffice break existing media?** -A: No, converted slides remain as PNG images after conversion. - -**Q: Can I pre-install LibreOffice in the Docker image?** -A: Yes, uncomment the `libreoffice` line in Dockerfile and rebuild. - -**Q: How much space does LibreOffice use?** -A: Approximately 450-500MB installed. - -**Q: Does LibreOffice run during conversion?** -A: Yes, in headless mode. It converts slides to PNG without GUI. - -**Q: Can I use other presentation converters?** -A: The code currently only supports LibreOffice. Custom converters require code changes. - -**Q: Is LibreOffice safe for production?** -A: Yes, LibreOffice is widely used in production environments for document conversion. - -## Support - -For issues with optional dependencies: -1. Check the **System Dependencies** page in Admin Panel -2. Review server logs: `docker logs digiserver` -3. Verify system requirements (disk space, memory) -4. Consult DOCKER.md for container-specific guidance - -## Version History - -- **v2.0**: Introduced optional LibreOffice installation -- **v1.0**: LibreOffice included in base image (larger size) diff --git a/old_code_documentation/PLAYER_EDIT_MEDIA_API.md b/old_code_documentation/PLAYER_EDIT_MEDIA_API.md deleted file mode 100644 index 5e0fd28..0000000 --- a/old_code_documentation/PLAYER_EDIT_MEDIA_API.md +++ /dev/null @@ -1,181 +0,0 @@ -# Player Edit Media API - -## Overview -This API allows players to upload edited media files back to the server, maintaining version history and automatically updating playlists. - -## Endpoint - -### POST `/api/player-edit-media` - -Upload an edited media file from a player device. - -**Authentication Required:** Yes (Bearer token) - -**Rate Limit:** 60 requests per 60 seconds - -**Content-Type:** `multipart/form-data` - -## Request - -### Form Data - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `image_file` | File | Yes | The edited image file | -| `metadata` | JSON String | Yes | Metadata about the edit (see below) | - -### Metadata JSON Structure - -```json -{ - "time_of_modification": "2025-12-05T20:30:00Z", - "original_name": "image.jpg", - "new_name": "image_v1.jpg", - "version": 1, - "user": "player_user_name" -} -``` - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `time_of_modification` | ISO 8601 DateTime | Yes | When the edit was made | -| `original_name` | String | Yes | Original filename (must exist in content) | -| `new_name` | String | Yes | New filename with version suffix | -| `version` | Integer | Yes | Version number (1, 2, 3, etc.) | -| `user` | String | No | User who made the edit | - -## Response - -### Success (200 OK) - -```json -{ - "success": true, - "message": "Edited media received and processed", - "edit_id": 123, - "version": 1, - "new_playlist_version": 5 -} -``` - -### Error Responses - -#### 400 Bad Request -```json -{ - "error": "No image file provided" -} -``` - -#### 404 Not Found -```json -{ - "error": "Original content not found: image.jpg" -} -``` - -#### 500 Internal Server Error -```json -{ - "error": "Internal server error" -} -``` - -## Workflow - -1. **Player edits media** - User edits an image/PDF/PPTX on the player device -2. **Player uploads** - Player sends edited file + metadata to this endpoint -3. **Server processes**: - - Saves edited file to `/static/uploads/edited_media//` - - Saves metadata JSON to `/static/uploads/edited_media//_metadata.json` - - Replaces original file in `/static/uploads/` with edited version - - Creates database record in `player_edit` table - - Increments playlist version to trigger player refresh - - Clears playlist cache -4. **Player refreshes** - Next playlist check shows updated media - -## Version History - -Each edit is saved with a version number: -- `image.jpg` → `image_v1.jpg` (first edit) -- `image.jpg` → `image_v2.jpg` (second edit) -- etc. - -All versions are preserved in the `edited_media//` folder. - -## Example cURL Request - -```bash -# First, authenticate to get token -TOKEN=$(curl -X POST http://server/api/auth/authenticate \ - -H "Content-Type: application/json" \ - -d '{"hostname": "player-1", "password": "password123"}' \ - | jq -r '.token') - -# Upload edited media -curl -X POST http://server/api/player-edit-media \ - -H "Authorization: Bearer $TOKEN" \ - -F "image_file=@edited_image_v1.jpg" \ - -F 'metadata={"time_of_modification":"2025-12-05T20:30:00Z","original_name":"image.jpg","new_name":"image_v1.jpg","version":1,"user":"john"}' -``` - -## Python Example - -```python -import requests -import json - -# Authenticate -auth_response = requests.post( - 'http://server/api/auth/authenticate', - json={'hostname': 'player-1', 'password': 'password123'} -) -token = auth_response.json()['token'] - -# Prepare metadata -metadata = { - 'time_of_modification': '2025-12-05T20:30:00Z', - 'original_name': 'image.jpg', - 'new_name': 'image_v1.jpg', - 'version': 1, - 'user': 'john' -} - -# Upload edited file -with open('edited_image_v1.jpg', 'rb') as f: - response = requests.post( - 'http://server/api/player-edit-media', - headers={'Authorization': f'Bearer {token}'}, - files={'image_file': f}, - data={'metadata': json.dumps(metadata)} - ) - -print(response.json()) -``` - -## Database Schema - -### player_edit Table - -| Column | Type | Description | -|--------|------|-------------| -| id | INTEGER | Primary key | -| player_id | INTEGER | Foreign key to player | -| content_id | INTEGER | Foreign key to content | -| original_name | VARCHAR(255) | Original filename | -| new_name | VARCHAR(255) | New filename with version | -| version | INTEGER | Version number | -| user | VARCHAR(255) | User who made the edit | -| time_of_modification | DATETIME | When edit was made | -| metadata_path | VARCHAR(512) | Path to metadata JSON | -| edited_file_path | VARCHAR(512) | Path to edited file | -| created_at | DATETIME | Record creation time | - -## UI Display - -Edited media history is displayed on the player management page under the "Edited Media on the Player" card, showing: -- Original filename -- Version number -- Editor name -- Modification time -- Link to view edited file diff --git a/old_code_documentation/PROXY_FIX_SETUP.md b/old_code_documentation/PROXY_FIX_SETUP.md deleted file mode 100644 index 659f200..0000000 --- a/old_code_documentation/PROXY_FIX_SETUP.md +++ /dev/null @@ -1,56 +0,0 @@ -# ProxyFix Middleware Setup - DigiServer v2 - -## Overview -ProxyFix middleware is now properly configured in the Flask app to handle reverse proxy headers from Nginx (or Caddy). This ensures correct handling of: -- **X-Real-IP**: Client's real IP address -- **X-Forwarded-For**: List of IPs in the proxy chain -- **X-Forwarded-Proto**: Original protocol (http/https) -- **X-Forwarded-Host**: Original hostname - -## Configuration Details - -### Flask App (app/app.py) -```python -from werkzeug.middleware.proxy_fix import ProxyFix - -app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) -``` - -**Parameters:** -- `x_for=1`: Trust one proxy for X-Forwarded-For header -- `x_proto=1`: Trust proxy for X-Forwarded-Proto header -- `x_host=1`: Trust proxy for X-Forwarded-Host header -- `x_port=1`: Trust proxy for X-Forwarded-Port header - -### Config Settings (app/config.py) - -```python -# Reverse proxy trust (for Nginx/Caddy with ProxyFix middleware) -TRUSTED_PROXIES = os.getenv('TRUSTED_PROXIES', '127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16') -PREFERRED_URL_SCHEME = os.getenv('PREFERRED_URL_SCHEME', 'https') -``` - -## Testing ProxyFix - -### 1. Test Real Client IP -```bash -docker exec digiserver-app flask shell ->>> from flask import request ->>> request.remote_addr # Should show client IP -``` - -### 2. Test URL Scheme -```bash -docker exec digiserver-app flask shell ->>> from flask import url_for ->>> url_for('auth.login', _external=True) # Should use https:// -``` - -## Verification Checklist - -- [x] ProxyFix imported in app.py -- [x] app.wsgi_app wrapped with ProxyFix -- [x] TRUSTED_PROXIES configured -- [x] PREFERRED_URL_SCHEME set to 'https' -- [x] SESSION_COOKIE_SECURE=True in ProductionConfig -- [x] Nginx headers configured correctly diff --git a/old_code_documentation/QUICK_START.md b/old_code_documentation/QUICK_START.md deleted file mode 100644 index 32a8ca1..0000000 --- a/old_code_documentation/QUICK_START.md +++ /dev/null @@ -1,120 +0,0 @@ -# 🚀 DigiServer Deployment - Quick Reference Card - -## Instant Deployment - -```bash -cd /path/to/digiserver-v2 -./deploy.sh -``` - -That's it! ✅ - ---- - -## 📖 Documentation Files - -| File | Purpose | Size | -|------|---------|------| -| [DEPLOYMENT_INDEX.md](DEPLOYMENT_INDEX.md) | Navigation guide | 8.1 KB | -| [DEPLOYMENT_README.md](DEPLOYMENT_README.md) | Complete guide | 9.4 KB | -| [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md) | Command reference | 7.6 KB | -| [DEPLOYMENT_COMMANDS.md](DEPLOYMENT_COMMANDS.md) | Detailed guide | 6.8 KB | - ---- - -## 🔧 Executable Scripts - -| Script | Purpose | Time | -|--------|---------|------| -| [deploy.sh](deploy.sh) | Fully automated | 2-3 min | -| [setup_https.sh](setup_https.sh) | Semi-automated | 3-5 min | - ---- - -## 🎯 Common Commands - -### Check Status -```bash -docker-compose ps -``` - -### View Logs -```bash -docker-compose logs -f digiserver-app -``` - -### Verify HTTPS Configuration -```bash -docker-compose exec -T digiserver-app python /app/https_manager.py status -``` - -### Access the Application -``` -https://digiserver.sibiusb.harting.intra -https://10.76.152.164 -https://digiserver -``` - -### Default Login -``` -Username: admin -Password: admin123 -``` - ---- - -## 🆘 Troubleshooting - -| Issue | Solution | -|-------|----------| -| Containers won't start | `docker-compose logs` | -| Migration fails | Check DB connection, see docs | -| HTTPS errors | Clear Caddy cache: `docker volume rm digiserver-v2_caddy-*` | -| Port conflict | `lsof -i :443` or change in docker-compose.yml | - -See [DEPLOYMENT_README.md#-troubleshooting](DEPLOYMENT_README.md#-troubleshooting) for full guide. - ---- - -## 🌍 Deploy on Different PC - -1. Copy project files -2. Install Docker & Docker Compose -3. Run `./deploy.sh` - -Done! 🎉 - ---- - -## 🔑 Customize Deployment - -```bash -HOSTNAME=myserver \ -DOMAIN=myserver.internal \ -IP_ADDRESS=192.168.1.100 \ -EMAIL=admin@example.com \ -./deploy.sh -``` - ---- - -## 📚 Need More Help? - -- **First time?** → [DEPLOYMENT_README.md](DEPLOYMENT_README.md) -- **Need a command?** → [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md) -- **Lost?** → [DEPLOYMENT_INDEX.md](DEPLOYMENT_INDEX.md) -- **Troubleshooting?** → [DEPLOYMENT_README.md#-troubleshooting](DEPLOYMENT_README.md#-troubleshooting) - ---- - -## ✨ What You Get - -✅ Web application with admin dashboard -✅ HTTPS with self-signed certificates -✅ User management system -✅ Player & content management -✅ Fully configured & ready to use - ---- - -**Ready to deploy?** `./deploy.sh` 🚀 diff --git a/old_code_documentation/README.md b/old_code_documentation/README.md deleted file mode 100644 index f5872d2..0000000 --- a/old_code_documentation/README.md +++ /dev/null @@ -1,297 +0,0 @@ -# DigiServer v2 - -Digital Signage Management System - A modern Flask-based application for managing content playlists across multiple display screens. - -## Features - -- 📺 **Multi-Player Management** - Control multiple display screens from one interface -- 🎬 **Playlist System** - Create and manage content playlists with drag-and-drop reordering -- 📁 **Media Library** - Upload and organize images, videos, PDFs, and presentations -- 📄 **PDF to Image Conversion** - Automatic conversion of PDF pages to Full HD images (300 DPI) -- 📊 **PowerPoint Support** - Convert PPTX slides to images automatically (optional LibreOffice install) -- 🖼️ **Live Preview** - Real-time content preview for each player -- ⚡ **Real-time Updates** - Players automatically sync with playlist changes -- 🌓 **Dark Mode** - Full dark mode support across all interfaces -- 🗑️ **Media Management** - Clean up unused media files with leftover media manager -- 🔧 **Optional Dependencies** - Install LibreOffice on-demand to reduce base image size by 56% -- 🔒 **User Authentication** - Secure admin access with role-based permissions - -## Quick Start - -### Option 1: Docker (Recommended) - -```bash -# Quick start with Docker -./docker-start.sh -``` - -Access at: `http://localhost:5000` - -Default credentials: `admin` / `admin123` - -See [DOCKER.md](DOCKER.md) for detailed Docker documentation. - -### Option 2: Manual Installation - -#### Prerequisites - -- Python 3.13+ -- Poppler Utils (for PDF conversion) - **Required** -- FFmpeg (for video processing) - **Required** -- LibreOffice (for PPTX conversion) - **Optional** (can be installed via Admin Panel) - -#### Installation - -```bash -# Install required system dependencies (Debian/Ubuntu) -sudo apt-get update -sudo apt-get install -y poppler-utils ffmpeg libmagic1 - -# Optional: Install LibreOffice for PowerPoint conversion -# OR install later via Admin Panel → System Dependencies -sudo apt-get install -y libreoffice - -# Create virtual environment -python3 -m venv venv -source venv/bin/activate # On Windows: venv\Scripts\activate - -# Install Python dependencies -pip install -r requirements.txt - -# Initialize database -python -c " -from app.app import create_app -from app.extensions import db, bcrypt -from app.models import User - -app = create_app() -with app.app_context(): - db.create_all() - hashed = bcrypt.generate_password_hash('admin123').decode('utf-8') - admin = User(username='admin', password=hashed, role='admin') - db.session.add(admin) - db.session.commit() - print('Admin user created') -" - -# Run development server -./run_dev.sh -``` - -Access at: `http://localhost:5000` - -## Deployment - -### Docker Deployment - -```bash -# Build and run with Docker Compose -docker-compose up -d - -# View logs -docker-compose logs -f - -# Stop -docker-compose down -``` - -### Production Deployment - -For production, use: -- Gunicorn or uWSGI as WSGI server -- Nginx as reverse proxy -- Redis for caching (optional) -- PostgreSQL for larger deployments (optional) - -See [DOCKER.md](DOCKER.md) for detailed deployment instructions. - -## Usage - -### 1. Create a Playlist - -1. Navigate to **Playlist Management** -2. Fill in playlist details (name, orientation, description) -3. Click **Create Playlist** - -### 2. Upload Media - -1. Go to **Upload Media** page -2. Select files (images, videos, PDFs, PPTX) -3. Choose media type and duration -4. Select target playlist (optional) -5. Click **Upload** - -**Supported Formats:** -- Images: JPG, PNG, GIF, BMP, WEBP -- Videos: MP4, AVI, MOV, MKV, WEBM -- Documents: PDF, PPT, PPTX - -### 3. Manage Playlists - -1. Open playlist management -2. Drag and drop to reorder content -3. Edit duration for each item -4. Remove unwanted items -5. Changes sync automatically to players - -### 4. Assign to Players - -1. Go to **Player Assignments** -2. Select playlist from dropdown for each player -3. View live preview to verify content - -### 5. Clean Up Media - -1. Navigate to **Admin** → **Manage Leftover Media** -2. Review unused files -3. Delete individual files or bulk delete by type - -## Configuration - -### Environment Variables - -Create a `.env` file: - -```env -FLASK_ENV=production -SECRET_KEY=your-random-secret-key -DATABASE_URL=sqlite:///instance/digiserver.db -``` - -### Upload Settings - -Edit `app/config.py` to adjust: -- Upload folder location -- Maximum file size -- Allowed file extensions - -## Project Structure - -``` -digiserver-v2/ -├── app/ -│ ├── blueprints/ # Route handlers -│ │ ├── admin.py # Admin panel routes -│ │ ├── content.py # Content management -│ │ ├── playlist.py # Playlist operations -│ │ └── players.py # Player management -│ ├── models/ # Database models -│ ├── templates/ # HTML templates -│ ├── static/ # CSS, JS, uploads -│ └── utils/ # Helper functions -├── instance/ # Database storage -├── Dockerfile # Docker configuration -├── docker-compose.yml # Docker Compose config -└── requirements.txt # Python dependencies -``` - -## API Endpoints - -### Player API -- `GET /api/playlist/` - Get player playlist -- `POST /api/players//heartbeat` - Send heartbeat - -### Admin API -- `POST /playlist//update-duration/` - Update content duration -- `POST /playlist//reorder` - Reorder playlist items - -## Troubleshooting - -### PDF Conversion Fails -Ensure poppler-utils is installed: -```bash -sudo apt-get install poppler-utils -``` - -### PPTX Conversion Fails -**Method 1: Via Web UI (Recommended)** -1. Go to Admin Panel → System Dependencies -2. Click "Install LibreOffice" -3. Wait 2-5 minutes for installation - -**Method 2: Manual Install** -```bash -sudo apt-get install libreoffice -# OR use the provided script -sudo ./install_libreoffice.sh -``` - -See [OPTIONAL_DEPENDENCIES.md](OPTIONAL_DEPENDENCIES.md) for details. - -### Upload Fails -Check folder permissions: -```bash -chmod -R 755 app/static/uploads -``` - -### Database Issues -Reset database: -```bash -rm instance/*.db -# Then reinitialize (see Installation) -``` - -## Development - -### Running Tests -```bash -pytest -``` - -### Code Formatting -```bash -black app/ -flake8 app/ -``` - -### Database Migrations -```bash -flask db migrate -m "Description" -flask db upgrade -``` - -## Contributing - -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Test thoroughly -5. Submit a pull request - -## License - -This project is proprietary software. All rights reserved. - -## Documentation - -- [DOCKER.md](DOCKER.md) - Docker deployment guide -- [OPTIONAL_DEPENDENCIES.md](OPTIONAL_DEPENDENCIES.md) - Optional dependency installation -- [PROGRESS.md](PROGRESS.md) - Development progress tracker -- [KIVY_PLAYER_COMPATIBILITY.md](KIVY_PLAYER_COMPATIBILITY.md) - Player integration guide - -## Support - -For issues and questions: -- Check [DOCKER.md](DOCKER.md) for deployment help -- Review [OPTIONAL_DEPENDENCIES.md](OPTIONAL_DEPENDENCIES.md) for LibreOffice setup -- Review troubleshooting section -- Check application logs - -## Version History - -- **v2.1** - Optional LibreOffice installation - - Reduced base Docker image by 56% (~900MB → ~400MB) - - On-demand LibreOffice installation via Admin Panel - - System Dependencies management page - - Enhanced error messages for PPTX without LibreOffice - -- **v2.0** - Complete rewrite with playlist-centric architecture - - PDF to image conversion (300 DPI) - - PPTX slide conversion - - Leftover media management - - Enhanced dark mode - - Duration editing for all content types - ---- - -Built with ❤️ using Flask, SQLAlchemy, and modern web technologies diff --git a/old_code_documentation/add_muted_column.py b/old_code_documentation/add_muted_column.py deleted file mode 100644 index 201671c..0000000 --- a/old_code_documentation/add_muted_column.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env python3 -"""Add muted column to playlist_content table.""" -from app.app import create_app -from app.extensions import db - -def add_muted_column(): - """Add muted column to playlist_content association table.""" - app = create_app() - - with app.app_context(): - try: - # Check if column already exists - result = db.session.execute(db.text("PRAGMA table_info(playlist_content)")).fetchall() - columns = [row[1] for row in result] - - if 'muted' in columns: - print("ℹ️ Column 'muted' already exists in playlist_content table") - return - - # Add muted column with default value True (muted by default) - db.session.execute(db.text(""" - ALTER TABLE playlist_content - ADD COLUMN muted BOOLEAN DEFAULT TRUE - """)) - db.session.commit() - print("✅ Successfully added 'muted' column to playlist_content table") - print(" Default: TRUE (videos will be muted by default)") - except Exception as e: - db.session.rollback() - print(f"❌ Error adding column: {e}") - -if __name__ == '__main__': - add_muted_column() diff --git a/old_code_documentation/blueprint_groups.py b/old_code_documentation/blueprint_groups.py deleted file mode 100644 index e2bb18d..0000000 --- a/old_code_documentation/blueprint_groups.py +++ /dev/null @@ -1,401 +0,0 @@ -"""Groups blueprint for group management and player assignments.""" -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify -from flask_login import login_required -from typing import List, Dict - -from app.extensions import db, cache -from app.models import Group, Player, Content -from app.utils.logger import log_action -from app.utils.group_player_management import get_player_status_info, get_group_statistics - -groups_bp = Blueprint('groups', __name__, url_prefix='/groups') - - -@groups_bp.route('/') -@login_required -def groups_list(): - """Display list of all groups.""" - try: - groups = Group.query.order_by(Group.name).all() - - # Get statistics for each group - group_stats = {} - for group in groups: - stats = get_group_statistics(group.id) - group_stats[group.id] = stats - - return render_template('groups/groups_list.html', - groups=groups, - group_stats=group_stats) - except Exception as e: - log_action('error', f'Error loading groups list: {str(e)}') - flash('Error loading groups list.', 'danger') - return redirect(url_for('main.dashboard')) - - -@groups_bp.route('/create', methods=['GET', 'POST']) -@login_required -def create_group(): - """Create a new group.""" - if request.method == 'GET': - available_content = Content.query.order_by(Content.filename).all() - return render_template('groups/create_group.html', available_content=available_content) - - try: - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() - content_ids = request.form.getlist('content_ids') - - # Validation - if not name or len(name) < 3: - flash('Group name must be at least 3 characters long.', 'warning') - return redirect(url_for('groups.create_group')) - - # Check if group name exists - existing_group = Group.query.filter_by(name=name).first() - if existing_group: - flash(f'Group "{name}" already exists.', 'warning') - return redirect(url_for('groups.create_group')) - - # Create group - new_group = Group( - name=name, - description=description or None - ) - - # Add content to group - if content_ids: - for content_id in content_ids: - content = Content.query.get(int(content_id)) - if content: - new_group.contents.append(content) - - db.session.add(new_group) - db.session.commit() - - log_action('info', f'Group "{name}" created with {len(content_ids)} content items') - flash(f'Group "{name}" created successfully.', 'success') - - return redirect(url_for('groups.groups_list')) - - except Exception as e: - db.session.rollback() - log_action('error', f'Error creating group: {str(e)}') - flash('Error creating group. Please try again.', 'danger') - return redirect(url_for('groups.create_group')) - - -@groups_bp.route('//edit', methods=['GET', 'POST']) -@login_required -def edit_group(group_id: int): - """Edit group details.""" - group = Group.query.get_or_404(group_id) - - if request.method == 'GET': - available_content = Content.query.order_by(Content.filename).all() - return render_template('groups/edit_group.html', - group=group, - available_content=available_content) - - try: - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() - content_ids = request.form.getlist('content_ids') - - # Validation - if not name or len(name) < 3: - flash('Group name must be at least 3 characters long.', 'warning') - return redirect(url_for('groups.edit_group', group_id=group_id)) - - # Check if group name exists (excluding current group) - existing_group = Group.query.filter(Group.name == name, Group.id != group_id).first() - if existing_group: - flash(f'Group name "{name}" is already in use.', 'warning') - return redirect(url_for('groups.edit_group', group_id=group_id)) - - # Update group - group.name = name - group.description = description or None - - # Update content - group.contents = [] - if content_ids: - for content_id in content_ids: - content = Content.query.get(int(content_id)) - if content: - group.contents.append(content) - - db.session.commit() - - # Clear cache for all players in this group - for player in group.players: - cache.delete_memoized('get_player_playlist', player.id) - - log_action('info', f'Group "{name}" (ID: {group_id}) updated') - flash(f'Group "{name}" updated successfully.', 'success') - - return redirect(url_for('groups.groups_list')) - - except Exception as e: - db.session.rollback() - log_action('error', f'Error updating group: {str(e)}') - flash('Error updating group. Please try again.', 'danger') - return redirect(url_for('groups.edit_group', group_id=group_id)) - - -@groups_bp.route('//delete', methods=['POST']) -@login_required -def delete_group(group_id: int): - """Delete a group.""" - try: - group = Group.query.get_or_404(group_id) - group_name = group.name - - # Unassign players from group - for player in group.players: - player.group_id = None - cache.delete_memoized('get_player_playlist', player.id) - - db.session.delete(group) - db.session.commit() - - log_action('info', f'Group "{group_name}" (ID: {group_id}) deleted') - flash(f'Group "{group_name}" deleted successfully.', 'success') - - except Exception as e: - db.session.rollback() - log_action('error', f'Error deleting group: {str(e)}') - flash('Error deleting group. Please try again.', 'danger') - - return redirect(url_for('groups.groups_list')) - - -@groups_bp.route('//manage') -@login_required -def manage_group(group_id: int): - """Manage group with player status cards and content.""" - try: - group = Group.query.get_or_404(group_id) - - # Get all players in this group - players = group.players.order_by(Player.name).all() - - # Get player status for each player - player_statuses = {} - for player in players: - status_info = get_player_status_info(player.id) - player_statuses[player.id] = status_info - - # Get group content - contents = group.contents.order_by(Content.position).all() - - # Get available players (not in this group) - available_players = Player.query.filter( - (Player.group_id == None) | (Player.group_id != group_id) - ).order_by(Player.name).all() - - # Get available content (not in this group) - all_content = Content.query.order_by(Content.filename).all() - - return render_template('groups/manage_group.html', - group=group, - players=players, - player_statuses=player_statuses, - contents=contents, - available_players=available_players, - all_content=all_content) - except Exception as e: - log_action('error', f'Error loading manage group page: {str(e)}') - flash('Error loading manage group page.', 'danger') - return redirect(url_for('groups.groups_list')) - - -@groups_bp.route('//fullscreen') -def group_fullscreen(group_id: int): - """Display group fullscreen view with all player status cards.""" - try: - group = Group.query.get_or_404(group_id) - - # Get all players in this group - players = group.players.order_by(Player.name).all() - - # Get player status for each player - player_statuses = {} - for player in players: - status_info = get_player_status_info(player.id) - player_statuses[player.id] = status_info - - return render_template('groups/group_fullscreen.html', - group=group, - players=players, - player_statuses=player_statuses) - except Exception as e: - log_action('error', f'Error loading group fullscreen: {str(e)}') - return "Error loading group fullscreen", 500 - - -@groups_bp.route('//add-player', methods=['POST']) -@login_required -def add_player_to_group(group_id: int): - """Add a player to a group.""" - try: - group = Group.query.get_or_404(group_id) - player_id = request.form.get('player_id') - - if not player_id: - flash('No player selected.', 'warning') - return redirect(url_for('groups.manage_group', group_id=group_id)) - - player = Player.query.get_or_404(int(player_id)) - player.group_id = group_id - db.session.commit() - - # Clear cache - cache.delete_memoized('get_player_playlist', player.id) - - log_action('info', f'Player "{player.name}" added to group "{group.name}"') - flash(f'Player "{player.name}" added to group successfully.', 'success') - - except Exception as e: - db.session.rollback() - log_action('error', f'Error adding player to group: {str(e)}') - flash('Error adding player to group. Please try again.', 'danger') - - return redirect(url_for('groups.manage_group', group_id=group_id)) - - -@groups_bp.route('//remove-player/', methods=['POST']) -@login_required -def remove_player_from_group(group_id: int, player_id: int): - """Remove a player from a group.""" - try: - player = Player.query.get_or_404(player_id) - - if player.group_id != group_id: - flash('Player is not in this group.', 'warning') - return redirect(url_for('groups.manage_group', group_id=group_id)) - - player_name = player.name - player.group_id = None - db.session.commit() - - # Clear cache - cache.delete_memoized('get_player_playlist', player_id) - - log_action('info', f'Player "{player_name}" removed from group {group_id}') - flash(f'Player "{player_name}" removed from group successfully.', 'success') - - except Exception as e: - db.session.rollback() - log_action('error', f'Error removing player from group: {str(e)}') - flash('Error removing player from group. Please try again.', 'danger') - - return redirect(url_for('groups.manage_group', group_id=group_id)) - - -@groups_bp.route('//add-content', methods=['POST']) -@login_required -def add_content_to_group(group_id: int): - """Add content to a group.""" - try: - group = Group.query.get_or_404(group_id) - content_ids = request.form.getlist('content_ids') - - if not content_ids: - flash('No content selected.', 'warning') - return redirect(url_for('groups.manage_group', group_id=group_id)) - - # Add content - added_count = 0 - for content_id in content_ids: - content = Content.query.get(int(content_id)) - if content and content not in group.contents: - group.contents.append(content) - added_count += 1 - - db.session.commit() - - # Clear cache for all players in this group - for player in group.players: - cache.delete_memoized('get_player_playlist', player.id) - - log_action('info', f'{added_count} content items added to group "{group.name}"') - flash(f'{added_count} content items added successfully.', 'success') - - except Exception as e: - db.session.rollback() - log_action('error', f'Error adding content to group: {str(e)}') - flash('Error adding content to group. Please try again.', 'danger') - - return redirect(url_for('groups.manage_group', group_id=group_id)) - - -@groups_bp.route('//remove-content/', methods=['POST']) -@login_required -def remove_content_from_group(group_id: int, content_id: int): - """Remove content from a group.""" - try: - group = Group.query.get_or_404(group_id) - content = Content.query.get_or_404(content_id) - - if content not in group.contents: - flash('Content is not in this group.', 'warning') - return redirect(url_for('groups.manage_group', group_id=group_id)) - - group.contents.remove(content) - db.session.commit() - - # Clear cache for all players in this group - for player in group.players: - cache.delete_memoized('get_player_playlist', player.id) - - log_action('info', f'Content "{content.filename}" removed from group "{group.name}"') - flash('Content removed from group successfully.', 'success') - - except Exception as e: - db.session.rollback() - log_action('error', f'Error removing content from group: {str(e)}') - flash('Error removing content from group. Please try again.', 'danger') - - return redirect(url_for('groups.manage_group', group_id=group_id)) - - -@groups_bp.route('//reorder-content', methods=['POST']) -@login_required -def reorder_group_content(group_id: int): - """Reorder content within a group.""" - try: - group = Group.query.get_or_404(group_id) - content_order = request.json.get('order', []) - - # Update positions - for idx, content_id in enumerate(content_order): - content = Content.query.get(content_id) - if content and content in group.contents: - content.position = idx - - db.session.commit() - - # Clear cache for all players in this group - for player in group.players: - cache.delete_memoized('get_player_playlist', player.id) - - log_action('info', f'Content reordered for group "{group.name}"') - return jsonify({'success': True}) - - except Exception as e: - db.session.rollback() - log_action('error', f'Error reordering group content: {str(e)}') - return jsonify({'success': False, 'error': str(e)}), 500 - - -@groups_bp.route('//stats') -@login_required -def group_stats(group_id: int): - """Get group statistics as JSON.""" - try: - stats = get_group_statistics(group_id) - return jsonify(stats) - except Exception as e: - log_action('error', f'Error getting group stats: {str(e)}') - return jsonify({'error': str(e)}), 500 diff --git a/old_code_documentation/check_fix_player.py b/old_code_documentation/check_fix_player.py deleted file mode 100644 index 02beb63..0000000 --- a/old_code_documentation/check_fix_player.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 -"""Check and fix player quickconnect code.""" - -from app import create_app -from app.models import Player -from app.extensions import db - -app = create_app() - -with app.app_context(): - # Find player by hostname - player = Player.query.filter_by(hostname='tv-terasa').first() - - if not player: - print("❌ Player 'tv-terasa' NOT FOUND in database!") - print("\nAll registered players:") - all_players = Player.query.all() - for p in all_players: - print(f" - ID={p.id}, Name='{p.name}', Hostname='{p.hostname}'") - else: - print(f"✅ Player found:") - print(f" ID: {player.id}") - print(f" Name: {player.name}") - print(f" Hostname: {player.hostname}") - print(f" Playlist ID: {player.playlist_id}") - print(f" Status: {player.status}") - print(f" QuickConnect Hash: {player.quickconnect_code[:60] if player.quickconnect_code else 'Not set'}...") - - # Test the quickconnect code - test_code = "8887779" - print(f"\n🔐 Testing quickconnect code: '{test_code}'") - - if player.check_quickconnect_code(test_code): - print(f"✅ Code '{test_code}' is VALID!") - else: - print(f"❌ Code '{test_code}' is INVALID - Hash doesn't match!") - - # Update it - print(f"\n🔧 Updating quickconnect code to: '{test_code}'") - player.set_quickconnect_code(test_code) - db.session.commit() - print("✅ QuickConnect code updated successfully!") - print(f" New hash: {player.quickconnect_code[:60]}...") - - # Verify the update - if player.check_quickconnect_code(test_code): - print(f"✅ Verification successful - code '{test_code}' now works!") - else: - print(f"❌ Verification failed - something went wrong!") diff --git a/old_code_documentation/clean_for_deployment.sh b/old_code_documentation/clean_for_deployment.sh deleted file mode 100755 index e02f0b8..0000000 --- a/old_code_documentation/clean_for_deployment.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/bin/bash -# Clean development data before Docker deployment -# This script removes all development data to ensure a fresh start - -set -e - -# Get the root directory of the application -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -echo "🧹 Cleaning DigiServer v2 for deployment..." -echo "📍 App root: $APP_ROOT" -echo "" - -# Confirm action -read -p "This will delete ALL data (database, uploads, logs). Continue? (y/N): " -n 1 -r -echo -if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "❌ Cancelled" - exit 1 -fi - -echo "" -echo "📦 Cleaning development data..." - -# Change to app root directory -cd "$APP_ROOT" - -# Remove database files -if [ -d "instance" ]; then - echo " 🗄️ Removing database files..." - rm -rf instance/*.db - rm -rf instance/*.db-* - echo " ✅ Database cleaned" -else - echo " ℹ️ No instance directory found" -fi - -# Remove uploaded media -if [ -d "app/static/uploads" ]; then - echo " 📁 Removing uploaded media files..." - find app/static/uploads -type f -not -name '.gitkeep' -delete 2>/dev/null || true - find app/static/uploads -type d -empty -not -path "app/static/uploads" -delete 2>/dev/null || true - echo " ✅ Uploads cleaned" -else - echo " ℹ️ No uploads directory found" -fi - -# Remove additional upload directory if exists -if [ -d "static/uploads" ]; then - echo " 📁 Removing static uploads..." - find static/uploads -type f -not -name '.gitkeep' -delete 2>/dev/null || true - find static/uploads -type d -empty -not -path "static/uploads" -delete 2>/dev/null || true - echo " ✅ Static uploads cleaned" -fi - -# Remove log files -echo " 📝 Removing log files..." -find . -name "*.log" -type f -delete 2>/dev/null || true -echo " ✅ Logs cleaned" - -# Remove Python cache -echo " 🐍 Removing Python cache..." -find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true -find . -type f -name "*.pyc" -delete 2>/dev/null || true -find . -type f -name "*.pyo" -delete 2>/dev/null || true -echo " ✅ Python cache cleaned" - -# Remove Flask session files if any -if [ -d "flask_session" ]; then - echo " 🔐 Removing session files..." - rm -rf flask_session - echo " ✅ Sessions cleaned" -fi - -# Summary -echo "" -echo "✨ Cleanup complete!" -echo "" -echo "📊 Summary:" -echo " - Database: Removed" -echo " - Uploaded media: Removed" -echo " - Logs: Removed" -echo " - Python cache: Removed" -echo "" -echo "🚀 Ready for deployment!" -echo "" -echo "Next steps:" -echo " 1. Build Docker image: docker compose build" -echo " 2. Start container: docker compose up -d" -echo " 3. Access at: http://localhost:80" -echo " 4. Login with: admin / admin123" -echo "" diff --git a/old_code_documentation/deploy_tips/DEPLOYMENT_READINESS_SUMMARY.md b/old_code_documentation/deploy_tips/DEPLOYMENT_READINESS_SUMMARY.md deleted file mode 100644 index 571425a..0000000 --- a/old_code_documentation/deploy_tips/DEPLOYMENT_READINESS_SUMMARY.md +++ /dev/null @@ -1,326 +0,0 @@ -# 🚀 Production Deployment Readiness Summary - -**Generated**: 2026-01-16 20:30 UTC -**Status**: ✅ **READY FOR PRODUCTION** - ---- - -## 📊 Deployment Status Overview - -``` -┌─────────────────────────────────────────────────────────────┐ -│ DEPLOYMENT READINESS MATRIX │ -├─────────────────────────────────────────────────────────────┤ -│ ✅ Code Management → Git committed │ -│ ✅ Dependencies → 48 packages, latest versions │ -│ ✅ Database → SQLAlchemy + 4 migrations │ -│ ✅ SSL/HTTPS → Valid cert (2027-01-16) │ -│ ✅ Docker → Configured with health checks │ -│ ✅ Security → HTTPS forced, CORS enabled │ -│ ✅ Application → Containers healthy & running │ -│ ✅ API Endpoints → Responding with CORS headers │ -│ ⚠️ Environment Vars → Need production values set │ -│ ⚠️ Secrets → Use os.getenv() defaults only │ -└─────────────────────────────────────────────────────────────┘ - -OVERALL READINESS: 95% ✅ -RECOMMENDATION: Ready for immediate production deployment -``` - ---- - -## ✅ Verified Working Systems - -### 1. **Application Framework** ✅ -- **Flask**: 3.1.0 (latest stable) -- **Configuration**: Production class properly defined -- **Blueprints**: All modules registered -- **Status**: Healthy and responding - -### 2. **HTTPS/TLS** ✅ -``` -Certificate Status: - Path: data/nginx-ssl/cert.pem - Issuer: Self-signed - Valid From: 2026-01-16 19:10:44 GMT - Expires: 2027-01-16 19:10:44 GMT - Days Remaining: 365 days - TLS Versions: 1.2, 1.3 - Status: ✅ Valid and operational -``` - -### 3. **CORS Configuration** ✅ -``` -Verified Headers Present: - ✅ access-control-allow-origin: * - ✅ access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS - ✅ access-control-allow-headers: Content-Type, Authorization - ✅ access-control-max-age: 3600 - -Tested Endpoints: - ✅ GET /api/health → Returns 200 with CORS headers - ✅ GET /api/playlists → Returns 400 with CORS headers - ✅ OPTIONS /api/* → Preflight handling working -``` - -### 4. **Docker Setup** ✅ -``` -Containers Running: - ✅ digiserver-app Status: Up 22 minutes (healthy) - ✅ digiserver-nginx Status: Up 23 minutes (healthy) - -Image Configuration: - ✅ Python 3.13-slim base image - ✅ Non-root user (appuser:1000) - ✅ Health checks configured - ✅ Proper restart policies - ✅ Volume mounts for persistence -``` - -### 5. **Database** ✅ -``` -Schema Management: - ✅ SQLAlchemy 2.0.37 configured - ✅ 4 migration files present - ✅ Flask-Migrate integration working - ✅ Database: SQLite (data/instance/dashboard.db) -``` - -### 6. **Security** ✅ -``` -Implemented Security Measures: - ✅ HTTPS-only (forced redirect in nginx) - ✅ SESSION_COOKIE_SECURE = True - ✅ SESSION_COOKIE_HTTPONLY = True - ✅ SESSION_COOKIE_SAMESITE = 'Lax' - ✅ X-Frame-Options: SAMEORIGIN - ✅ X-Content-Type-Options: nosniff - ✅ Content-Security-Policy configured - ✅ Non-root container user - ✅ No debug mode in production -``` - -### 7. **Dependencies** ✅ -``` -Critical Packages (All Latest): - ✅ Flask==3.1.0 - ✅ Flask-SQLAlchemy==3.1.1 - ✅ Flask-Cors==4.0.0 - ✅ gunicorn==23.0.0 - ✅ Flask-Bcrypt==1.0.1 - ✅ Flask-Login==0.6.3 - ✅ Flask-Migrate==4.0.5 - ✅ cryptography==42.0.7 - ✅ Werkzeug==3.0.1 - ✅ SQLAlchemy==2.0.37 - ✅ click==8.1.7 - ✅ Jinja2==3.1.2 - -Total Packages: 48 -Vulnerability Scan: All packages at latest stable versions -``` - ---- - -## 📋 Git Commit Status - -``` -Latest Commit: - Hash: c4e43ce - Message: HTTPS/CORS improvements: Enable CORS for player connections, - secure session cookies, add certificate endpoint, nginx CORS headers - Files Changed: 15 (with new documentation) - Status: ✅ All changes committed -``` - ---- - -## ⚠️ Pre-Deployment Checklist - -### Must Complete Before Deployment: - -- [ ] **Set Environment Variables** - ```bash - export SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')" - export ADMIN_USERNAME="admin" - export ADMIN_PASSWORD="" - export ADMIN_EMAIL="admin@company.com" - export DOMAIN="your-domain.com" - ``` - -- [ ] **Choose SSL Strategy** - - Option A: Keep self-signed cert (works for internal networks) - - Option B: Generate Let's Encrypt cert (recommended for public) - - Option C: Use commercial certificate - -- [ ] **Create .env File** (Optional but recommended) - ```bash - cp .env.example .env - # Edit .env with your production values - ``` - -- [ ] **Update docker-compose.yml Environment** (if not using .env) - - Update SECRET_KEY - - Update ADMIN_PASSWORD - - Update DOMAIN - -- [ ] **Test Before Going Live** - ```bash - docker-compose down - docker-compose up -d - # Wait 30 seconds for startup - curl -k https://your-server/api/health - ``` - -### Recommended But Not Critical: - -- [ ] Set up database backups -- [ ] Configure SSL certificate auto-renewal (if using Let's Encrypt) -- [ ] Set up log aggregation/monitoring -- [ ] Configure firewall rules (allow only 80, 443) -- [ ] Plan disaster recovery procedures - ---- - -## 🎯 Quick Deployment Guide - -### 1. Prepare Environment -```bash -cd /opt/digiserver-v2 - -# Create environment file -cat > .env << 'EOF' -SECRET_KEY= -ADMIN_USERNAME=admin -ADMIN_PASSWORD= -ADMIN_EMAIL=admin@company.com -DOMAIN=your-domain.com -EMAIL=admin@company.com -EOF - -chmod 600 .env -``` - -### 2. Build and Deploy -```bash -# Build images -docker-compose build - -# Start services -docker-compose up -d - -# Initialize database (first time only) -docker-compose exec digiserver-app flask db upgrade - -# Verify deployment -curl -k https://your-server/api/health -``` - -### 3. Verify Operation -```bash -# Check logs -docker-compose logs -f digiserver-app - -# Health check -curl -k https://your-server/api/health - -# CORS headers -curl -i -k https://your-server/api/playlists - -# Admin panel -open https://your-server/admin -``` - ---- - -## 📊 Performance Specifications - -``` -Expected Capacity: - Concurrent Connections: ~100+ (configurable via gunicorn workers) - Request Timeout: 30 seconds - Session Duration: Browser session - Database: SQLite (sufficient for <50 players) - -For Production at Scale (100+ players): - ⚠️ Recommend upgrading to PostgreSQL - ⚠️ Recommend load balancer with multiple app instances - ⚠️ Recommend Redis caching layer -``` - ---- - -## 🔍 Monitoring & Maintenance - -### Health Checks -```bash -# Application health -curl -k https://your-server/api/health - -# Response should be: -# {"status":"healthy","timestamp":"...","version":"2.0.0"} -``` - -### Logs Location -``` -Container Logs: docker-compose logs -f digiserver-app -Nginx Logs: docker-compose logs -f digiserver-nginx -Database: data/instance/dashboard.db -Uploads: data/uploads/ -``` - -### Backup Strategy -```bash -# Daily backup -docker-compose exec digiserver-app \ - cp instance/dashboard.db /backup/dashboard.db.$(date +%Y%m%d) - -# Backup schedule (add to crontab) -0 2 * * * /opt/digiserver-v2/backup.sh -``` - ---- - -## ✅ Sign-Off - -| Component | Status | Tested | Notes | -|-----------|--------|--------|-------| -| Code | ✅ Ready | ✅ Yes | Committed to Git | -| Docker | ✅ Ready | ✅ Yes | Containers healthy | -| HTTPS | ✅ Ready | ✅ Yes | TLS 1.3 verified | -| CORS | ✅ Ready | ✅ Yes | All endpoints responding | -| Database | ✅ Ready | ✅ Yes | Migrations present | -| Security | ✅ Ready | ✅ Yes | All hardening applied | -| API | ✅ Ready | ✅ Yes | Health check passing | - ---- - -## 🚀 Final Recommendation - -``` -╔═════════════════════════════════════════════════╗ -║ DEPLOYMENT APPROVED FOR PRODUCTION ║ -║ All critical systems verified working ║ -║ Readiness: 95% (only env vars need setting) ║ -║ Risk Level: LOW ║ -║ Estimated Deployment Time: 30 minutes ║ -╚═════════════════════════════════════════════════╝ - -NEXT STEPS: -1. Set production environment variables -2. Review and customize .env.example → .env -3. Execute docker-compose up -d -4. Run health checks -5. Monitor logs for 24 hours - -SUPPORT: -- Documentation: See PRODUCTION_DEPLOYMENT_GUIDE.md -- Troubleshooting: See old_code_documentation/ -- Health Verification: Run ./verify-deployment.sh -``` - ---- - -**Generated by**: Production Deployment Verification System -**Last Updated**: 2026-01-16 20:30:00 UTC -**Validity**: 24 hours (re-run verification before major changes) diff --git a/old_code_documentation/deploy_tips/DEPLOYMENT_STEPS_QUICK.md b/old_code_documentation/deploy_tips/DEPLOYMENT_STEPS_QUICK.md deleted file mode 100644 index 47a45bb..0000000 --- a/old_code_documentation/deploy_tips/DEPLOYMENT_STEPS_QUICK.md +++ /dev/null @@ -1,215 +0,0 @@ -# 🚀 Deployment Steps - Quick Reference - -**Total Time**: ~10 minutes | **Risk Level**: LOW | **Difficulty**: Easy - ---- - -## ⏸️ Phase 1: Pre-Deployment (Before you start) - -### Step 1: Identify Target IP -Determine what IP your host will have **after** restart: -```bash -TARGET_IP=192.168.0.121 # Example: your static production IP -``` - -### Step 2: Generate SECRET_KEY -```bash -python -c "import secrets; print(secrets.token_urlsafe(32))" -# Copy output - you'll need this -``` - -### Step 3: Create .env File -```bash -cp .env.example .env -``` - -### Step 4: Configure .env -```bash -nano .env -``` - -Edit these values in `.env`: -``` -SECRET_KEY= -ADMIN_PASSWORD= -HOST_IP=192.168.0.121 -DOMAIN=digiserver.local -TRUSTED_PROXIES=192.168.0.0/24 -``` - ---- - -## 🔨 Phase 2: Build & Start (Still on current network) - -### Step 5: Build Docker Images -```bash -docker-compose build -``` - -### Step 6: Start Containers -```bash -docker-compose up -d -``` - -### Step 7: Initialize Database -```bash -docker-compose exec digiserver-app flask db upgrade -``` - -### Step 8: Wait for Startup -```bash -# Wait ~30 seconds for containers to be healthy -sleep 30 - -# Verify containers are healthy -docker-compose ps -# Look for "healthy" status on both containers -``` - ---- - -## 🌐 Phase 3: Move Host to Target Network - -### Step 9: Network Configuration -- Physically disconnect host from current network -- Connect to production network (e.g., 192.168.0.0/24) -- Host will receive/retain static IP (192.168.0.121) - ---- - -## ✅ Phase 4: Verification - -### Step 10: Test Health Endpoint -```bash -curl -k https://192.168.0.121/api/health - -# Expected response: -# {"status":"healthy","timestamp":"...","version":"2.0.0"} -``` - -### Step 11: Check Logs -```bash -docker-compose logs --tail=50 digiserver-app - -# Look for any ERROR messages -# Should see Flask running on port 5000 -``` - -### Step 12: Test API with CORS -```bash -curl -i -k https://192.168.0.121/api/playlists - -# Verify CORS headers present: -# access-control-allow-origin: * -# access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS -``` - ---- - -## 📋 Command Cheat Sheet - -```bash -# Create environment -cp .env.example .env && nano .env - -# Build and start -docker-compose build -docker-compose up -d - -# Initialize database -docker-compose exec digiserver-app flask db upgrade - -# Check status -docker-compose ps - -# View logs -docker-compose logs -f digiserver-app - -# Health check -curl -k https://192.168.0.121/api/health - -# Stop services -docker-compose down - -# Restart services -docker-compose restart -``` - ---- - -## ⏱️ Timing Breakdown - -| Phase | Duration | Notes | -|-------|----------|-------| -| Pre-deployment setup | 5 min | Configure .env | -| Docker build | 2-3 min | First time only | -| Containers start | 30 sec | Automatic | -| Database init | 10 sec | Flask migrations | -| Network move | Instant | Plug/Unplug | -| Verification | 2 min | Health checks | -| **Total** | **~10 min** | Ready to go | - ---- - -## ✨ Post-Deployment - -Once verified working: - -- **Backup .env** (contains secrets) - ```bash - cp .env /backup/.env.backup - chmod 600 /backup/.env.backup - ``` - -- **Enable backups** (optional) - ```bash - # Add to crontab for daily backups - 0 2 * * * docker-compose exec digiserver-app \ - cp instance/dashboard.db /backup/db.$(date +\%Y\%m\%d) - ``` - -- **Monitor logs** (first 24 hours) - ```bash - docker-compose logs -f digiserver-app - ``` - ---- - -## 🆘 Troubleshooting Quick Fixes - -| Issue | Fix | -|-------|-----| -| Build fails | Run: `docker-compose build --no-cache` | -| Port already in use | Run: `docker-compose down` first | -| Container won't start | Check logs: `docker-compose logs digiserver-app` | -| Health check fails | Wait 30 sec longer, networks take time | -| Can't reach API | Verify host IP: `ip addr \| grep 192.168` | -| Certificate error | Curl with `-k` flag (self-signed cert) | - ---- - -## 🎯 Success Criteria - -✅ All steps completed when: - -- [ ] `docker-compose ps` shows both containers "Up" and "healthy" -- [ ] `curl -k https://192.168.0.121/api/health` returns 200 -- [ ] CORS headers present in API responses -- [ ] No ERROR messages in logs -- [ ] Admin panel accessible at https://192.168.0.121/admin - ---- - -## 📞 Need Help? - -See detailed guides: -- **General deployment**: [MASTER_DEPLOYMENT_PLAN.md](MASTER_DEPLOYMENT_PLAN.md) -- **IP configuration**: [PRE_DEPLOYMENT_IP_CONFIGURATION.md](PRE_DEPLOYMENT_IP_CONFIGURATION.md) -- **All commands**: [deployment-commands-reference.sh](deployment-commands-reference.sh) -- **Verify setup**: [verify-deployment.sh](verify-deployment.sh) - ---- - -**Status**: Ready to deploy -**Last Updated**: 2026-01-16 -**Deployment Type**: Network transition (deploy on one network, run on another) diff --git a/old_code_documentation/deploy_tips/DOCUMENTATION_INDEX.md b/old_code_documentation/deploy_tips/DOCUMENTATION_INDEX.md deleted file mode 100644 index 85fde9e..0000000 --- a/old_code_documentation/deploy_tips/DOCUMENTATION_INDEX.md +++ /dev/null @@ -1,301 +0,0 @@ -# DigiServer v2 - Complete Documentation Index - -## 🎯 Quick Links - -### **For Immediate Deployment** 👈 START HERE -- **[DEPLOYMENT_STEPS_QUICK.md](DEPLOYMENT_STEPS_QUICK.md)** - ⭐ **QUICKEST** - 4 phases, 12 steps, ~10 min -- **[MASTER_DEPLOYMENT_PLAN.md](MASTER_DEPLOYMENT_PLAN.md)** - Complete 5-minute deployment guide -- **[.env.example](.env.example)** - Environment configuration template -- **[DEPLOYMENT_READINESS_SUMMARY.md](DEPLOYMENT_READINESS_SUMMARY.md)** - Current status verification -- **[PRE_DEPLOYMENT_IP_CONFIGURATION.md](PRE_DEPLOYMENT_IP_CONFIGURATION.md)** - For network transitions - -### **Detailed Reference** -- **[PRODUCTION_DEPLOYMENT_GUIDE.md](PRODUCTION_DEPLOYMENT_GUIDE.md)** - Full deployment procedures -- **[deployment-commands-reference.sh](deployment-commands-reference.sh)** - Command reference -- **[verify-deployment.sh](verify-deployment.sh)** - Automated verification - ---- - -## 📚 Full Documentation Structure - -### **Deployment Documentation (New)** -``` -Project Root (/srv/digiserver-v2/) -├── ⭐ DEPLOYMENT_STEPS_QUICK.md ← START HERE (QUICKEST) -├── 🚀 MASTER_DEPLOYMENT_PLAN.md ← START HERE (Detailed) -├── 📋 PRODUCTION_DEPLOYMENT_GUIDE.md -├── ✅ DEPLOYMENT_READINESS_SUMMARY.md -├── ⭐ PRE_DEPLOYMENT_IP_CONFIGURATION.md ← For network transitions -├── 🔧 .env.example -├── 📖 deployment-commands-reference.sh -└── ✔️ verify-deployment.sh - -HTTPS/CORS Implementation Documentation -├── old_code_documentation/ -│ ├── PLAYER_HTTPS_CONNECTION_ANALYSIS.md -│ ├── PLAYER_HTTPS_CONNECTION_FIXES.md -│ ├── PLAYER_HTTPS_INTEGRATION_GUIDE.md -│ └── player_analisis/ -│ ├── KIWY_PLAYER_ANALYSIS_INDEX.md -│ ├── KIWY_PLAYER_HTTPS_ANALYSIS.md -│ └── ...more KIWY player documentation -``` - -### **Configuration Files** -``` -Docker & Deployment -├── docker-compose.yml ← Container orchestration -├── Dockerfile ← Container image -├── docker-entrypoint.sh ← Container startup -├── nginx.conf ← Reverse proxy config -└── requirements.txt ← Python dependencies - -Application -├── app/ -│ ├── app.py ← CORS initialization -│ ├── config.py ← Environment config -│ ├── extensions.py ← Flask extensions -│ ├── blueprints/ -│ │ ├── api.py ← API endpoints + certificate -│ │ ├── auth.py ← Authentication -│ │ ├── admin.py ← Admin panel -│ │ └── ...other blueprints -│ └── models/ -│ ├── player.py -│ ├── user.py -│ └── ...other models - -Database -├── migrations/ -│ ├── add_player_user_table.py -│ ├── add_https_config_table.py -│ └── ...other migrations -└── data/ - ├── instance/ ← SQLite database - ├── nginx-ssl/ ← SSL certificates - └── uploads/ ← User uploads -``` - ---- - -## ✅ Current System Status - -### **Verified Working** ✅ -- ✅ Application running on Flask 3.1.0 -- ✅ Docker containers healthy and operational -- ✅ HTTPS/TLS 1.2 & 1.3 enabled -- ✅ CORS headers on all API endpoints -- ✅ Database migrations configured -- ✅ Security hardening applied -- ✅ All code committed to Git - -### **Configuration** ⏳ -- ⏳ Environment variables need production values -- ⏳ SSL certificate strategy to be selected -- ⏳ Admin credentials to be set - ---- - -## 🚀 Quick Start Command - -```bash -# 1. Generate SECRET_KEY -python -c "import secrets; print(secrets.token_urlsafe(32))" - -# 2. Create .env file -cp .env.example .env -# Edit .env with your production values - -# 3. Deploy -docker-compose build -docker-compose up -d -docker-compose exec digiserver-app flask db upgrade - -# 4. Verify -curl -k https://your-domain/api/health -``` - ---- - -## 📊 Documentation Purpose Reference - -| Document | Purpose | Audience | Read Time | -|----------|---------|----------|-----------| -| **MASTER_DEPLOYMENT_PLAN.md** | Complete deployment overview | DevOps/Admins | 10 min | -| **PRODUCTION_DEPLOYMENT_GUIDE.md** | Detailed step-by-step guide | DevOps/Admins | 20 min | -| **DEPLOYMENT_READINESS_SUMMARY.md** | System status verification | Everyone | 5 min | -| **deployment-commands-reference.sh** | Quick command lookup | DevOps | 2 min | -| **verify-deployment.sh** | Automated system checks | DevOps | 5 min | -| **.env.example** | Environment template | DevOps/Admins | 2 min | -| **PLAYER_HTTPS_INTEGRATION_GUIDE.md** | Player device setup | Developers | 15 min | -| **PLAYER_HTTPS_CONNECTION_FIXES.md** | Technical fix details | Developers | 10 min | - ---- - -## 🎯 Common Tasks - -### Deploy to Production -```bash -# See: MASTER_DEPLOYMENT_PLAN.md → Five-Minute Deployment -cat MASTER_DEPLOYMENT_PLAN.md -``` - -### Check System Status -```bash -# See: DEPLOYMENT_READINESS_SUMMARY.md -cat DEPLOYMENT_READINESS_SUMMARY.md -``` - -### View All Commands -```bash -bash deployment-commands-reference.sh -``` - -### Verify Deployment -```bash -bash verify-deployment.sh -``` - -### Check Current Health -```bash -docker-compose ps -curl -k https://192.168.0.121/api/health -``` - -### View Logs -```bash -docker-compose logs -f digiserver-app -``` - ---- - -## 📞 Support Resources - -### **For Deployment Issues** -1. Check [MASTER_DEPLOYMENT_PLAN.md](MASTER_DEPLOYMENT_PLAN.md) troubleshooting section -2. Run `bash verify-deployment.sh` for automated checks -3. Review container logs: `docker-compose logs -f` - -### **For HTTPS/CORS Issues** -1. See [PLAYER_HTTPS_CONNECTION_FIXES.md](old_code_documentation/player_analisis/PLAYER_HTTPS_CONNECTION_FIXES.md) -2. Review [PLAYER_HTTPS_INTEGRATION_GUIDE.md](old_code_documentation/player_analisis/PLAYER_HTTPS_INTEGRATION_GUIDE.md) -3. Check nginx config: `cat nginx.conf | grep -A 10 -B 10 "access-control"` - -### **For Database Issues** -1. Check migration status: `docker-compose exec digiserver-app flask db current` -2. View migrations: `ls -la migrations/` -3. Backup before changes: `docker-compose exec digiserver-app cp instance/dashboard.db /backup/` - ---- - -## 🔐 Security Checklist - -Before production deployment, ensure: - -- [ ] SECRET_KEY set to strong random value -- [ ] ADMIN_PASSWORD set to strong password -- [ ] DOMAIN configured (or using IP) -- [ ] SSL certificate strategy decided -- [ ] Firewall allows only 80 and 443 -- [ ] Database backups configured -- [ ] Monitoring/logging configured -- [ ] Emergency procedures documented - -See [PRODUCTION_DEPLOYMENT_GUIDE.md](PRODUCTION_DEPLOYMENT_GUIDE.md) for detailed security recommendations. - ---- - -## 📈 Performance & Scaling - -### Current Capacity -- **Concurrent Connections**: ~100+ -- **Players Supported**: 50+ (SQLite limit) -- **Request Timeout**: 30 seconds -- **Storage**: Local filesystem - -### For Production Scale (100+ players) -See [PRODUCTION_DEPLOYMENT_GUIDE.md](PRODUCTION_DEPLOYMENT_GUIDE.md) → Performance Tuning section - ---- - -## 🔄 Git Commit History - -Recent deployment-related commits: -``` -0e242eb - Production deployment documentation -c4e43ce - HTTPS/CORS improvements -cf44843 - Nginx reverse proxy and deployment improvements -``` - -View full history: -```bash -git log --oneline | head -10 -``` - ---- - -## 📅 Version Information - -- **DigiServer**: v2.0.0 -- **Flask**: 3.1.0 -- **Python**: 3.13-slim -- **Docker**: Latest -- **SSL Certificate Valid Until**: 2027-01-16 - ---- - -## 🎓 Learning Resources - -### **Understanding the Architecture** -1. Read [MASTER_DEPLOYMENT_PLAN.md](MASTER_DEPLOYMENT_PLAN.md) architecture section -2. Review [docker-compose.yml](docker-compose.yml) configuration -3. Examine [app/config.py](app/config.py) for environment settings - -### **Understanding HTTPS/CORS** -1. See [PLAYER_HTTPS_CONNECTION_ANALYSIS.md](old_code_documentation/player_analisis/PLAYER_HTTPS_CONNECTION_ANALYSIS.md) -2. Review [nginx.conf](nginx.conf) CORS section -3. Check [app/app.py](app/app.py) CORS initialization - -### **Understanding Database** -1. Review [migrations/](migrations/) directory -2. See [app/models/](app/models/) for schema -3. Check [app/config.py](app/config.py) database config - ---- - -## 📝 Change Log - -### Latest Changes (Deployment Session) -- Added comprehensive deployment documentation -- Created environment configuration template -- Implemented automated verification script -- Added deployment command reference -- Updated HTTPS/CORS implementation -- All changes committed to Git - -### Previous Sessions -- Added CORS support for API endpoints -- Implemented secure session cookies -- Enhanced nginx with CORS headers -- Added certificate endpoint -- Configured self-signed SSL certificates - ---- - -## ✅ Deployment Approval - -``` -╔════════════════════════════════════════════════════╗ -║ APPROVED FOR PRODUCTION DEPLOYMENT ║ -║ Status: 95% Ready ║ -║ All systems tested and verified ║ -║ See: MASTER_DEPLOYMENT_PLAN.md to begin ║ -╚════════════════════════════════════════════════════╝ -``` - ---- - -**Generated**: 2026-01-16 20:30 UTC -**Last Updated**: 2026-01-16 -**Status**: Production Ready -**Next Action**: Review MASTER_DEPLOYMENT_PLAN.md and begin deployment diff --git a/old_code_documentation/deploy_tips/MASTER_DEPLOYMENT_PLAN.md b/old_code_documentation/deploy_tips/MASTER_DEPLOYMENT_PLAN.md deleted file mode 100644 index 6a2d2a5..0000000 --- a/old_code_documentation/deploy_tips/MASTER_DEPLOYMENT_PLAN.md +++ /dev/null @@ -1,380 +0,0 @@ -# 🚀 DigiServer v2 - Production Deployment Master Plan - -## 📌 Quick Navigation - -- **[Deployment Readiness Summary](DEPLOYMENT_READINESS_SUMMARY.md)** - Current system status ✅ -- **[Production Deployment Guide](PRODUCTION_DEPLOYMENT_GUIDE.md)** - Detailed procedures -- **[Command Reference](deployment-commands-reference.sh)** - Quick commands -- **[Verification Script](verify-deployment.sh)** - Automated checks - ---- - -## 🎯 Deployment Status - -``` -✅ Code: Committed and ready -✅ Docker: Configured and tested -✅ HTTPS: Valid certificate (expires 2027-01-16) -✅ CORS: Enabled for API endpoints -✅ Database: Migrations configured -✅ Security: All hardening applied -⚠️ Environment: Needs configuration - -OVERALL: 95% READY FOR PRODUCTION -``` - ---- - -## 🚀 Five-Minute Deployment - -### Step 0: Configure Target IP (If deploying on different network) - -**Special case**: If your host will be on a different IP after deployment/restart: - -```bash -# See: PRE_DEPLOYMENT_IP_CONFIGURATION.md for detailed instructions -# Quick version: -TARGET_IP=192.168.0.121 # What IP will host have AFTER deployment? -TARGET_DOMAIN=digiserver.local # Optional domain name -``` - -This must be set in `.env` BEFORE running `docker-compose up -d` - -### Step 1: Prepare (2 minutes) -```bash -cd /opt/digiserver-v2 - -# Generate secret key -SECRET=$(python -c "import secrets; print(secrets.token_urlsafe(32))") - -# Create .env file -cat > .env << EOF -SECRET_KEY=$SECRET -ADMIN_USERNAME=admin -ADMIN_PASSWORD=YourStrongPassword123! -ADMIN_EMAIL=admin@company.com -DOMAIN=your-domain.com -EMAIL=admin@company.com -FLASK_ENV=production -EOF - -chmod 600 .env -``` - -### Step 2: Deploy (2 minutes) -```bash -# Build and start -docker-compose build -docker-compose up -d - -# Wait for startup -sleep 30 - -# Initialize database -docker-compose exec digiserver-app flask db upgrade -``` - -### Step 3: Verify (1 minute) -```bash -# Health check -curl -k https://your-domain/api/health - -# CORS check -curl -i -k https://your-domain/api/playlists - -# View logs -docker-compose logs --tail=20 digiserver-app -``` - ---- - -## 📋 Complete Deployment Checklist - -### Pre-Deployment (24 hours before) -- [ ] Review [DEPLOYMENT_READINESS_SUMMARY.md](DEPLOYMENT_READINESS_SUMMARY.md) -- [ ] Generate strong SECRET_KEY -- [ ] Generate strong ADMIN_PASSWORD -- [ ] Plan SSL strategy (self-signed, Let's Encrypt, or commercial) -- [ ] Backup current database (if migrating) -- [ ] Schedule maintenance window -- [ ] Notify stakeholders - -### Deployment Day -- [ ] Create .env file with production values -- [ ] Review docker-compose.yml configuration -- [ ] Run: `docker-compose build --no-cache` -- [ ] Run: `docker-compose up -d` -- [ ] Wait 30 seconds for startup -- [ ] Run database migrations if needed -- [ ] Verify health checks passing -- [ ] Test API endpoints -- [ ] Verify CORS headers present - -### Post-Deployment (First 24 hours) -- [ ] Monitor logs for errors -- [ ] Test player connections -- [ ] Verify playlist fetching works -- [ ] Check container health status -- [ ] Monitor resource usage -- [ ] Backup database -- [ ] Document any issues -- [ ] Create deployment log entry - -### Ongoing Maintenance -- [ ] Daily database backups -- [ ] Weekly security updates check -- [ ] Monthly certificate expiry review -- [ ] Quarterly performance review - ---- - -## 🔧 Environment Variables Explained - -| Variable | Purpose | Example | Required | -|----------|---------|---------|----------| -| `SECRET_KEY` | Flask session encryption | `$(python -c "import secrets; print(secrets.token_urlsafe(32))")` | ✅ YES | -| `ADMIN_USERNAME` | Admin panel username | `admin` | ✅ YES | -| `ADMIN_PASSWORD` | Admin panel password | `MyStrong!Pass123` | ✅ YES | -| `ADMIN_EMAIL` | Admin email address | `admin@company.com` | ✅ YES | -| `DOMAIN` | Server domain | `digiserver.company.com` | ❌ NO | -| `EMAIL` | Contact email | `admin@company.com` | ❌ NO | -| `FLASK_ENV` | Flask environment | `production` | ✅ YES | -| `DATABASE_URL` | Database connection | `sqlite:////data/db` | ❌ NO | -| `LOG_LEVEL` | Application log level | `INFO` | ❌ NO | - ---- - -## 🛡️ Security Considerations - -### Enabled Security Features ✅ -- **HTTPS**: Enforced with automatic HTTP→HTTPS redirect -- **CORS**: Configured for `/api/*` endpoints -- **Secure Cookies**: `SESSION_COOKIE_SECURE=True`, `SESSION_COOKIE_HTTPONLY=True` -- **Session Protection**: `SESSION_COOKIE_SAMESITE=Lax` -- **Security Headers**: X-Frame-Options, X-Content-Type-Options, CSP -- **Non-root Container**: Runs as `appuser:1000` -- **TLS 1.2/1.3**: Latest protocols enabled -- **HSTS**: Configured at 365 days - -### Recommended Additional Steps -1. **SSL Certificate**: Upgrade from self-signed to Let's Encrypt - ```bash - certbot certonly --standalone -d your-domain.com - cp /etc/letsencrypt/live/your-domain.com/* data/nginx-ssl/ - ``` - -2. **Database**: Backup daily - ```bash - 0 2 * * * docker-compose exec digiserver-app \ - cp instance/dashboard.db /backup/dashboard.db.$(date +%Y%m%d) - ``` - -3. **Monitoring**: Set up log aggregation -4. **Firewall**: Only allow ports 80 and 443 -5. **Updates**: Check for security updates monthly - ---- - -## 🔍 Verification Commands - -### Health Check -```bash -curl -k https://your-domain/api/health - -# Expected response: -# {"status":"healthy","timestamp":"...","version":"2.0.0"} -``` - -### CORS Header Verification -```bash -curl -i -k https://your-domain/api/playlists | grep -i access-control - -# Expected headers: -# access-control-allow-origin: * -# access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS -# access-control-allow-headers: Content-Type, Authorization -# access-control-max-age: 3600 -``` - -### Certificate Verification -```bash -# Check certificate validity -openssl x509 -in data/nginx-ssl/cert.pem -text -noout - -# Check expiry date -openssl x509 -enddate -noout -in data/nginx-ssl/cert.pem -``` - -### Container Health -```bash -docker-compose ps - -# Expected output: -# NAME STATUS PORTS -# digiserver-app Up (healthy) 5000/tcp -# digiserver-nginx Up (healthy) 80→80, 443→443 -``` - ---- - -## 📊 Performance Tuning - -### For Small Deployments (1-20 players) -```yaml -# docker-compose.yml -services: - digiserver-app: - environment: - - GUNICORN_WORKERS=2 - - GUNICORN_THREADS=4 -``` - -### For Medium Deployments (20-100 players) -```yaml -environment: - - GUNICORN_WORKERS=4 - - GUNICORN_THREADS=4 -``` - -### For Large Deployments (100+ players) -- Upgrade to PostgreSQL database -- Use load balancer with multiple app instances -- Add Redis caching layer -- Implement CDN for media files - ---- - -## 🆘 Troubleshooting - -### "Connection Refused" on HTTPS -```bash -# Check containers running -docker-compose ps - -# Check nginx logs -docker-compose logs nginx - -# Verify SSL certificate exists -ls -la data/nginx-ssl/ -``` - -### "Permission Denied" Errors -```bash -# Fix permissions -docker-compose exec digiserver-app chmod 755 /app -docker-compose restart -``` - -### "Database Locked" Error -```bash -# Restart application -docker-compose restart digiserver-app - -# If persistent, restore from backup -docker-compose down -cp /backup/dashboard.db.bak data/instance/dashboard.db -docker-compose up -d -``` - -### High Memory Usage -```bash -# Check memory usage -docker stats - -# Reduce workers if needed -docker-compose down -# Edit docker-compose.yml, set GUNICORN_WORKERS=2 -docker-compose up -d -``` - ---- - -## 📚 Documentation Structure - -``` -/srv/digiserver-v2/ -├── DEPLOYMENT_READINESS_SUMMARY.md ← Current status -├── PRODUCTION_DEPLOYMENT_GUIDE.md ← Detailed guide -├── deployment-commands-reference.sh ← Quick commands -├── verify-deployment.sh ← Validation script -├── .env.example ← Environment template -├── docker-compose.yml ← Container config -├── Dockerfile ← Container image -└── old_code_documentation/ ← Additional docs - ├── DEPLOYMENT_COMMANDS.md - ├── HTTPS_SETUP.md - └── ... -``` - ---- - -## 📞 Support & Additional Resources - -### Documentation Files -1. **[DEPLOYMENT_READINESS_SUMMARY.md](DEPLOYMENT_READINESS_SUMMARY.md)** - Status verification -2. **[PRODUCTION_DEPLOYMENT_GUIDE.md](PRODUCTION_DEPLOYMENT_GUIDE.md)** - Complete deployment steps -3. **[old_code_documentation/HTTPS_SETUP.md](old_code_documentation/HTTPS_SETUP.md)** - SSL/TLS details - -### Quick Command Reference -```bash -bash deployment-commands-reference.sh # View all commands -bash verify-deployment.sh # Run verification -``` - -### Getting Help -- Check logs: `docker-compose logs -f digiserver-app` -- Run verification: `bash verify-deployment.sh` -- Review documentation in `old_code_documentation/` - ---- - -## ✅ Final Deployment Readiness - -| Component | Status | Action | -|-----------|--------|--------| -| **Code** | ✅ Committed | Ready to deploy | -| **Docker** | ✅ Tested | Ready to deploy | -| **HTTPS** | ✅ Valid cert | Ready to deploy | -| **CORS** | ✅ Enabled | Ready to deploy | -| **Database** | ✅ Configured | Ready to deploy | -| **Security** | ✅ Hardened | Ready to deploy | -| **Environment** | ⚠️ Needs setup | **REQUIRES ACTION** | - -**Status**: 95% Ready - Only environment variables need to be set - ---- - -## 🎯 Next Steps - -1. **Set Environment Variables** - ```bash - cp .env.example .env - nano .env # Edit with your values - ``` - -2. **Deploy** - ```bash - docker-compose build - docker-compose up -d - docker-compose exec digiserver-app flask db upgrade - ``` - -3. **Verify** - ```bash - curl -k https://your-domain/api/health - docker-compose logs --tail=50 digiserver-app - ``` - -4. **Monitor** - ```bash - docker-compose logs -f digiserver-app - docker stats - ``` - ---- - -**Last Updated**: 2026-01-16 20:30 UTC -**Deployment Ready**: ✅ YES -**Recommendation**: Safe to deploy immediately after environment configuration -**Estimated Deployment Time**: 5-10 minutes -**Risk Level**: LOW - All systems tested and verified diff --git a/old_code_documentation/deploy_tips/PRE_DEPLOYMENT_IP_CONFIGURATION.md b/old_code_documentation/deploy_tips/PRE_DEPLOYMENT_IP_CONFIGURATION.md deleted file mode 100644 index a00af91..0000000 --- a/old_code_documentation/deploy_tips/PRE_DEPLOYMENT_IP_CONFIGURATION.md +++ /dev/null @@ -1,346 +0,0 @@ -# Pre-Deployment IP Configuration Guide - -## 🎯 Purpose - -This guide helps you configure the host IP address **before deployment** when your host: -- Is currently on a **different network** during deployment -- Will move to a **static IP** after deployment/restart -- Needs SSL certificates and nginx config set up for that **future IP** - ---- - -## 📋 Pre-Deployment Workflow - -### Step 1: Identify Your Target IP Address - -**Before deployment**, determine what IP your host will have **after** it's deployed and restarted: - -```bash -# Example: Your host will be at 192.168.0.121 after deployment -TARGET_IP=192.168.0.121 -DOMAIN_NAME=digiserver.local # or your domain -``` - -### Step 2: Create .env File with Target IP - -```bash -cp .env.example .env - -# Edit .env with your VALUES: -cat > .env << 'EOF' -FLASK_ENV=production -SECRET_KEY= -ADMIN_USERNAME=admin -ADMIN_PASSWORD= -ADMIN_EMAIL=admin@company.com - -# TARGET IP/Domain (where host will be AFTER deployment) -DOMAIN=digiserver.local -HOST_IP=192.168.0.121 -EMAIL=admin@company.com - -# Network configuration for this subnet -TRUSTED_PROXIES=192.168.0.0/24 - -PREFERRED_URL_SCHEME=https -ENABLE_LIBREOFFICE=true -LOG_LEVEL=INFO -EOF - -chmod 600 .env -``` - -### Step 3: Update nginx.conf with Target IP - -If you want nginx to reference the IP (optional, domain is preferred): - -```bash -# View current nginx config -cat nginx.conf | grep -A 5 "server_name" - -# If needed, update server_name in nginx.conf: -# server_name 192.168.0.121 digiserver.local; -``` - -### Step 4: Configure SSL Certificate for Target IP - -The self-signed certificate should be generated for your target IP/domain: - -```bash -# Check current certificate -openssl x509 -in data/nginx-ssl/cert.pem -text -noout | grep -A 2 "Subject:" - -# If you need to regenerate for new IP: -cd data/nginx-ssl/ - -# Generate new self-signed cert (valid 1 year) -openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \ - -days 365 -nodes \ - -subj "/C=US/ST=State/L=City/O=Org/CN=192.168.0.121" - -# OR with domain: -openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \ - -days 365 -nodes \ - -subj "/C=US/ST=State/L=City/O=Org/CN=digiserver.local" -``` - ---- - -## 🔧 Configuration Reference - -### .env Fields for IP Configuration - -```bash -# Primary configuration -DOMAIN=digiserver.local # DNS name (preferred over IP) -HOST_IP=192.168.0.121 # Static IP after deployment -PREFERRED_URL_SCHEME=https # Always use HTTPS - -# Network security -TRUSTED_PROXIES=192.168.0.0/24 # Your subnet range - -# Application URLs will use these values: -# - https://digiserver.local/api/health -# - https://192.168.0.121/admin -``` - -### Example Configurations - -**Scenario 1: Local Network (Recommended)** -```bash -DOMAIN=digiserver.local -HOST_IP=192.168.0.121 -TRUSTED_PROXIES=192.168.0.0/24 -``` - -**Scenario 2: Cloud Deployment (AWS)** -```bash -DOMAIN=digiserver.company.com -HOST_IP=10.0.1.50 -TRUSTED_PROXIES=10.0.0.0/8 -``` - -**Scenario 3: Multiple Networks** -```bash -DOMAIN=digiserver.local -HOST_IP=192.168.0.121 -# Trust multiple networks during transition -TRUSTED_PROXIES=192.168.0.0/24,10.0.0.0/8 -``` - ---- - -## 📝 Deployment Checklist with IP Configuration - -Before running `docker-compose up -d`: - -- [ ] **Determine target IP/domain** - ```bash - # What will this host's IP be after deployment? - TARGET_IP=192.168.0.121 - ``` - -- [ ] **Create .env file** - ```bash - cp .env.example .env - nano .env # Edit with target IP - ``` - -- [ ] **Verify values in .env** - ```bash - grep "DOMAIN\|HOST_IP\|TRUSTED_PROXIES" .env - ``` - -- [ ] **Check SSL certificate** - ```bash - ls -la data/nginx-ssl/ - openssl x509 -enddate -noout -in data/nginx-ssl/cert.pem - ``` - -- [ ] **Generate new cert if needed** - ```bash - cd data/nginx-ssl/ - openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \ - -days 365 -nodes -subj "/C=US/ST=State/L=City/O=Org/CN=192.168.0.121" - ``` - -- [ ] **Deploy** - ```bash - docker-compose build - docker-compose up -d - docker-compose exec digiserver-app flask db upgrade - ``` - ---- - -## 🔄 Network Transition Workflow - -### Scenario: Deploy on Network A, Run on Network B - -**During Deployment (Network A):** -```bash -# Host might be at 10.0.0.50 currently, but will be 192.168.0.121 after -cp .env.example .env - -# SET .env with FUTURE IP -echo "HOST_IP=192.168.0.121" >> .env -echo "DOMAIN=digiserver.local" >> .env -echo "TRUSTED_PROXIES=192.168.0.0/24" >> .env - -docker-compose build -docker-compose up -d -docker-compose exec digiserver-app flask db upgrade -``` - -**After Host Moves to New Network:** -```bash -# Host is now at 192.168.0.121 -# Container still uses config from .env (which already has correct IP) - -# Verify it's working -curl -k https://192.168.0.121/api/health - -# No additional config needed - already set in .env! -``` - ---- - -## 🛠️ Troubleshooting IP Configuration - -### Issue: Certificate doesn't match IP - -```bash -# Check certificate IP -openssl x509 -in data/nginx-ssl/cert.pem -text -noout | grep -A 2 "Subject Alt" - -# Regenerate if needed -cd data/nginx-ssl/ -rm cert.pem key.pem -openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \ - -days 365 -nodes -subj "/C=US/ST=State/L=City/O=Org/CN=192.168.0.121" - -# Restart nginx -docker-compose restart nginx -``` - -### Issue: Connection refused on new IP - -```bash -# Verify .env has correct IP -cat .env | grep "HOST_IP\|DOMAIN" - -# Check if containers are running -docker-compose ps - -# Check nginx config -docker-compose exec nginx grep "server_name" /etc/nginx/nginx.conf - -# View nginx error logs -docker-compose logs nginx -``` - -### Issue: TRUSTED_PROXIES not working - -```bash -# Verify setting in .env -grep "TRUSTED_PROXIES" .env - -# Check Flask is using it -docker-compose exec digiserver-app python -c " -from app.config import ProductionConfig -print(f'TRUSTED_PROXIES: {ProductionConfig.TRUSTED_PROXIES}') -" - -# If not set, rebuild: -docker-compose down -docker-compose build --no-cache -docker-compose up -d -``` - ---- - -## 📊 IP Configuration Quick Reference - -| Setting | Purpose | Example | -|---------|---------|---------| -| `DOMAIN` | Primary access URL | `digiserver.local` | -| `HOST_IP` | Static IP after deployment | `192.168.0.121` | -| `TRUSTED_PROXIES` | IPs that can forward headers | `192.168.0.0/24` | -| `PREFERRED_URL_SCHEME` | HTTP or HTTPS | `https` | - ---- - -## ✅ Verification After Deployment - -Once host is on its target IP: - -```bash -# Test health endpoint -curl -k https://192.168.0.121/api/health - -# Test with domain (if using DNS) -curl -k https://digiserver.local/api/health - -# Check certificate info -openssl s_client -connect 192.168.0.121:443 -showcerts - -# Verify CORS headers -curl -i -k https://192.168.0.121/api/playlists -``` - ---- - -## 🔐 Security Notes - -1. **Use DOMAIN over IP** when possible (DNS is more flexible) -2. **TRUSTED_PROXIES** should match your network (not 0.0.0.0/0) -3. **Certificate** should be valid for your actual IP/domain -4. **Backup .env** - it contains SECRET_KEY and passwords - ---- - -## 📋 Complete Pre-Deployment Checklist - -``` -PRE-DEPLOYMENT IP CONFIGURATION CHECKLIST -========================================== - -Network Planning: - [ ] Determine host's TARGET IP address - [ ] Determine host's TARGET domain name (if any) - [ ] Identify network subnet (e.g., 192.168.0.0/24) - -Configuration: - [ ] Create .env file from .env.example - [ ] Set DOMAIN to target domain/IP - [ ] Set HOST_IP to target static IP - [ ] Set TRUSTED_PROXIES to your network range - [ ] Generate/verify SSL certificate for target IP - [ ] Review all sensitive values (passwords, keys) - -Deployment: - [ ] Run docker-compose build - [ ] Run docker-compose up -d - [ ] Run database migrations - [ ] Wait for containers to be healthy - -Verification (After Host IP Change): - [ ] Host has static IP assigned - [ ] Test: curl -k https://TARGET_IP/api/health - [ ] Test: curl -k https://DOMAIN/api/health (if using DNS) - [ ] Check SSL certificate matches - [ ] Verify CORS headers present - [ ] Check logs for errors - -Post-Deployment: - [ ] Backup .env file securely - [ ] Document deployment IP/domain for future ref - [ ] Set up backups - [ ] Monitor logs for 24 hours -``` - ---- - -**Status**: Ready to use for network transition deployments -**Last Updated**: 2026-01-16 -**Use Case**: Deploy on temp network, run on production network with static IP diff --git a/old_code_documentation/deploy_tips/PRODUCTION_DEPLOYMENT_GUIDE.md b/old_code_documentation/deploy_tips/PRODUCTION_DEPLOYMENT_GUIDE.md deleted file mode 100644 index 730e0a2..0000000 --- a/old_code_documentation/deploy_tips/PRODUCTION_DEPLOYMENT_GUIDE.md +++ /dev/null @@ -1,363 +0,0 @@ -# Production Deployment Readiness Report - -## 📋 Executive Summary - -**Status**: ⚠️ **MOSTLY READY** - 8/10 areas clear, 2 critical items need attention before production - -The application is viable for production deployment but requires: -1. ✅ Commit code changes to version control -2. ✅ Set proper environment variables -3. ✅ Verify SSL certificate strategy - ---- - -## 📊 Detailed Assessment - -### ✅ AREAS READY FOR PRODUCTION - -#### 1. **Docker Configuration** ✅ -- ✅ Dockerfile properly configured with: - - Python 3.13-slim base image (secure, minimal) - - Non-root user (appuser:1000) for security - - Health checks configured - - All dependencies properly installed - - Proper file permissions - -#### 2. **Dependencies** ✅ -- ✅ 48 packages in requirements.txt -- ✅ Latest stable versions: - - Flask==3.1.0 - - SQLAlchemy==2.0.37 - - Flask-Cors==4.0.0 (newly added) - - gunicorn==23.0.0 - - All security packages up-to-date - -#### 3. **Database Setup** ✅ -- ✅ 4 migration files exist -- ✅ SQLAlchemy ORM properly configured -- ✅ Database schema versioning ready - -#### 4. **SSL/HTTPS Configuration** ✅ -- ✅ Self-signed certificate valid until 2027-01-16 -- ✅ TLS 1.2 and 1.3 support enabled -- ✅ nginx SSL configuration hardened - -#### 5. **Security Headers** ✅ -- ✅ X-Frame-Options: SAMEORIGIN -- ✅ X-Content-Type-Options: nosniff -- ✅ Content-Security-Policy configured -- ✅ Referrer-Policy configured - -#### 6. **Deployment Scripts** ✅ -- ✅ docker-compose.yml properly configured -- ✅ docker-entrypoint.sh handles initialization -- ✅ Restart policies set to "unless-stopped" -- ✅ Health checks configured - -#### 7. **Flask Configuration** ✅ -- ✅ Production config class defined -- ✅ CORS enabled for API endpoints -- ✅ Session security configured -- ✅ ProxyFix middleware enabled - -#### 8. **Logging & Monitoring** ✅ -- ✅ Gunicorn logging configured -- ✅ Docker health checks configured -- ✅ Container restart policies configured - ---- - -## ⚠️ ISSUES REQUIRING ATTENTION - -### Issue #1: Hardcoded Default Values in Config 🔴 - -**Location**: `app/config.py` - -**Problem**: -```python -SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') -DEFAULT_ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'Initial01!') -``` - -**Risk**: Default values will be used if environment variables not set - -**Solution** (Choose one): - -**Option A: Remove defaults (Recommended)** -```python -SECRET_KEY = os.getenv('SECRET_KEY') # Fails fast if not set -DEFAULT_ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD') -``` - -**Option B: Use stronger defaults** -```python -import secrets -SECRET_KEY = os.getenv('SECRET_KEY', secrets.token_urlsafe(32)) -DEFAULT_ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', secrets.token_urlsafe(16)) -``` - ---- - -### Issue #2: Uncommitted Changes 🟡 - -**Current status**: 7 uncommitted changes - -``` - M app/app.py (CORS implementation) - M app/blueprints/api.py (Certificate endpoint) - M app/config.py (Session security) - M app/extensions.py (CORS support) - M nginx.conf (CORS headers) - M requirements.txt (Added cryptography) - ? old_code_documentation/ (New documentation) -``` - -**Action Required**: -```bash -cd /srv/digiserver-v2 -git add -A -git commit -m "HTTPS improvements: Enable CORS, fix player connections, add security headers" -git log --oneline -1 -``` - ---- - -## 🚀 PRODUCTION DEPLOYMENT CHECKLIST - -### Pre-Deployment (Execute in order) - -- [ ] **1. Commit all changes** - ```bash - git status - git add -A - git commit -m "Production-ready: HTTPS/CORS fixes" - ``` - -- [ ] **2. Set environment variables** - Create `.env` file or configure in deployment system: - ```bash - SECRET_KEY= - ADMIN_USERNAME=admin - ADMIN_PASSWORD= - ADMIN_EMAIL=admin@yourdomain.com - DATABASE_URL=sqlite:////path/to/db # or PostgreSQL - FLASK_ENV=production - DOMAIN=your-domain.com - EMAIL=admin@your-domain.com - ``` - -- [ ] **3. Update docker-compose.yml environment section** - ```yaml - environment: - - FLASK_ENV=production - - SECRET_KEY=${SECRET_KEY} - - ADMIN_USERNAME=${ADMIN_USERNAME} - - ADMIN_PASSWORD=${ADMIN_PASSWORD} - - DATABASE_URL=${DATABASE_URL} - - DOMAIN=${DOMAIN} - - EMAIL=${EMAIL} - ``` - -- [ ] **4. SSL Certificate Strategy** - - **Option A: Keep Self-Signed (Quick)** - - Current certificate valid until 2027 - - Players must accept/trust cert - - Suitable for internal networks - - **Option B: Use Let's Encrypt (Recommended)** - - Install certbot: `apt install certbot` - - Generate cert: `certbot certonly --standalone -d yourdomain.com` - - Copy to: `data/nginx-ssl/` - - Auto-renew with systemd timer - - **Option C: Use Commercial Certificate** - - Purchase from provider - - Copy cert and key to `data/nginx-ssl/` - - Update nginx.conf paths if needed - -- [ ] **5. Database initialization** - ```bash - # First run will create database - docker-compose up -d - # Run migrations - docker-compose exec digiserver-app flask db upgrade - ``` - -- [ ] **6. Test deployment** - ```bash - # Health check - curl -k https://your-server/api/health - - # CORS headers - curl -i -k https://your-server/api/playlists - - # Login page - curl -k https://your-server/login - ``` - -- [ ] **7. Backup database** - ```bash - docker-compose exec digiserver-app \ - cp instance/dashboard.db /backup/dashboard.db.bak - ``` - -- [ ] **8. Configure monitoring** - - Set up log aggregation - - Configure alerts for container restarts - - Monitor disk space for uploads - -### Post-Deployment - -- [ ] Verify player connections work -- [ ] Test playlist fetching -- [ ] Monitor error logs for 24 hours -- [ ] Verify database backups are working -- [ ] Set up SSL renewal automation - ---- - -## 📦 ENVIRONMENT VARIABLES REQUIRED - -| Variable | Purpose | Example | Required | -|----------|---------|---------|----------| -| `FLASK_ENV` | Flask environment | `production` | ✅ | -| `SECRET_KEY` | Session encryption | `<32+ char random>` | ✅ | -| `ADMIN_USERNAME` | Initial admin user | `admin` | ✅ | -| `ADMIN_PASSWORD` | Initial admin password | `` | ✅ | -| `ADMIN_EMAIL` | Admin email | `admin@company.com` | ✅ | -| `DATABASE_URL` | Database connection | `sqlite:////data/db` | ❌ (default works) | -| `DOMAIN` | Server domain | `digiserver.company.com` | ❌ (localhost default) | -| `EMAIL` | SSL/Cert email | `admin@company.com` | ❌ | -| `PREFERRED_URL_SCHEME` | URL scheme | `https` | ✅ (set in config) | -| `TRUSTED_PROXIES` | Proxy whitelist | `10.0.0.0/8` | ✅ (set in config) | - ---- - -## 🔒 SECURITY RECOMMENDATIONS - -### Before Going Live - -1. **Change all default passwords** - - [ ] Admin initial password - - [ ] Database password (if using external DB) - -2. **Rotate SSL certificates** - - [ ] Replace self-signed cert with Let's Encrypt or commercial - - [ ] Set up auto-renewal - -3. **Enable HTTPS only** - - [ ] Redirect all HTTP to HTTPS (already configured) - - [ ] Set HSTS header (consider adding) - -4. **Secure the instance** - - [ ] Close unnecessary ports - - [ ] Firewall rules for 80 and 443 only - - [ ] SSH only with key authentication - - [ ] Regular security updates - -5. **Database Security** - - [ ] Regular backups (daily recommended) - - [ ] Test backup restoration - - [ ] Restrict database access - -6. **Monitoring** - - [ ] Enable application logging - - [ ] Set up alerts for errors - - [ ] Monitor resource usage - - [ ] Check SSL expiration dates - ---- - -## 🐳 DEPLOYMENT COMMANDS - -### Fresh Production Deployment - -```bash -# 1. Clone repository -git clone /opt/digiserver-v2 -cd /opt/digiserver-v2 - -# 2. Create environment file -cat > .env << 'EOF' -SECRET_KEY=your-generated-secret-key-here -ADMIN_USERNAME=admin -ADMIN_PASSWORD=your-strong-password -ADMIN_EMAIL=admin@company.com -DOMAIN=your-domain.com -EMAIL=admin@company.com -EOF - -# 3. Build and start -docker-compose -f docker-compose.yml build -docker-compose -f docker-compose.yml up -d - -# 4. Initialize database (first run only) -docker-compose exec digiserver-app flask db upgrade - -# 5. Verify -docker-compose logs -f digiserver-app -curl -k https://localhost/api/health -``` - -### Stopping Service - -```bash -docker-compose down -``` - -### View Logs - -```bash -# App logs -docker-compose logs -f digiserver-app - -# Nginx logs -docker-compose logs -f nginx - -# Last 100 lines -docker-compose logs --tail=100 digiserver-app -``` - -### Database Backup - -```bash -# Backup -docker-compose exec digiserver-app \ - cp instance/dashboard.db /backup/dashboard.db.$(date +%Y%m%d) - -# Restore -docker-compose exec digiserver-app \ - cp /backup/dashboard.db.20260116 instance/dashboard.db -``` - ---- - -## ✅ FINAL DEPLOYMENT STATUS - -| Component | Status | Action | -|-----------|--------|--------| -| Code | ⚠️ Uncommitted | Commit changes | -| Environment | ⚠️ Not configured | Set env vars | -| SSL | ✅ Ready | Use as-is or upgrade | -| Database | ✅ Ready | Initialize on first run | -| Docker | ✅ Ready | Build and deploy | -| HTTPS | ✅ Ready | CORS + security enabled | -| Security | ✅ Ready | Change defaults | - ---- - -## 🎯 CONCLUSION - -**The application IS ready for production deployment** with these pre-requisites: - -1. ✅ Commit code changes -2. ✅ Set production environment variables -3. ✅ Plan SSL certificate strategy -4. ✅ Configure backups -5. ✅ Set up monitoring - -**Estimated deployment time**: 30 minutes -**Risk level**: LOW (all systems tested and working) -**Recommendation**: **PROCEED WITH DEPLOYMENT** - diff --git a/old_code_documentation/docker-start.sh b/old_code_documentation/docker-start.sh deleted file mode 100755 index 9b461b2..0000000 --- a/old_code_documentation/docker-start.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/bin/bash - -echo "🚀 DigiServer v2 - Docker Quick Start" -echo "=====================================" -echo "" - -# Check if Docker is installed -if ! command -v docker &> /dev/null; then - echo "❌ Docker is not installed. Please install Docker first." - exit 1 -fi - -# Check if Docker Compose is installed -if ! command -v docker-compose &> /dev/null; then - echo "❌ Docker Compose is not installed. Please install Docker Compose first." - exit 1 -fi - -# Create .env file if it doesn't exist -if [ ! -f .env ]; then - echo "📝 Creating .env file..." - cp .env.example .env - - # Generate random secret key - SECRET_KEY=$(openssl rand -base64 32) - sed -i "s/change-this-to-a-random-secret-key/$SECRET_KEY/" .env - echo "✅ Created .env with generated SECRET_KEY" -fi - -# Create required directories -echo "📁 Creating required directories..." -mkdir -p instance app/static/uploads -echo "✅ Directories created" - -echo "" -echo "🔨 Building Docker image..." -docker-compose build - -echo "" -echo "🚀 Starting DigiServer v2..." -docker-compose up -d - -echo "" -echo "⏳ Waiting for application to start..." -sleep 5 - -# Check if container is running -if docker-compose ps | grep -q "Up"; then - echo "" - echo "✅ DigiServer v2 is running!" - echo "" - echo "📍 Access the application at: http://localhost:5000" - echo "" - echo "👤 Default credentials:" - echo " Username: admin" - echo " Password: admin123" - echo "" - echo "📋 Useful commands:" - echo " View logs: docker-compose logs -f" - echo " Stop: docker-compose down" - echo " Restart: docker-compose restart" - echo " Shell access: docker-compose exec digiserver bash" - echo "" - echo "⚠️ IMPORTANT: Change the admin password after first login!" -else - echo "" - echo "❌ Failed to start DigiServer v2" - echo " Check logs with: docker-compose logs" -fi diff --git a/old_code_documentation/fix_player_user_schema.py b/old_code_documentation/fix_player_user_schema.py deleted file mode 100644 index 3e71def..0000000 --- a/old_code_documentation/fix_player_user_schema.py +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python3 -"""Fix player_user table schema by dropping and recreating it.""" -import sys -sys.path.insert(0, '/app') - -from app.app import create_app -from app.extensions import db -from app.models.player_user import PlayerUser - -def main(): - app = create_app('production') - with app.app_context(): - # Drop the old table - print("Dropping player_user table...") - db.session.execute(db.text('DROP TABLE IF EXISTS player_user')) - db.session.commit() - - # Recreate with new schema - print("Creating player_user table with new schema...") - PlayerUser.__table__.create(db.engine) - - print("Done! player_user table recreated successfully.") - -if __name__ == '__main__': - main() diff --git a/old_code_documentation/generate_nginx_certs.sh b/old_code_documentation/generate_nginx_certs.sh deleted file mode 100755 index d081f1e..0000000 --- a/old_code_documentation/generate_nginx_certs.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash -# Generate self-signed SSL certificates for Nginx -# Usage: ./generate_nginx_certs.sh [domain] [days] - -DOMAIN=${1:-localhost} -DAYS=${2:-365} -CERT_DIR="./data/nginx-ssl" - -echo "🔐 Generating self-signed SSL certificate for Nginx" -echo "Domain: $DOMAIN" -echo "Valid for: $DAYS days" -echo "Certificate directory: $CERT_DIR" - -# Create directory if it doesnt exist -mkdir -p "$CERT_DIR" - -# Generate private key and certificate -openssl req -x509 -nodes -days "$DAYS" \ - -newkey rsa:2048 \ - -keyout "$CERT_DIR/key.pem" \ - -out "$CERT_DIR/cert.pem" \ - -subj "/CN=$DOMAIN/O=DigiServer/C=US" - -# Set proper permissions -chmod 644 "$CERT_DIR/cert.pem" -chmod 600 "$CERT_DIR/key.pem" - -echo "✅ Certificates generated successfully!" -echo "Certificate: $CERT_DIR/cert.pem" -echo "Key: $CERT_DIR/key.pem" diff --git a/old_code_documentation/init-data.sh.deprecated b/old_code_documentation/init-data.sh.deprecated deleted file mode 100755 index be2e5e3..0000000 --- a/old_code_documentation/init-data.sh.deprecated +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -# Initialize ./data folder with all necessary files for deployment - -set -e - -echo "🔧 Initializing data folder..." -mkdir -p data/{app,instance,uploads} - -echo "📁 Copying app folder..." -rm -rf data/app -mkdir -p data/app -cp -r app/* data/app/ - -echo "📋 Copying migrations..." -rm -rf data/migrations -cp -r migrations data/ - -echo "🔧 Copying utility scripts..." -cp fix_player_user_schema.py data/ - -echo "🔐 Setting permissions..." -chmod 755 data/{app,instance,uploads} -chmod -R 755 data/app/ -find data/app -type f \( -name "*.py" -o -name "*.html" -o -name "*.css" -o -name "*.js" \) -exec chmod 644 {} \; -chmod 777 data/instance data/uploads - -echo "✅ Data folder initialized successfully!" -echo "📊 Data folder contents:" -du -sh data/*/ diff --git a/old_code_documentation/migrate_add_edit_enabled.py b/old_code_documentation/migrate_add_edit_enabled.py deleted file mode 100644 index d6040b4..0000000 --- a/old_code_documentation/migrate_add_edit_enabled.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Migration: Add edit_on_player_enabled column to playlist_content table.""" -import sqlite3 -import os - -DB_PATH = 'instance/dashboard.db' - -def migrate(): - """Add edit_on_player_enabled column to playlist_content.""" - if not os.path.exists(DB_PATH): - print(f"Database not found at {DB_PATH}") - return False - - conn = sqlite3.connect(DB_PATH) - cursor = conn.cursor() - - try: - # Check if column already exists - cursor.execute("PRAGMA table_info(playlist_content)") - columns = [col[1] for col in cursor.fetchall()] - - if 'edit_on_player_enabled' in columns: - print("Column 'edit_on_player_enabled' already exists!") - return True - - # Add the new column with default value False - print("Adding 'edit_on_player_enabled' column to playlist_content table...") - cursor.execute(""" - ALTER TABLE playlist_content - ADD COLUMN edit_on_player_enabled BOOLEAN DEFAULT 0 - """) - - conn.commit() - print("✅ Migration completed successfully!") - print("Column 'edit_on_player_enabled' added with default value False (0)") - - return True - - except Exception as e: - conn.rollback() - print(f"❌ Migration failed: {e}") - return False - - finally: - conn.close() - -if __name__ == '__main__': - migrate() diff --git a/old_code_documentation/nginx-custom-domains.conf b/old_code_documentation/nginx-custom-domains.conf deleted file mode 100644 index 32fc0da..0000000 --- a/old_code_documentation/nginx-custom-domains.conf +++ /dev/null @@ -1,21 +0,0 @@ -# Nginx configuration for custom HTTPS domains -# This file will be dynamically generated based on HTTPSConfig database entries -# Include this in your nginx.conf with: include /etc/nginx/conf.d/custom-domains.conf; - -# Example entry for custom domain: -# server { -# listen 443 ssl http2; -# listen [::]:443 ssl http2; -# server_name digiserver.example.com; -# -# ssl_certificate /etc/nginx/ssl/custom/cert.pem; -# ssl_certificate_key /etc/nginx/ssl/custom/key.pem; -# -# location / { -# proxy_pass http://digiserver_app; -# proxy_set_header Host $host; -# proxy_set_header X-Real-IP $remote_addr; -# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -# proxy_set_header X-Forwarded-Proto $scheme; -# } -# } diff --git a/old_code_documentation/nginx.conf b/old_code_documentation/nginx.conf deleted file mode 100644 index 0e22e5e..0000000 --- a/old_code_documentation/nginx.conf +++ /dev/null @@ -1,129 +0,0 @@ -user nginx; -worker_processes auto; -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; - use epoll; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - - sendfile on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 65; - types_hash_max_size 2048; - client_max_body_size 2048M; - - # Gzip compression - gzip on; - gzip_vary on; - gzip_proxied any; - gzip_comp_level 6; - gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml; - - # Upstream to Flask application - upstream digiserver_app { - server digiserver-app:5000; - keepalive 32; - } - - # HTTP Server - redirect to HTTPS - server { - listen 80 default_server; - listen [::]:80 default_server; - server_name _; - - # Allow ACME challenges for Let's Encrypt - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } - - # Redirect HTTP to HTTPS for non-ACME requests - location / { - return 301 https://$host$request_uri; - } - } - - # HTTPS Server (with self-signed cert by default) - server { - listen 443 ssl http2 default_server; - listen [::]:443 ssl http2 default_server; - server_name localhost; - - # SSL certificate paths (will be volume-mounted) - ssl_certificate /etc/nginx/ssl/cert.pem; - ssl_certificate_key /etc/nginx/ssl/key.pem; - - # SSL Configuration - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; - ssl_prefer_server_ciphers on; - ssl_session_cache shared:SSL:10m; - ssl_session_timeout 10m; - - # Security Headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "no-referrer-when-downgrade" always; - add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; - - # CORS Headers for API endpoints (allows player device connections) - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always; - add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always; - add_header 'Access-Control-Max-Age' '3600' always; - - # Handle OPTIONS requests for CORS preflight - if ($request_method = 'OPTIONS') { - return 204; - } - - # Proxy settings - location / { - proxy_pass http://digiserver_app; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $server_name; - proxy_set_header X-Forwarded-Port $server_port; - - # Timeouts for large uploads - proxy_connect_timeout 300s; - proxy_send_timeout 300s; - proxy_read_timeout 300s; - - # Buffering - proxy_buffering on; - proxy_buffer_size 128k; - proxy_buffers 4 256k; - proxy_busy_buffers_size 256k; - } - - # Static files caching - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { - proxy_pass http://digiserver_app; - proxy_cache_valid 200 60d; - expires 60d; - add_header Cache-Control "public, immutable"; - } - } - - # Additional server blocks for custom domains can be included here - include /etc/nginx/conf.d/*.conf; -} diff --git a/old_code_documentation/player_analisis/KIWY_PLAYER_ANALYSIS_INDEX.md b/old_code_documentation/player_analisis/KIWY_PLAYER_ANALYSIS_INDEX.md deleted file mode 100644 index 4e3bd1f..0000000 --- a/old_code_documentation/player_analisis/KIWY_PLAYER_ANALYSIS_INDEX.md +++ /dev/null @@ -1,395 +0,0 @@ -# Kiwy-Signage Player HTTPS/SSL Analysis - Complete Documentation - -## Overview - -This documentation provides a comprehensive analysis of how the Kiwy-Signage player (https://gitea.moto-adv.com/ske087/Kiwy-Signage.git) handles HTTPS connections and SSL certificate verification, along with implementation guides for adding self-signed certificate support. - -**Analysis Date:** January 16, 2026 -**Player Version:** Latest from repository -**Server Compatibility:** DigiServer v2 - ---- - -## Key Findings - -### Current State -- ✅ **HTTPS Support:** Yes, fully functional for CA-signed certificates -- ❌ **Self-Signed Certificates:** NOT supported without code modifications -- ❌ **Custom CA Bundles:** NOT supported without code modifications -- ✅ **SSL Verification:** Enabled by default (uses requests library defaults) -- ⚠️ **Hardcoded Settings:** None (relies entirely on requests library) - -### Architecture -- **HTTP Client:** Python `requests` library (v2.32.4) -- **HTTPS Requests:** 6 locations in 2 main files -- **Certificate Verification:** Implicit `verify=True` (default behavior) -- **Configuration:** Via `config/app_config.json` (no SSL options currently) - ---- - -## Documentation Files - -### 1. 📋 [KIWY_PLAYER_HTTPS_ANALYSIS.md](./KIWY_PLAYER_HTTPS_ANALYSIS.md) -**Main technical analysis document** - Start here for comprehensive understanding - -**Contents:** -- Executive summary -- HTTP client library details -- Main connection files and locations -- HTTPS connection architecture -- Certificate verification code analysis -- Current SSL/certificate behavior -- Required changes for self-signed support -- Testing instructions -- Summary tables and references - -**Read this if you need:** Full technical details, code references, line numbers - ---- - -### 2. ⚡ [KIWY_PLAYER_HTTPS_QUICK_REF.md](./KIWY_PLAYER_HTTPS_QUICK_REF.md) -**Quick reference guide** - Use this for quick lookups and summaries - -**Contents:** -- Quick facts and key statistics -- Where HTTPS requests are made (code locations) -- What gets sent over HTTPS (data flow) -- The problem with self-signed certificates -- How to enable self-signed certificate support -- Configuration files overview -- Network flow diagrams -- SSL error troubleshooting -- Testing instructions - -**Read this if you need:** Quick answers, quick start, troubleshooting - ---- - -### 3. 🔧 [KIWY_PLAYER_SSL_PATCHES.md](./KIWY_PLAYER_SSL_PATCHES.md) -**Implementation guide with exact code patches** - Use this to implement the changes - -**Contents:** -- Complete PATCH 1: Create ssl_config.py (NEW FILE) -- Complete PATCH 2: Modify src/player_auth.py (7 changes) -- Complete PATCH 3: Modify src/get_playlists_v2.py (2 changes) -- PATCH 4: Extract server certificate -- PATCH 5: Using environment variables -- Testing procedures after patches -- Implementation checklist -- Rollback instructions - -**Read this if you need:** To implement self-signed certificate support - ---- - -### 4. 📐 [KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md](./KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md) -**Visual architecture and flow diagrams** - Use this to understand the system visually - -**Contents:** -- Current architecture before patches (with ASCII diagrams) -- New architecture after patches -- Certificate resolution flow -- File structure before/after -- Deployment scenarios (production, self-signed, dev) -- Request flow sequence diagram -- Error handling flow -- Security comparison table - -**Read this if you need:** Visual understanding, deployment planning - ---- - -## Quick Navigation - -### I want to... - -**Understand how the player works:** -→ Read [KIWY_PLAYER_HTTPS_ANALYSIS.md](./KIWY_PLAYER_HTTPS_ANALYSIS.md) Section 3 - -**Find where HTTPS requests happen:** -→ Read [KIWY_PLAYER_HTTPS_QUICK_REF.md](./KIWY_PLAYER_HTTPS_QUICK_REF.md) "Where HTTPS Requests Are Made" - -**Implement self-signed cert support:** -→ Follow [KIWY_PLAYER_SSL_PATCHES.md](./KIWY_PLAYER_SSL_PATCHES.md) step by step - -**See a visual diagram:** -→ Read [KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md](./KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md) - -**Understand the problem:** -→ Read [KIWY_PLAYER_HTTPS_QUICK_REF.md](./KIWY_PLAYER_HTTPS_QUICK_REF.md) "The Problem with Self-Signed Certificates" - -**Check specific code lines:** -→ Read [KIWY_PLAYER_HTTPS_ANALYSIS.md](./KIWY_PLAYER_HTTPS_ANALYSIS.md) Section 3 "All HTTPS Request Points" - -**See the recommended solution:** -→ Read [KIWY_PLAYER_HTTPS_ANALYSIS.md](./KIWY_PLAYER_HTTPS_ANALYSIS.md) Section 6 "Option 2: Custom CA Certificate Bundle" - ---- - -## Implementation Path - -### For Production Deployment (Recommended) - -1. **Review the analysis** - - Read [KIWY_PLAYER_HTTPS_ANALYSIS.md](./KIWY_PLAYER_HTTPS_ANALYSIS.md) sections 1-5 - - Understand current limitations and proposed solution - -2. **Plan the implementation** - - Review [KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md](./KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md) deployment scenarios - - Decide on environment-specific configurations - -3. **Implement patches** - - Follow [KIWY_PLAYER_SSL_PATCHES.md](./KIWY_PLAYER_SSL_PATCHES.md) - - Create `src/ssl_config.py` - - Modify `src/player_auth.py` (7 changes) - - Modify `src/get_playlists_v2.py` (2 changes) - -4. **Deploy certificates** - - Export certificate from DigiServer - - Place in `config/ca_bundle.crt` - - Verify using [KIWY_PLAYER_SSL_PATCHES.md](./KIWY_PLAYER_SSL_PATCHES.md) Test section - -5. **Test thoroughly** - - Test with self-signed server - - Test with production server (verify backward compatibility) - - Monitor player logs for SSL errors - -6. **Document** - - Update player README with SSL certificate setup instructions - - Document certificate rotation procedures - -### For Quick Testing (Development) - -1. Review [KIWY_PLAYER_HTTPS_QUICK_REF.md](./KIWY_PLAYER_HTTPS_QUICK_REF.md) "Quickest Fix" -2. Use `SSLConfig.disable_verification()` temporarily -3. ⚠️ **Never use in production** - ---- - -## File Summary - -| File | Purpose | Length | Best For | -|------|---------|--------|----------| -| KIWY_PLAYER_HTTPS_ANALYSIS.md | Main technical document | ~400 lines | Complete understanding | -| KIWY_PLAYER_HTTPS_QUICK_REF.md | Quick reference | ~300 lines | Quick lookups | -| KIWY_PLAYER_SSL_PATCHES.md | Implementation guide | ~350 lines | Applying changes | -| KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md | Visual diagrams | ~400 lines | Visual learning | - ---- - -## Key Statistics - -### Current Implementation -- **HTTP Client Library:** `requests` v2.32.4 -- **HTTPS Request Locations:** 6 (5 in player_auth.py, 1 in get_playlists_v2.py) -- **Lines With Certificate Handling:** 0 (all use implicit defaults) -- **SSL Configuration Options:** 0 (all use system defaults) -- **Custom CA Support:** ❌ Not implemented - -### After Patches -- **New Files:** 1 (`ssl_config.py`) -- **Modified Files:** 2 (`player_auth.py`, `get_playlists_v2.py`) -- **Code Lines Added:** ~60 (new module) -- **Code Lines Modified:** ~8 (in existing modules) -- **New Dependencies:** 0 (uses existing requests library) -- **Breaking Changes:** 0 (fully backward compatible) - -### Code Locations - -**src/player_auth.py:** -- Line 95: `requests.post(auth_url, ...)` -- Line 157: `requests.post(verify_url, ...)` -- Line 178: `requests.get(playlist_url, ...)` -- Line 227: `requests.post(heartbeat_url, ...)` -- Line 254: `requests.post(feedback_url, ...)` - -**src/get_playlists_v2.py:** -- Line 159: `requests.get(file_url, ...)` - ---- - -## Configuration - -### Current Configuration (No SSL Options) -**File:** `config/app_config.json` -```json -{ - "server_ip": "digi-signage.moto-adv.com", - "port": "443", - "screen_name": "tv-terasa", - "quickconnect_key": "8887779", - "orientation": "Landscape", - "touch": "True", - "max_resolution": "1920x1080", - "edit_feature_enabled": true -} -``` - -### After Patches - New Files - -**New:** `config/ca_bundle.crt` (Certificate file) -``` ------BEGIN CERTIFICATE----- -MIIDXTCCAkWgAwIBAgIJAJC1/iNAZwqDMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV -... (certificate content) ------END CERTIFICATE----- -``` - -**New:** `src/ssl_config.py` (Module for SSL configuration) - ---- - -## Architecture Overview - -### Before Patches -``` -Player Application - ├─ main.py (GUI) - ├─ player_auth.py (Auth) - └─ get_playlists_v2.py (Playlists) - │ - ├─ requests.post/get(..., timeout=30) - │ └─ Uses default: verify=True - │ └─ Only works with CA-signed certs - │ - └─ Python requests library - └─ System CA certificates - ├─ Production certs: ✅ Works - └─ Self-signed certs: ❌ Fails -``` - -### After Patches -``` -Player Application - ├─ main.py (GUI) - ├─ player_auth.py (Auth) [MODIFIED] - ├─ get_playlists_v2.py (Playlists) [MODIFIED] - └─ ssl_config.py [NEW] - │ - ├─ requests.post/get(..., verify=ca_bundle) - │ └─ Uses SSLConfig.get_verify_setting() - │ └─ Works with multiple cert types - │ - └─ Python requests library - ├─ Custom CA: 'config/ca_bundle.crt' - ├─ Env var: REQUESTS_CA_BUNDLE - ├─ System certs: True - │ - ├─ Production certs: ✅ Works - ├─ Self-signed certs: ✅ Works (with ca_bundle.crt) - └─ Custom CA: ✅ Works (with env var) -``` - ---- - -## Security Considerations - -### Current Implementation ✅ -- ✅ SSL certificate verification enabled -- ✅ Works securely with CA-signed certificates -- ✅ No hardcoded insecure defaults -- ✅ Uses Python best practices - -### Self-Signed Support (After Patches) ✅ -- ✅ Maintains security with custom CA verification -- ✅ No downgrade to insecure `verify=False` -- ✅ Backward compatible with production -- ✅ Supports environment-specific configurations - -### NOT Recommended -- ❌ Using `verify=False` in production -- ❌ Disabling SSL verification permanently -- ❌ Ignoring certificate errors -- ❌ Man-in-the-middle attack risks - ---- - -## Troubleshooting - -### Common Issues - -**Problem:** "certificate verify failed" -**Solution:** See [KIWY_PLAYER_HTTPS_QUICK_REF.md](./KIWY_PLAYER_HTTPS_QUICK_REF.md) "SSL Error Troubleshooting" - -**Problem:** Player won't connect to DigiServer -**Solution:** See [KIWY_PLAYER_HTTPS_ANALYSIS.md](./KIWY_PLAYER_HTTPS_ANALYSIS.md) Section 5 - -**Problem:** Not sure if patches are applied correctly -**Solution:** See [KIWY_PLAYER_SSL_PATCHES.md](./KIWY_PLAYER_SSL_PATCHES.md) "Testing After Patches" - -**Problem:** Need to rollback changes -**Solution:** See [KIWY_PLAYER_SSL_PATCHES.md](./KIWY_PLAYER_SSL_PATCHES.md) "Rollback Instructions" - ---- - -## References - -### Source Repository -- **URL:** https://gitea.moto-adv.com/ske087/Kiwy-Signage.git -- **Main Files:** - - `src/player_auth.py` - Authentication and API communication - - `src/get_playlists_v2.py` - Playlist management - - `src/main.py` - GUI application - - `config/app_config.json` - Configuration - -### Python Libraries Used -- **requests** v2.32.4 - HTTP client with SSL support -- **kivy** ≥2.3.0 - GUI framework -- **aiohttp** v3.9.1 - Async HTTP (not used for auth) - -### Related Documentation -- [Python requests SSL verification](https://requests.readthedocs.io/en/latest/user/advanced/#ssl-cert-verification) -- [OpenSSL certificate export](https://www.ssl.com/article/exporting-certificate-from-browser/) -- [Requests CA bundle documentation](https://docs.python-requests.org/en/latest/user/advanced/) - ---- - -## Changelog - -### 2026-01-16 - Initial Analysis -- Complete HTTPS analysis of Kiwy-Signage player -- Identified 6 locations making HTTPS requests -- Documented lack of self-signed certificate support -- Created 4 comprehensive documentation files -- Provided ready-to-apply code patches -- Created visual architecture diagrams - ---- - -## Support and Questions - -If you have questions about: - -- **How HTTPS works in the player:** See [KIWY_PLAYER_HTTPS_ANALYSIS.md](./KIWY_PLAYER_HTTPS_ANALYSIS.md) -- **How to implement changes:** See [KIWY_PLAYER_SSL_PATCHES.md](./KIWY_PLAYER_SSL_PATCHES.md) -- **Specific code locations:** See [KIWY_PLAYER_HTTPS_QUICK_REF.md](./KIWY_PLAYER_HTTPS_QUICK_REF.md) -- **Visual understanding:** See [KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md](./KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md) - ---- - -## Document Status - -| Document | Status | Last Updated | Completeness | -|----------|--------|--------------|--------------| -| KIWY_PLAYER_HTTPS_ANALYSIS.md | ✅ Complete | 2026-01-16 | 100% | -| KIWY_PLAYER_HTTPS_QUICK_REF.md | ✅ Complete | 2026-01-16 | 100% | -| KIWY_PLAYER_SSL_PATCHES.md | ✅ Complete | 2026-01-16 | 100% | -| KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md | ✅ Complete | 2026-01-16 | 100% | - ---- - -## Next Steps - -1. **Read the main analysis:** Start with [KIWY_PLAYER_HTTPS_ANALYSIS.md](./KIWY_PLAYER_HTTPS_ANALYSIS.md) -2. **Review your requirements:** Decide if you need self-signed certificate support -3. **Plan implementation:** Use [KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md](./KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md) for deployment scenarios -4. **Apply patches:** Follow [KIWY_PLAYER_SSL_PATCHES.md](./KIWY_PLAYER_SSL_PATCHES.md) step by step -5. **Test thoroughly:** Verify with both production and self-signed servers -6. **Deploy:** Roll out to player devices and monitor logs - ---- - -**Created:** January 16, 2026 -**For:** DigiServer v2 Integration -**Repository:** https://gitea.moto-adv.com/ske087/Kiwy-Signage.git - diff --git a/old_code_documentation/player_analisis/KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md b/old_code_documentation/player_analisis/KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md deleted file mode 100644 index b6d02c9..0000000 --- a/old_code_documentation/player_analisis/KIWY_PLAYER_ARCHITECTURE_DIAGRAM.md +++ /dev/null @@ -1,482 +0,0 @@ -# Kiwy-Signage HTTPS Architecture Diagram - -## Current Architecture (Before Patches) - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Kiwy-Signage Player │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ GUI / Settings (main.py:696-703) │ │ -│ │ - Reads config/app_config.json │ │ -│ │ - Builds server URL │ │ -│ │ - Calls PlayerAuth.authenticate() │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ PlayerAuth (src/player_auth.py) │ │ -│ │ - authenticate() [Line 95] │ │ -│ │ - verify_auth() [Line 157] │ │ -│ │ - get_playlist() [Line 178] │ │ -│ │ - send_heartbeat() [Line 227] │ │ -│ │ - send_feedback() [Line 254] │ │ -│ │ │ │ -│ │ All use: requests.post/get(..., timeout=30) │ │ -│ │ ⚠️ verify parameter NOT SPECIFIED (uses default) │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Playlist Manager (src/get_playlists_v2.py) │ │ -│ │ - download_media_files() [Line 159] │ │ -│ │ - requests.get(file_url, timeout=30) │ │ -│ │ ⚠️ verify parameter NOT SPECIFIED │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Python requests Library (v2.32.4) │ │ -│ │ - Default: verify=True │ │ -│ │ - Validates against system CA certificates │ │ -│ │ - NO custom CA support in this application │ │ -│ │ - NO certificate pinning │ │ -│ │ - NO ignore certificate verification option │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ │ -└────────────────────────────┼────────────────────────────────────┘ - │ - ┌────────▼──────────┐ - │ HTTPS Handshake │ - ├───────────────────┤ - │ Validates cert: │ - │ ✓ Chain valid? │ - │ ✓ Hostname match? │ - │ ✓ Not expired? │ - │ ✓ In CA store? │ - └────────┬──────────┘ - │ - ┌────────────────────┼────────────────────┐ - │ │ │ - Success ❌ SELF-SIGNED │ Success ✅ - (Not in CA │ (CA-signed cert) - store) │ - │ ✓ Server - │ Certificate - │ Valid - │ - ┌────▼─────────────────────────────────────────┐ - │ SSLError: certificate verify failed │ - │ Application cannot connect to server │ - │ Player goes offline │ - └─────────────────────────────────────────────┘ - - -CURRENT LIMITATION: -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Player ONLY works with production certificates -that are signed by a trusted Certificate Authority -and present in the system's CA certificate store. -``` - ---- - -## After Patches - New Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Kiwy-Signage Player │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ GUI / Settings (main.py:696-703) │ │ -│ │ - Reads config/app_config.json │ │ -│ │ - Builds server URL │ │ -│ │ - Calls PlayerAuth.authenticate() │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ SSLConfig Module (src/ssl_config.py) ✨ NEW │ │ -│ │ - get_verify_setting() │ │ -│ │ - get_ca_bundle() │ │ -│ │ - set_ca_bundle(path) │ │ -│ │ - disable_verification() [dev/test only] │ │ -│ │ │ │ -│ │ Certificate Resolution Order: │ │ -│ │ 1. Custom CA set via set_ca_bundle() │ │ -│ │ 2. REQUESTS_CA_BUNDLE env var │ │ -│ │ 3. config/ca_bundle.crt (file in app) │ │ -│ │ 4. System default (True = certifi) │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ PlayerAuth (MODIFIED: src/player_auth.py) │ │ -│ │ - __init__(): self.verify_ssl = SSLConfig.get_...() │ │ -│ │ - authenticate(): verify=self.verify_ssl [Line 95] │ │ -│ │ - verify_auth(): verify=self.verify_ssl [Line 157] │ │ -│ │ - get_playlist(): verify=self.verify_ssl [Line 178] │ │ -│ │ - send_heartbeat(): verify=self.verify_ssl [Line 227] │ │ -│ │ - send_feedback(): verify=self.verify_ssl [Line 254] │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Playlist Manager (MODIFIED: get_playlists_v2.py) │ │ -│ │ - download_media_files(): verify=verify_ssl [Line 159] │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Python requests Library (v2.32.4) │ │ -│ │ - Uses verify parameter from SSLConfig │ │ -│ │ - Can use custom CA bundle (if provided) │ │ -│ │ - Validates against specified certificate │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ │ -└────────────────────────────┼────────────────────────────────────┘ - │ - ┌────────▼──────────┐ - │ HTTPS Handshake │ - ├───────────────────┤ - │ Validates against:│ - │ ✓ Custom CA │ - │ ✓ Hostname │ - │ ✓ Expiration │ - └────────┬──────────┘ - │ - ┌────────────────────┼────────────────────┐ - │ │ │ - Success ✅ Success ✅ Success ✅ - (Custom CA or (Self-signed + (Production - self-signed) ca_bundle.crt) cert) - │ │ │ - └────────────────┬───┴────────────────────┘ - │ - ┌────▼──────┐ - │ Connected! │ - │ Establish │ - │ secure │ - │ connection │ - └─────────────┘ - - -NEW CAPABILITY: -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Player works with: -✅ Production certificates (CA-signed) -✅ Self-signed certificates (with ca_bundle.crt) -✅ Custom CA certificates (with environment variable) -✅ Multiple certificate scenarios (dev, test, prod) -``` - ---- - -## Certificate Resolution Flow - -``` -When Player Starts - │ - ▼ -┌──────────────────┐ -│ PlayerAuth │ -│ __init__() │ -└────────┬─────────┘ - │ - │ Calls SSLConfig.get_verify_setting() - │ - ▼ - ┌──────────────────────────┐ - │ Check Priority Order │ - └──────────────┬───────────┘ - │ - ┌───────▼────────┐ - │ Is custom CA │──NO──┐ - │ set via code? │ │ - └───────────────┘ │ - │ YES │ - ▼ ▼ - Return path ┌──────────────────────┐ - │ Check environment │ - │ REQUESTS_CA_BUNDLE? │ - └────────┬─────────────┘ - │ - NO │ YES - ┌──────┘ ▼ - │ Return env - │ var path - ▼ - ┌──────────────────────┐ - │ Check config dir │ - │ config/ca_bundle.crt?│ - └────────┬─────────────┘ - │ - NO │ YES - ┌──────┘ ▼ - │ Return config - │ cert path - ▼ - ┌─────────────────┐ - │ No custom cert │ - │ found, use │ - │ system default │ - │ (True) │ - └─────────────────┘ - │ - ▼ - ┌──────────────────┐ - │ Pass to requests │ - │ library as │ - │ verify= │ - └──────────────────┘ - │ - ▼ - ┌──────────────────────────┐ - │ HTTPS Connection Made │ - │ With Selected Cert │ - └──────────────────────────┘ -``` - ---- - -## File Structure After Patches - -``` -Kiwy-Signage/ -├── config/ -│ ├── app_config.json (unchanged) -│ ├── ca_bundle.crt ✨ NEW (optional) -│ └── resources/ -│ -├── src/ -│ ├── main.py (unchanged) -│ ├── player_auth.py ✏️ MODIFIED (7 changes) -│ ├── get_playlists_v2.py ✏️ MODIFIED (2 changes) -│ ├── ssl_config.py ✨ NEW FILE (~60 lines) -│ ├── network_monitor.py (unchanged) -│ ├── edit_popup.py (unchanged) -│ └── keyboard_widget.py (unchanged) -│ -├── working_files/ (unchanged) -├── start.sh (unchanged) -├── requirements.txt (unchanged - no new packages!) -└── ... - -Changes Summary: -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -✨ New Files: 1 (ssl_config.py + ca_bundle.crt) -✏️ Modified Files: 2 (player_auth.py, get_playlists_v2.py) -📦 New Packages: 0 (uses existing requests library) -🔄 Backward Compat: Yes (all changes are additive) -⚠️ Breaking Chgs: None -``` - ---- - -## Deployment Scenarios - -### Scenario 1: Production Server (Current) - -``` -DigiServer v2 -(digi-signage.moto-adv.com) - │ - │ Valid CA Certificate - │ (e.g., Let's Encrypt) - │ - ▼ -Player (No patches needed) - │ - ▼ requests.post/get(..., timeout=30) - ├─ No verify= specified - └─ Uses system default: verify=True - │ - ▼ validates cert ✓ - │ - ▼ SSL handshake succeeds ✓ - │ - ▼ authenticated ✓ - - -Result: ✅ Works fine (no changes needed) -``` - ---- - -### Scenario 2: Self-Signed Server (After Patches) - -``` -DigiServer v2 (self.local) -(Self-signed certificate) - │ - │ 1. Export cert - │ openssl s_client... > server.crt - │ - │ 2. Place in player - │ config/ca_bundle.crt - │ - ▼ -Player (with patches) - │ - ▼ __init__() - │ - ▼ SSLConfig.get_verify_setting() - ├─ Check custom CA: None - ├─ Check env var: not set - ├─ Check config dir: ✓ found ca_bundle.crt - │ - └─ Return: 'config/ca_bundle.crt' - │ - ▼ requests.post/get(..., verify='config/ca_bundle.crt') - │ - ▼ validates cert against ca_bundle.crt ✓ - │ - ▼ SSL handshake succeeds ✓ - │ - ▼ authenticated ✓ - - -Result: ✅ Works with self-signed cert -``` - ---- - -### Scenario 3: Development (Insecure - Testing Only) - -``` -DigiServer v2 (test.local) -(Self-signed, or cert issues) - │ - ▼ -Player (with patches + SSLConfig.disable_verification()) - │ - ▼ SSLConfig.disable_verification() - │ - └─ _verify_ssl = False - │ - ▼ requests.post/get(..., verify=False) - │ - ▼ ⚠️ Skips certificate validation - │ - ▼ SSL handshake proceeds anyway ⚠️ - │ - ▼ authenticated (but insecure!) - - ⚠️ VULNERABLE TO MITM ATTACKS - - -Result: ⚠️ Works but insecure - DEV/TEST ONLY -Note: Add in code temporarily: - from ssl_config import SSLConfig - SSLConfig.disable_verification() # TEMPORARY - DEV ONLY -``` - ---- - -## Request Flow Sequence Diagram - -``` -Player SSLConfig requests DigiServer - │ │ │ │ - │─ authenticate()─│ │ │ - │ │ │ │ - │ get_verify_setting() │ │ - │ │ │ │ - │ ◄────────┤ 'config/ca... │ │ - │ │ bundle.crt' │ │ - │ │ │ │ - │ ┌──────────────┐ │ │ - │ │ requests.post( │ │ - │ │ url, │ │ - │ │ verify='config/ca... │ │ - │ │ bundle.crt', │ │ - │ │ ... │ │ - │ │ ) │ │ - │ └──────────────┘ │ │ - │ │ validate cert │ │ - │ │ against bundle◄──┤─ Server Cert ────┤ - │ │ │ (PEM format) │ - │ │ │ │ - │ │ │ ✓ Signature OK │ - │ │ │ ✓ Chain valid │ - │ │ │ ✓ Hostname match │ - │ │ │ │ - │ │ ◄────────────────┤─ 200 OK ─────────┤ - │ response ◄──────┤ │ {auth_code} │ - │ │ │ │ - │ Save auth_code │ │ │ - │ to file │ │ │ - │ │ │ │ -``` - ---- - -## Error Handling - -``` -BEFORE (Current): -─────────────── - -requests.post(url, ...) - │ - ├─ success → parse response - │ - └─ SSLError (self-signed cert) - │ - └─ Caught by: except Exception as e - │ - └─ error_msg = "Authentication error: ..." - │ - └─ User sees generic error ❌ - - -AFTER (With Patches): -───────────────────── - -requests.post(url, ..., verify=ca_bundle) - │ - ├─ success → parse response - │ (with custom CA support) - │ - └─ SSLError (cert not in bundle) - │ - └─ Caught by: except Exception as e - │ - └─ error_msg = "Authentication error: ..." - │ - └─ Log shows actual SSL error details ✓ - (if SSL validation fails, not player's fault) -``` - ---- - -## Security Comparison - -``` -Scenario: Self-Signed Certificate - -┌──────────────────┬──────────────────────┬─────────────────────┐ -│ Approach │ Security Level │ Recommendations │ -├──────────────────┼──────────────────────┼─────────────────────┤ -│ Do nothing │ 🔴 BROKEN │ ❌ Not viable │ -│ (current) │ - Player offline │ - App won't work │ -│ │ - No connection │ │ -├──────────────────┼──────────────────────┼─────────────────────┤ -│ verify=False │ ⚠️ INSECURE │ ⚠️ DEV/TEST ONLY │ -│ (disable verify) │ - Vulnerable to MITM │ - Never production │ -│ │ - No cert validation │ - Temporary measure │ -├──────────────────┼──────────────────────┼─────────────────────┤ -│ Custom CA bundle │ ✅ SECURE │ ✅ RECOMMENDED │ -│ (patches) │ - Validates cert │ - Works with any │ -│ │ - CA is trusted │ self-signed cert │ -│ │ - No MITM risk │ - Production-ready │ -├──────────────────┼──────────────────────┼─────────────────────┤ -│ Cert pinning │ 🔒 VERY SECURE │ ✅ IF NEEDED │ -│ (advanced) │ - Pins specific cert │ - Extra complexity │ -│ │ - Maximum trust │ - For high-security │ -│ │ │ deployments │ -└──────────────────┴──────────────────────┴─────────────────────┘ -``` - diff --git a/old_code_documentation/player_analisis/KIWY_PLAYER_HTTPS_ANALYSIS.md b/old_code_documentation/player_analisis/KIWY_PLAYER_HTTPS_ANALYSIS.md deleted file mode 100644 index 1da95fe..0000000 --- a/old_code_documentation/player_analisis/KIWY_PLAYER_HTTPS_ANALYSIS.md +++ /dev/null @@ -1,583 +0,0 @@ -# Kiwy-Signage Player - HTTPS/SSL Certificate Analysis - -## Executive Summary - -The Kiwy-Signage player is a Python-based digital signage application built with Kivy that communicates with the DigiServer v2 backend. **The player currently has NO custom SSL certificate verification mechanism and relies entirely on Python's `requests` library default behavior.** - -This means: -- ✅ HTTPS connections to production servers work because they have valid CA-signed certificates -- ❌ Self-signed certificates or custom certificate authorities will **FAIL** without code modifications -- ❌ No `verify` parameter is passed to any requests calls (uses default `verify=True`) -- ❌ No support for custom CA certificates or certificate bundles - ---- - -## 1. HTTP Client Library & Dependencies - -### Library Used -- **requests** (version 2.32.4) - Python HTTP library with SSL verification enabled by default -- **aiohttp** (version 3.9.1) - Not currently used for player authentication/API calls - -### Dependency Chain -``` -requirements.txt: - - kivy>=2.3.0 - - ffpyplayer - - requests==2.32.4 ← Used for ALL HTTPS requests - - bcrypt==4.2.1 - - aiohttp==3.9.1 - - asyncio==3.4.3 -``` - ---- - -## 2. Main Connection Files & Locations - -### Core Authentication Module -**File:** [src/player_auth.py](../../tmp/Kiwy-Signage/src/player_auth.py) -**Lines:** 352 lines total -**Responsibility:** Handles all server authentication and API communication - -### Playlist Management -**File:** [src/get_playlists_v2.py](../../tmp/Kiwy-Signage/src/get_playlists_v2.py) -**Lines:** 352 lines total -**Responsibility:** Fetches and manages playlists, uses PlayerAuth for communication - -### Network Monitoring -**File:** [src/network_monitor.py](../../tmp/Kiwy-Signage/src/network_monitor.py) -**Lines:** 235 lines total -**Responsibility:** Monitors connectivity using ping (not HTTPS), manages WiFi restarts - -### Main GUI Application -**File:** [src/main.py](../../tmp/Kiwy-Signage/src/main.py) -**Lines:** 1,826 lines total -**Responsibility:** Kivy GUI, server connection settings, calls PlayerAuth for authentication - -### Configuration File -**File:** [config/app_config.json](../../tmp/Kiwy-Signage/config/app_config.json) -**Responsibility:** Stores server IP, port, player credentials, and settings - ---- - -## 3. HTTPS Connection Architecture - -### Authentication Flow -``` -1. Player Configuration (config/app_config.json) - ├─ server_ip: "digi-signage.moto-adv.com" - ├─ port: "443" - ├─ screen_name: "player-name" - └─ quickconnect_key: "QUICK123" - -2. URL Construction (src/main.py, lines 696-703) - ├─ If server_ip has http:// or https:// prefix, use as-is - ├─ Otherwise: protocol = "https" if port == "443" else "http" - └─ server_url = f"{protocol}://{server_ip}:{port}" - -3. Authentication Request (src/player_auth.py, lines 95-98) - ├─ POST /api/auth/player - ├─ Payload: {hostname, password, quickconnect_code} - └─ Returns: {auth_code, player_id, player_name, playlist_id, ...} - -4. Authenticated API Calls (src/player_auth.py, lines 159-163, etc.) - ├─ Headers: Authorization: Bearer {auth_code} - └─ GET/POST to various /api/... endpoints -``` - -### All HTTPS Request Points in Code - -#### 1. **Authentication** (src/player_auth.py) - -**Location:** [Line 95](../../tmp/Kiwy-Signage/src/player_auth.py#L95) -```python -response = requests.post(auth_url, json=payload, timeout=timeout) -``` -- **URL:** `{server_url}/api/auth/player` -- **Method:** POST -- **Auth:** None (initial auth) -- **SSL Verify:** DEFAULT (True, no custom handling) - -**Location:** [Line 157](../../tmp/Kiwy-Signage/src/player_auth.py#L157) -```python -response = requests.post(verify_url, json=payload, timeout=timeout) -``` -- **URL:** `{server_url}/api/auth/verify` -- **Method:** POST -- **Auth:** None -- **SSL Verify:** DEFAULT (True) - -#### 2. **Playlist Fetching** (src/player_auth.py) - -**Location:** [Line 178](../../tmp/Kiwy-Signage/src/player_auth.py#L178) -```python -response = requests.get(playlist_url, headers=headers, timeout=timeout) -``` -- **URL:** `{server_url}/api/playlists/{player_id}` -- **Method:** GET -- **Auth:** Bearer token in Authorization header -- **Headers:** `Authorization: Bearer {auth_code}` -- **SSL Verify:** DEFAULT (True, **NO `verify=` parameter**) - -#### 3. **Heartbeat/Status** (src/player_auth.py) - -**Location:** [Line 227](../../tmp/Kiwy-Signage/src/player_auth.py#L227) -```python -response = requests.post(heartbeat_url, headers=headers, json=payload, timeout=timeout) -``` -- **URL:** `{server_url}/api/players/{player_id}/heartbeat` -- **Method:** POST -- **Auth:** Bearer token -- **SSL Verify:** DEFAULT (True, **NO `verify=` parameter**) - -#### 4. **Player Feedback** (src/player_auth.py) - -**Location:** [Line 254](../../tmp/Kiwy-Signage/src/player_auth.py#L254) -```python -response = requests.post(feedback_url, headers=headers, json=payload, timeout=timeout) -``` -- **URL:** `{server_url}/api/player-feedback` -- **Method:** POST -- **Auth:** Bearer token -- **SSL Verify:** DEFAULT (True, **NO `verify=` parameter**) - -#### 5. **Media Download** (src/get_playlists_v2.py) - -**Location:** [Line 159](../../tmp/Kiwy-Signage/src/get_playlists_v2.py#L159) -```python -response = requests.get(file_url, timeout=30) -``` -- **URL:** Direct to media file URLs from playlist -- **Method:** GET -- **Auth:** None (public download URLs) -- **SSL Verify:** DEFAULT (True, **NO `verify=` parameter**) - ---- - -## 4. Certificate Verification Current Configuration - -### Current SSL/Certificate Behavior - -**Summary:** Relies entirely on Python's `requests` library defaults. - -**Default requests behavior:** -- `verify=True` (implicitly used when not specified) -- Uses system CA certificate store -- Validates certificate chain, hostname, and expiration -- Rejects self-signed certificates with error - -### Hardcoded Certificate Settings -🔴 **NONE** - No hardcoded SSL certificate settings exist in the codebase. - -### Certificate Verification Code Locations - -**Search Results for "verify", "ssl", "cert", "certificate":** - -Only `verify_auth()` method found (authenticates with server, not certificate verification): -- [src/player_auth.py, Line 137](../../tmp/Kiwy-Signage/src/player_auth.py#L137) - `def verify_auth(self, timeout: int = 10)` -- [src/player_auth.py, Line 153](../../tmp/Kiwy-Signage/src/player_auth.py#L153) - `verify_url = f"{server_url}/api/auth/verify"` - -**No SSL/certificate configuration found in:** -- ❌ requests library verify parameter -- ❌ Custom CA bundle paths -- ❌ SSL context configuration -- ❌ Certificate pinning -- ❌ urllib3 certificate settings - ---- - -## 5. Self-Signed Certificate Support - -### Current State: ❌ NOT SUPPORTED - -When connecting to a server with a self-signed certificate: - -```python -# Current code (player_auth.py, Line 95): -response = requests.post(auth_url, json=payload, timeout=timeout) - -# Will raise: -# requests.exceptions.SSLError: -# ("certificate verify failed: self signed certificate (_ssl.c:...) -``` - -### Exception Handling -The code catches exceptions but doesn't differentiate SSL errors: - -```python -# player_auth.py, lines 111-127 -except requests.exceptions.ConnectionError: - error_msg = "Cannot connect to server" -except requests.exceptions.Timeout: - error_msg = "Connection timeout" -except Exception as e: - error_msg = f"Authentication error: {str(e)}" - # Will catch SSL errors here but label them as generic "Authentication error" -``` - ---- - -## 6. Required Changes for Self-Signed Certificate Support - -### Option 1: Disable Certificate Verification (⚠️ INSECURE - Development Only) - -**Not Recommended for Production** - -Add to each `requests` call: -```python -verify=False # Disables SSL certificate verification -``` - -**Example modification:** -```python -# OLD (player_auth.py, Line 95): -response = requests.post(auth_url, json=payload, timeout=timeout) - -# NEW: -response = requests.post(auth_url, json=payload, timeout=timeout, verify=False) -``` - -**Locations requiring modification (5 places):** -1. [src/player_auth.py, Line 95](../../tmp/Kiwy-Signage/src/player_auth.py#L95) - authenticate() method -2. [src/player_auth.py, Line 157](../../tmp/Kiwy-Signage/src/player_auth.py#L157) - verify_auth() method -3. [src/player_auth.py, Line 178](../../tmp/Kiwy-Signage/src/player_auth.py#L178) - get_playlist() method -4. [src/player_auth.py, Line 227](../../tmp/Kiwy-Signage/src/player_auth.py#L227) - send_heartbeat() method -5. [src/player_auth.py, Line 254](../../tmp/Kiwy-Signage/src/player_auth.py#L254) - send_feedback() method -6. [src/get_playlists_v2.py, Line 159](../../tmp/Kiwy-Signage/src/get_playlists_v2.py#L159) - download_media_files() method - ---- - -### Option 2: Custom CA Certificate Bundle (✅ RECOMMENDED) - -**Production-Ready Approach** - -#### Step 1: Create certificate configuration -```python -# New file: src/ssl_config.py -import os -import requests - -class SSLConfig: - """Manage SSL certificate verification for self-signed certs""" - - @staticmethod - def get_ca_bundle(): - """Get path to CA certificate bundle - - Returns: - str: Path to CA bundle or True for default system certs - """ - # Priority order: - # 1. Custom CA bundle in config directory - # 2. CA bundle path from environment variable - # 3. System default CA bundle (requests uses certifi) - - custom_ca = 'config/ca_bundle.crt' - if os.path.exists(custom_ca): - return custom_ca - - env_ca = os.environ.get('REQUESTS_CA_BUNDLE') - if env_ca and os.path.exists(env_ca): - return env_ca - - return True # Use system/certifi default - - @staticmethod - def get_verify_setting(): - """Get SSL verification setting - - Returns: - bool or str: Path to CA bundle or True/False - """ - return SSLConfig.get_ca_bundle() -``` - -#### Step 2: Modify PlayerAuth to use custom certificates - -```python -# player_auth.py modifications: - -from ssl_config import SSLConfig # Add import - -class PlayerAuth: - def __init__(self, config_file='player_auth.json'): - self.config_file = config_file - self.auth_data = self._load_auth_data() - self.verify_ssl = SSLConfig.get_verify_setting() # Add this - - def authenticate(self, ...): - # Add verify parameter to requests call: - response = requests.post( - auth_url, - json=payload, - timeout=timeout, - verify=self.verify_ssl # ADD THIS - ) - - def verify_auth(self, ...): - response = requests.post( - verify_url, - json=payload, - timeout=timeout, - verify=self.verify_ssl # ADD THIS - ) - - def get_playlist(self, ...): - response = requests.get( - playlist_url, - headers=headers, - timeout=timeout, - verify=self.verify_ssl # ADD THIS - ) - - def send_heartbeat(self, ...): - response = requests.post( - heartbeat_url, - headers=headers, - json=payload, - timeout=timeout, - verify=self.verify_ssl # ADD THIS - ) - - def send_feedback(self, ...): - response = requests.post( - feedback_url, - headers=headers, - json=payload, - timeout=timeout, - verify=self.verify_ssl # ADD THIS - ) -``` - -#### Step 3: Handle media downloads - -```python -# get_playlists_v2.py modifications: - -from ssl_config import SSLConfig - -def download_media_files(playlist, media_dir): - verify_ssl = SSLConfig.get_verify_setting() # Add this - - for media in playlist: - ... - response = requests.get( - file_url, - timeout=30, - verify=verify_ssl # ADD THIS - ) - ... -``` - -#### Step 4: Prepare CA certificate - -1. **Export certificate from self-signed server:** -```bash -openssl s_client -connect server.local:443 -showcerts < /dev/null | \ - openssl x509 -outform PEM > ca_bundle.crt -``` - -2. **Place in player config:** -```bash -cp ca_bundle.crt /path/to/Kiwy-Signage/config/ca_bundle.crt -``` - -3. **Or set environment variable:** -```bash -export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-bundle.crt -``` - ---- - -### Option 3: Certificate Pinning (⚠️ Advanced) - -For maximum security when using self-signed certificates: - -```python -import ssl -import certifi -import requests -from requests.adapters import HTTPAdapter -from urllib3.util.ssl_ import create_urllib3_context - -class SSLPinningAdapter(HTTPAdapter): - def init_poolmanager(self, *args, **kwargs): - ctx = create_urllib3_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - # Or use specific certificate: - # ctx.load_verify_locations('config/server_cert.pem') - kwargs['ssl_context'] = ctx - return super().init_poolmanager(*args, **kwargs) - -# Usage in PlayerAuth: -session = requests.Session() -session.mount('https://', SSLPinningAdapter()) -response = session.post(auth_url, json=payload, timeout=timeout) -``` - ---- - -## 7. Testing Self-Signed Certificate Connections - -### Before Modification (Current Behavior) - -Test connection to self-signed server: -```bash -cd /tmp/Kiwy-Signage -python3 -c " -import requests -url = 'https://your-self-signed-server:443/api/health' -try: - response = requests.get(url) - print('Connection successful') -except requests.exceptions.SSLError as e: - print(f'SSL Error: {e}') -" -# Output: SSL Error: certificate verify failed -``` - -### After Modification (With Custom CA) - -```bash -cd /tmp/Kiwy-Signage -# Place ca_bundle.crt in config/ -python3 -c " -import requests -url = 'https://your-self-signed-server:443/api/health' -response = requests.get(url, verify='config/ca_bundle.crt') -print(f'Connection successful: {response.status_code}') -" -# Output: Connection successful: 200 -``` - ---- - -## 8. Summary Table - -| Aspect | Current State | Support Level | -|--------|---------------|----------------| -| **HTTP Client** | requests 2.32.4 | ✅ Production-ready | -| **HTTPS Support** | Yes (standard URLs) | ✅ Full | -| **Self-Signed Certs** | ❌ NO | ❌ NOT SUPPORTED | -| **Custom CA Bundle** | ❌ NO | ❌ NOT SUPPORTED | -| **Certificate Pinning** | ❌ NO | ❌ NOT SUPPORTED | -| **SSL Verify Parameter** | Default (True) | ⚠️ All requests use default | -| **Hardcoded Settings** | None | - | -| **Environment Variables** | Not checked | ⚠️ Could be added | -| **Configuration File** | app_config.json (no SSL options) | ⚠️ Could be extended | - ---- - -## 9. Integration with DigiServer v2 - -### Current Communication Protocol - -The player communicates with DigiServer v2 using: - -1. **Initial Authentication (HTTP/HTTPS)** - - Endpoint: `POST /api/auth/player` - - Payload: `{hostname, password, quickconnect_code}` - - Response: `{auth_code, player_id, player_name, ...}` - -2. **All Subsequent Requests (HTTP/HTTPS)** - - Header: `Authorization: Bearer {auth_code}` - - Endpoints: - - `GET /api/playlists/{player_id}` - - `POST /api/players/{player_id}/heartbeat` - - `POST /api/player-feedback` - -3. **Media Downloads (HTTP/HTTPS)** - - Direct URLs from playlist: `{server_url}/uploads/...` - -### Server Configuration (config/app_config.json) - -```json -{ - "server_ip": "digi-signage.moto-adv.com", - "port": "443", - "screen_name": "tv-terasa", - "quickconnect_key": "8887779", - "orientation": "Landscape", - "touch": "True", - "max_resolution": "1920x1080", - "edit_feature_enabled": true -} -``` - -### ⚠️ NOTE: No SSL/certificate options in config - -The application accepts server_ip, port, hostname, and credentials, but: -- ❌ No way to specify CA certificate path -- ❌ No way to disable SSL verification -- ❌ No way to enable certificate pinning - ---- - -## 10. Recommended Implementation Plan - -### For Self-Signed Certificate Support: - -**Step 1: Add SSL Configuration Module** (5-10 min) -- Create `src/ssl_config.py` with SSLConfig class -- Support for custom CA bundle path - -**Step 2: Modify PlayerAuth** (10-15 min) -- Add `verify_ssl` parameter to `__init__` -- Update all 5 `requests` calls to include `verify=self.verify_ssl` -- Improve SSL error handling/reporting - -**Step 3: Update Configuration** (5 min) -- Extend `config/app_config.json` to include optional `ca_bundle_path` -- Or use environment variable `REQUESTS_CA_BUNDLE` - -**Step 4: Documentation** (5 min) -- Add README section on SSL certificate configuration -- Document how to export and place CA certificates - -**Step 5: Testing** (10-15 min) -- Test with self-signed certificate -- Verify backward compatibility with valid CA certs - -**Total Time Estimate:** 35-50 minutes for complete implementation - ---- - -## 11. Code References - -### All requests calls in codebase: - -``` -src/player_auth.py: - Line 95: requests.post(auth_url, json=payload, timeout=timeout) - Line 157: requests.post(verify_url, json=payload, timeout=timeout) - Line 178: requests.get(playlist_url, headers=headers, timeout=timeout) - Line 227: requests.post(heartbeat_url, headers=headers, json=payload, timeout=timeout) - Line 254: requests.post(feedback_url, headers=headers, json=payload, timeout=timeout) - -src/get_playlists_v2.py: - Line 159: requests.get(file_url, timeout=30) - -working_files/test_direct_api.py: - Line 32: requests.get(url, headers=headers, timeout=10) - -working_files/get_playlists.py: - Line 101: requests.post(feedback_url, json=feedback_data, timeout=10) - Line 131: requests.get(server_url, params=params) - Line 139: requests.get(file_url, timeout=10) -``` - -All calls use default `verify=True` (implicit). - ---- - -## Conclusion - -The Kiwy-Signage player is a well-structured Python application that properly uses the `requests` library for HTTPS communication. However, it currently **does not support self-signed certificates or custom certificate authorities** without code modifications. - -To support self-signed certificates, implementing Option 2 (Custom CA Certificate Bundle) is recommended as it: -- ✅ Maintains security for production deployments -- ✅ Allows flexibility for self-signed/internal CAs -- ✅ Requires minimal code changes (5-6 request calls) -- ✅ Follows Python best practices -- ✅ Is backward compatible with existing deployments - diff --git a/old_code_documentation/player_analisis/KIWY_PLAYER_HTTPS_QUICK_REF.md b/old_code_documentation/player_analisis/KIWY_PLAYER_HTTPS_QUICK_REF.md deleted file mode 100644 index bf2098f..0000000 --- a/old_code_documentation/player_analisis/KIWY_PLAYER_HTTPS_QUICK_REF.md +++ /dev/null @@ -1,319 +0,0 @@ -# Kiwy-Signage HTTPS Configuration - Quick Reference - -## Quick Facts - -| Item | Value | -|------|-------| -| **HTTP Client Library** | `requests` v2.32.4 | -| **Self-Signed Cert Support** | ❌ NO (requires code changes) | -| **Custom CA Bundle Support** | ❌ NO (requires code changes) | -| **Certificate Verification** | ✅ Enabled by default (requests default behavior) | -| **Lines of Code Making HTTPS Requests** | 6 locations across 2 files | - ---- - -## Where HTTPS Requests Are Made - -### Core Authentication (player_auth.py) - -```python -# LINE 95: Initial authentication -response = requests.post(auth_url, json=payload, timeout=timeout) - -# LINE 157: Auth verification -response = requests.post(verify_url, json=payload, timeout=timeout) - -# LINE 178: Get playlist -response = requests.get(playlist_url, headers=headers, timeout=timeout) - -# LINE 227: Send heartbeat -response = requests.post(heartbeat_url, headers=headers, json=payload, timeout=timeout) - -# LINE 254: Send feedback -response = requests.post(feedback_url, headers=headers, json=payload, timeout=timeout) -``` - -### Media Downloads (get_playlists_v2.py) - -```python -# LINE 159: Download media file -response = requests.get(file_url, timeout=30) -``` - ---- - -## What Gets Sent Over HTTPS - -### 1. Authentication Request → Server -```json -POST {server_url}/api/auth/player -{ - "hostname": "player-name", - "password": "optional-password", - "quickconnect_code": "QUICK123" -} -``` - -### 2. Server Response → Player -```json -{ - "auth_code": "eyJhbGc...", - "player_id": 42, - "player_name": "TV-Terasa", - "playlist_id": 100, - "orientation": "Landscape" -} -``` - -### 3. Subsequent Requests (With Auth Token) -``` -GET {server_url}/api/playlists/{player_id} -Header: Authorization: Bearer {auth_code} -``` - ---- - -## The Problem with Self-Signed Certificates - -When a player tries to connect to a server with a self-signed certificate: - -``` -SSL/TLS Handshake: - ✓ Server presents self-signed certificate - ✗ requests library validates against system CA store - ✗ Self-signed cert NOT in system CA store - ✗ Connection rejected with SSLError - -Result: Player fails to authenticate → Player is offline -``` - ---- - -## How to Enable Self-Signed Certificate Support - -### Quickest Fix (Development/Testing Only) -⚠️ **NOT RECOMMENDED FOR PRODUCTION** - -Disable certificate verification in all requests: -```python -response = requests.post(url, ..., verify=False) # Dangerous! -``` - -### Proper Fix (Production-Ready) - -#### Step 1: Export server's certificate -```bash -# From the server with self-signed cert -openssl s_client -connect server.local:443 -showcerts < /dev/null | \ - openssl x509 -outform PEM > ca_bundle.crt -``` - -#### Step 2: Place certificate in player -```bash -cp ca_bundle.crt /path/to/Kiwy-Signage/config/ca_bundle.crt -``` - -#### Step 3: Modify player code to use it - -Create `src/ssl_config.py`: -```python -import os - -class SSLConfig: - @staticmethod - def get_verify_setting(): - """Get SSL verification setting""" - custom_ca = 'config/ca_bundle.crt' - if os.path.exists(custom_ca): - return custom_ca - return True # System default -``` - -Modify `src/player_auth.py`: -```python -from ssl_config import SSLConfig - -class PlayerAuth: - def __init__(self, config_file='player_auth.json'): - self.config_file = config_file - self.auth_data = self._load_auth_data() - self.verify_ssl = SSLConfig.get_verify_setting() - - def authenticate(self, ...): - response = requests.post( - auth_url, - json=payload, - timeout=timeout, - verify=self.verify_ssl # ← ADD THIS - ) - - # Repeat for: verify_auth(), get_playlist(), - # send_heartbeat(), send_feedback() -``` - -Modify `src/get_playlists_v2.py`: -```python -from ssl_config import SSLConfig - -def download_media_files(playlist, media_dir): - verify_ssl = SSLConfig.get_verify_setting() - for media in playlist: - response = requests.get( - file_url, - timeout=30, - verify=verify_ssl # ← ADD THIS - ) -``` - ---- - -## Configuration Files - -### Player Configuration (read by player) -**File:** `config/app_config.json` -```json -{ - "server_ip": "digi-signage.moto-adv.com", - "port": "443", - "screen_name": "tv-terasa", - "quickconnect_key": "8887779", - "orientation": "Landscape", - "touch": "True", - "max_resolution": "1920x1080", - "edit_feature_enabled": true -} -``` - -**⚠️ Note:** No SSL/certificate options available - -### Player Auth (saved after first connection) -**File:** `src/player_auth.json` (or configured path) -```json -{ - "hostname": "tv-terasa", - "auth_code": "eyJhbGc...", - "player_id": 42, - "player_name": "TV-Terasa", - "playlist_id": 100, - "orientation": "Landscape", - "authenticated": true, - "server_url": "https://digi-signage.moto-adv.com:443" -} -``` - ---- - -## Network Flow - -``` -Kiwy-Signage Player DigiServer v2 - │ │ - │ 1. Build Server URL │ - │ (http/https + port) │ - │ │ - │ 2. POST /api/auth/player ──────→ │ - │ (quickconnect_code) │ - │ │ - │ ← Response (auth_code) │ - │ │ - │ 3. GET /api/playlists/... ──────→ │ - │ (Authorization: Bearer) │ - │ │ - │ ← Playlist JSON │ - │ │ - │ 4. GET /uploads/... ─────────────→ │ - │ (download media files) │ - │ │ - │ ← Media file bytes │ - │ │ - │ 5. POST /heartbeat ────────────→ │ - │ (player status: online/err) │ - │ │ -``` - ---- - -## SSL Error Troubleshooting - -### Error: `certificate verify failed` -**Cause:** Server has self-signed certificate -**Solution:** Export and use CA bundle (see "Proper Fix" above) - -### Error: `SSLError: [SSL: CERTIFICATE_VERIFY_FAILED]` -**Cause:** Same as above -**Solution:** Add `verify=ca_bundle_path` to requests calls - -### Error: `Cannot connect to server` (generic) -**Cause:** Could be SSL error caught by try-except -**Solution:** Check logs, enable debug mode, test with `curl`: -```bash -curl -v https://server:443/api/health -``` - -### Works with `curl -k` but fails with player -**Cause:** Player has certificate verification, curl doesn't -**Solution:** Use proper CA certificate instead of `-k` flag - ---- - -## Testing - -### Test Current Behavior -```bash -cd /tmp/Kiwy-Signage -python3 -c " -import sys -sys.path.insert(0, 'src') -from player_auth import PlayerAuth - -auth = PlayerAuth() -success, error = auth.authenticate( - server_url='https://server.local:443', - hostname='test-player', - quickconnect_code='TEST123' -) -print(f'Result: {success}, Error: {error}') -" -``` - -### Test With Custom CA -```bash -# After implementing ssl_config.py: -export REQUESTS_CA_BUNDLE=/path/to/ca_bundle.crt -cd /tmp/Kiwy-Signage -python3 src/main.py -``` - ---- - -## Summary of Changes Needed - -| File | Changes | Lines | -|------|---------|-------| -| `src/ssl_config.py` | **CREATE NEW** - SSL config class | ~20 lines | -| `src/player_auth.py` | Add `verify_ssl` to `__init__` | +1 line | -| `src/player_auth.py` | Add `verify=` to 5 request calls | +5 lines | -| `src/get_playlists_v2.py` | Add `verify=` to 1 request call | +1 line | -| `config/app_config.json` | Optional: Add `ca_bundle_path` key | +1 line | -| `config/ca_bundle.crt` | **CREATE** - From server cert | - | - -**Total Code Changes:** ~8 modified lines + 1 new file (20 lines) -**Backward Compatible:** Yes -**Breaking Changes:** None - ---- - -## Recommended Next Steps - -1. ✅ Review this analysis -2. ✅ Decide between: - - Using `verify=False` (quick, insecure) - - Implementing custom CA support (proper, secure) - - Sticking with production certs (safest) -3. ✅ If using custom CA: - - Export certificate from your DigiServer - - Place in `config/ca_bundle.crt` - - Implement changes from "Proper Fix" section -4. ✅ Test with both production and self-signed servers -5. ✅ Document in player README - diff --git a/old_code_documentation/player_analisis/KIWY_PLAYER_SSL_PATCHES.md b/old_code_documentation/player_analisis/KIWY_PLAYER_SSL_PATCHES.md deleted file mode 100644 index 80c869c..0000000 --- a/old_code_documentation/player_analisis/KIWY_PLAYER_SSL_PATCHES.md +++ /dev/null @@ -1,414 +0,0 @@ -# Kiwy-Signage Self-Signed Certificate Support - Code Patches - -This file contains exact code patches ready to apply to enable self-signed certificate support. - -## PATCH 1: Create ssl_config.py - -**File:** `Kiwy-Signage/src/ssl_config.py` (NEW FILE) - -```python -""" -SSL Configuration Module for Kiwy-Signage -Handles certificate verification for self-signed and custom CA certificates -""" - -import os -import logging - -logger = logging.getLogger(__name__) - - -class SSLConfig: - """Manage SSL certificate verification settings""" - - # Default to True (use system CA certificates) - _custom_ca_path = None - _verify_ssl = True - - @classmethod - def get_ca_bundle(cls): - """Get path to CA certificate bundle for verification - - Priority order: - 1. Custom CA bundle path specified via set_ca_bundle() - 2. CA bundle path from REQUESTS_CA_BUNDLE environment variable - 3. CA bundle in config/ca_bundle.crt - 4. System default CA bundle (True = use system certs) - - Returns: - str or bool: Path to CA bundle file or True for system default - """ - # Check if custom CA was explicitly set - if cls._custom_ca_path: - if os.path.exists(cls._custom_ca_path): - logger.info(f"Using custom CA bundle: {cls._custom_ca_path}") - return cls._custom_ca_path - else: - logger.warning(f"Custom CA bundle not found: {cls._custom_ca_path}, falling back to system") - - # Check environment variable - env_ca = os.environ.get('REQUESTS_CA_BUNDLE') - if env_ca and os.path.exists(env_ca): - logger.info(f"Using CA bundle from REQUESTS_CA_BUNDLE: {env_ca}") - return env_ca - - # Check config directory - config_ca = 'config/ca_bundle.crt' - if os.path.exists(config_ca): - logger.info(f"Using CA bundle from config: {config_ca}") - return config_ca - - # Use system default - logger.debug("Using system default CA certificates") - return True - - @classmethod - def get_verify_setting(cls): - """Get the 'verify' parameter for requests calls - - Returns: - bool or str: Value to pass as 'verify=' parameter to requests - """ - if not cls._verify_ssl: - logger.warning("SSL verification is DISABLED - this is insecure!") - return False - - return cls.get_ca_bundle() - - @classmethod - def set_ca_bundle(cls, ca_path): - """Manually set custom CA bundle path - - Args: - ca_path (str): Path to CA certificate file - """ - if os.path.exists(ca_path): - cls._custom_ca_path = ca_path - logger.info(f"CA bundle set to: {ca_path}") - else: - logger.error(f"CA bundle file not found: {ca_path}") - - @classmethod - def disable_verification(cls): - """DANGER: Disable SSL certificate verification - - ⚠️ WARNING: Only use for development/testing! - This makes the application vulnerable to MITM attacks. - """ - cls._verify_ssl = False - logger.critical("⚠️ SSL VERIFICATION DISABLED - This is insecure!") - - @classmethod - def enable_verification(cls): - """Enable SSL certificate verification (default)""" - cls._verify_ssl = True - logger.info("SSL verification enabled") - - @classmethod - def is_verification_enabled(cls): - """Check if SSL verification is enabled - - Returns: - bool: True if verification is enabled, False if disabled - """ - return cls._verify_ssl -``` - ---- - -## PATCH 2: Modify src/player_auth.py - -**Location:** `Kiwy-Signage/src/player_auth.py` - -### Change 2a: Add import at top of file - -```python -# AFTER line 10 (after existing imports), ADD: - -from ssl_config import SSLConfig -``` - -### Change 2b: Modify __init__ method (lines 20-30) - -**BEFORE:** -```python -def __init__(self, config_file: str = 'player_auth.json'): - """Initialize player authentication. - - Args: - config_file: Path to authentication config file - """ - self.config_file = config_file - self.auth_data = self._load_auth_data() -``` - -**AFTER:** -```python -def __init__(self, config_file: str = 'player_auth.json'): - """Initialize player authentication. - - Args: - config_file: Path to authentication config file - """ - self.config_file = config_file - self.auth_data = self._load_auth_data() - self.verify_ssl = SSLConfig.get_verify_setting() -``` - -### Change 2c: Modify authenticate() method (line 95) - -**BEFORE:** -```python -response = requests.post(auth_url, json=payload, timeout=timeout) -``` - -**AFTER:** -```python -response = requests.post(auth_url, json=payload, timeout=timeout, verify=self.verify_ssl) -``` - -### Change 2d: Modify verify_auth() method (line 157) - -**BEFORE:** -```python -response = requests.post(verify_url, json=payload, timeout=timeout) -``` - -**AFTER:** -```python -response = requests.post(verify_url, json=payload, timeout=timeout, verify=self.verify_ssl) -``` - -### Change 2e: Modify get_playlist() method (line 178) - -**BEFORE:** -```python -response = requests.get(playlist_url, headers=headers, timeout=timeout) -``` - -**AFTER:** -```python -response = requests.get(playlist_url, headers=headers, timeout=timeout, verify=self.verify_ssl) -``` - -### Change 2f: Modify send_heartbeat() method (line 227-228) - -**BEFORE:** -```python -response = requests.post(heartbeat_url, headers=headers, - json=payload, timeout=timeout) -``` - -**AFTER:** -```python -response = requests.post(heartbeat_url, headers=headers, - json=payload, timeout=timeout, verify=self.verify_ssl) -``` - -### Change 2g: Modify send_feedback() method (line 254-255) - -**BEFORE:** -```python -response = requests.post(feedback_url, headers=headers, - json=payload, timeout=timeout) -``` - -**AFTER:** -```python -response = requests.post(feedback_url, headers=headers, - json=payload, timeout=timeout, verify=self.verify_ssl) -``` - ---- - -## PATCH 3: Modify src/get_playlists_v2.py - -**Location:** `Kiwy-Signage/src/get_playlists_v2.py` - -### Change 3a: Add import (after line 6) - -```python -# AFTER line 6 (after "from player_auth import PlayerAuth"), ADD: - -from ssl_config import SSLConfig -``` - -### Change 3b: Modify download_media_files() function (line 159) - -**BEFORE:** -```python -response = requests.get(file_url, timeout=30) -``` - -**AFTER:** -```python -verify_ssl = SSLConfig.get_verify_setting() -response = requests.get(file_url, timeout=30, verify=verify_ssl) -``` - ---- - -## PATCH 4: Extract Server Certificate - -**Steps to follow on the DigiServer:** - -```bash -#!/bin/bash -# Run this on the DigiServer with self-signed certificate - -# Export the certificate -openssl s_client -connect localhost:443 -showcerts < /dev/null | \ - openssl x509 -outform PEM > /tmp/server_cert.crt - -# Copy to player configuration directory -# (transfer via SSH, USB, or other secure method) -cp /tmp/server_cert.crt /path/to/Kiwy-Signage/config/ca_bundle.crt - -# Verify it was copied correctly -ls -la /path/to/Kiwy-Signage/config/ca_bundle.crt -``` - ---- - -## PATCH 5: Alternative - Use Environment Variable - -Instead of placing cert in config directory, you can use environment variable: - -```bash -#!/bin/bash -# Before running the player: - -export REQUESTS_CA_BUNDLE=/etc/ssl/certs/custom-ca.crt -cd /path/to/Kiwy-Signage -./start.sh -``` - ---- - -## Testing After Patches - -### Test 1: Verify patches applied correctly - -```bash -cd /tmp/Kiwy-Signage/src - -# Check imports added -grep "from ssl_config import SSLConfig" player_auth.py -grep "from ssl_config import SSLConfig" get_playlists_v2.py - -# Check verify parameter added -grep "verify=self.verify_ssl" player_auth.py | wc -l -# Should output: 5 - -# Check new file exists -test -f ssl_config.py && echo "ssl_config.py exists" || echo "MISSING" -``` - -### Test 2: Test with self-signed server - -```bash -cd /tmp/Kiwy-Signage - -# 1. Export server cert (run on server) -openssl s_client -connect server.local:443 -showcerts < /dev/null | \ - openssl x509 -outform PEM > config/ca_bundle.crt - -# 2. Test player connection -python3 -c " -import sys -sys.path.insert(0, 'src') -from player_auth import PlayerAuth -from ssl_config import SSLConfig - -# Check what certificate will be used -cert_path = SSLConfig.get_ca_bundle() -print(f'Using certificate: {cert_path}') - -# Try authentication -auth = PlayerAuth() -success, error = auth.authenticate( - server_url='https://server.local:443', - hostname='test-player', - quickconnect_code='TEST123' -) -print(f'Connection result: {\"SUCCESS\" if success else \"FAILED\"}') -if error: - print(f'Error: {error}') -" -``` - -### Test 3: Verify backward compatibility - -```bash -cd /tmp/Kiwy-Signage - -# Test connection to production server (valid CA cert) -python3 -c " -import sys -sys.path.insert(0, 'src') -from player_auth import PlayerAuth - -auth = PlayerAuth() -success, error = auth.authenticate( - server_url='https://digi-signage.moto-adv.com', - hostname='test-player', - quickconnect_code='TEST123' -) -print(f'Production server: {\"OK\" if success else \"FAILED\"}') -" -``` - ---- - -## Summary of Changes - -| File | Type | Changes | Complexity | -|------|------|---------|------------| -| `src/ssl_config.py` | NEW | Full file (~60 lines) | Low | -| `src/player_auth.py` | MODIFY | 7 small changes | Low | -| `src/get_playlists_v2.py` | MODIFY | 2 small changes | Low | -| `config/ca_bundle.crt` | NEW | Certificate file | N/A | - -**Total lines of code modified:** ~8 lines -**New code added:** ~60 lines -**Breaking changes:** None -**Backward compatible:** Yes - ---- - -## Rollback Instructions - -If you need to revert the changes: - -```bash -cd /tmp/Kiwy-Signage - -# Restore original files from git -git checkout src/player_auth.py -git checkout src/get_playlists_v2.py - -# Remove new file -rm src/ssl_config.py - -# Remove certificate file (optional) -rm config/ca_bundle.crt -``` - ---- - -## Implementation Checklist - -- [ ] Read the full analysis (KIWY_PLAYER_HTTPS_ANALYSIS.md) -- [ ] Review this patch file -- [ ] Create `src/ssl_config.py` (PATCH 1) -- [ ] Apply changes to `src/player_auth.py` (PATCH 2) -- [ ] Apply changes to `src/get_playlists_v2.py` (PATCH 3) -- [ ] Export server certificate (PATCH 4) -- [ ] Place certificate in `config/ca_bundle.crt` -- [ ] Run Test 1: Verify patches applied -- [ ] Run Test 2: Test with self-signed server -- [ ] Run Test 3: Test with production server -- [ ] Update player documentation -- [ ] Deploy to test player -- [ ] Monitor player logs for SSL errors - diff --git a/old_code_documentation/player_analisis/PLAYER_HTTPS_CONNECTION_ANALYSIS.md b/old_code_documentation/player_analisis/PLAYER_HTTPS_CONNECTION_ANALYSIS.md deleted file mode 100644 index 5e0c758..0000000 --- a/old_code_documentation/player_analisis/PLAYER_HTTPS_CONNECTION_ANALYSIS.md +++ /dev/null @@ -1,375 +0,0 @@ -# Player HTTPS Connection Issues - Analysis & Solutions - -## Problem Summary -Players can successfully connect to the DigiServer when using **HTTP on port 80**, but connections are **refused/blocked when the server is on HTTPS**. - ---- - -## Root Causes Identified - -### 1. **Missing CORS Headers on API Endpoints** ⚠️ CRITICAL -**Issue:** The app imports `Flask-Cors` (requirements.txt line 31) but **never initializes it** in the application. - -**Location:** -- [app/extensions.py](app/extensions.py) - CORS not initialized -- [app/app.py](app/app.py#L1-L80) - No CORS initialization in create_app() - -**Impact:** Players making cross-origin requests (from device IP to server domain/IP) get CORS errors and connections are refused at the browser/HTTP client level. - -**Affected Endpoints:** -- `/api/playlists` - GET (primary endpoint for player playlist fetch) -- `/api/auth/player` - POST (authentication) -- `/api/auth/verify` - POST (token verification) -- `/api/player-feedback` - POST (player status updates) -- All endpoints prefixed with `/api/*` - ---- - -### 2. **SSL Certificate Trust Issues** ⚠️ CRITICAL for Device-to-Server Communication - -**Issue:** Players are likely receiving **self-signed certificates** from nginx. - -**Location:** -- [docker-compose.yml](docker-compose.yml#L22-L35) - Nginx container with SSL -- [nginx.conf](nginx.conf#L54-L67) - SSL certificate paths point to self-signed certs -- [data/nginx-ssl/](data/nginx-ssl/) - Contains `cert.pem` and `key.pem` - -**Details:** -``` -ssl_certificate /etc/nginx/ssl/cert.pem; -ssl_certificate_key /etc/nginx/ssl/key.pem; -``` - -**Impact:** -- Players using standard HTTP clients (Python `requests`, JavaScript `fetch`, Kivy's HTTP module) will **reject self-signed certificates by default** -- This causes connection refusal with SSL certificate verification errors -- The player might be using hardcoded certificate verification (certificate pinning) - ---- - -### 3. **No Certificate Validation Bypass in Player API** ⚠️ HIGH - -**Issue:** The API endpoints don't provide a way for players to bypass SSL verification or explicitly trust the certificate. - -**What's Missing:** -```python -# Players likely need: -# - Endpoint to fetch and validate server certificate -# - API response with certificate fingerprint -# - Configuration to disable cert verification for self-signed setups -# - Or: Generate proper certificates with Let's Encrypt -``` - ---- - -### 4. **Potential HTTP/HTTPS Redirect Issues** - -**Location:** [nginx.conf](nginx.conf#L40-L50) - -**Issue:** HTTP requests to "/" are redirected to HTTPS: -```nginx -location / { - return 301 https://$host$request_uri; # Forces HTTPS -} -``` - -**Impact:** -- If player tries to connect via HTTP, it gets a 301 redirect to HTTPS -- If the player doesn't follow redirects or isn't configured for HTTPS, it fails -- The redirect URL depends on the `$host` variable, which might not match player's expectations - ---- - -### 5. **ProxyFix Middleware May Lose Protocol Info** - -**Location:** [app/app.py](app/app.py#L37) - -**Issue:** -```python -app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) -``` - -**Detail:** If nginx doesn't properly set `X-Forwarded-Proto: https`, the app might generate HTTP URLs in responses instead of HTTPS. - -**Config Check:** -```nginx -proxy_set_header X-Forwarded-Proto $scheme; # Should be in nginx.conf -``` - -✓ **This is present in nginx.conf**, so ProxyFix should work correctly. - ---- - -### 6. **Security Headers Might Block Requests** - -**Location:** [nginx.conf](nginx.conf#L70-L74) - -**Issue:** -```nginx -add_header X-Frame-Options "SAMEORIGIN" always; -add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; -``` - -**Impact:** Overly restrictive CSP could block embedded resource loading from players. - ---- - -### 7. **Missing Player Certificate Configuration** ⚠️ CRITICAL - -**Issue:** Players (especially embedded devices) often have: -- Limited certificate stores -- Self-signed cert validation disabled by default in some frameworks -- No built-in mechanism to trust new certificates - -**What's Not Addressed:** -- No endpoint to retrieve server certificate for device installation -- No configuration for certificate thumbprint verification -- No setup guide for device SSL configuration - ---- - -## Solutions by Priority - -### 🔴 **PRIORITY 1: Enable CORS for API Endpoints** - -**Fix:** Initialize Flask-CORS in the application. - -**File:** [app/extensions.py](app/extensions.py) -```python -from flask_cors import CORS - -# Add after other extensions -``` - -**File:** [app/app.py](app/app.py) - In `create_app()` function -```python -# After initializing extensions, add: -CORS(app, resources={ - r"/api/*": { - "origins": ["*"], # Or specific origins: ["http://...", "https://..."] - "methods": ["GET", "POST", "OPTIONS"], - "allow_headers": ["Content-Type", "Authorization"], - "supports_credentials": True, - "max_age": 3600 - } -}) -``` - ---- - -### 🔴 **PRIORITY 2: Fix SSL Certificate Issues** - -**Option A: Use Let's Encrypt (Recommended for production)** -```bash -# Generate proper certificates with certbot -certbot certonly --standalone -d yourdomain.com --email your@email.com -``` - -**Option B: Generate Self-Signed Certs with Longer Validity** -```bash -# Current certs might be expired or have trust issues -openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 \ - -subj "/CN=digiserver/O=Organization/C=US" -``` - -**Option C: Allow Players to Trust Self-Signed Cert** - -Add endpoint to serve certificate: -```python -# In app/blueprints/api.py - -@api_bp.route('/certificate', methods=['GET']) -def get_server_certificate(): - """Return server certificate for player installation.""" - try: - with open('/etc/nginx/ssl/cert.pem', 'r') as f: - cert_content = f.read() - - return jsonify({ - 'certificate': cert_content, - 'certificate_format': 'PEM' - }), 200 - except Exception as e: - return jsonify({'error': str(e)}), 500 -``` - ---- - -### 🟡 **PRIORITY 3: Update Configuration** - -**File:** [app/config.py](app/config.py) - -**Change:** -```python -# Line 28 - Currently set to False for development -SESSION_COOKIE_SECURE = False # Set to True in production with HTTPS -``` - -**To:** -```python -class ProductionConfig(Config): - SESSION_COOKIE_SECURE = True # HTTPS only - SESSION_COOKIE_SAMESITE = 'Lax' -``` - ---- - -### 🟡 **PRIORITY 4: Fix nginx Configuration** - -**Verify in [nginx.conf](nginx.conf):** -```nginx -# Line 86-95: Ensure these headers are present -proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header X-Forwarded-Host $server_name; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - -# Add this for player connections: -proxy_set_header X-Forwarded-Port 443; -``` - -**Consider relaxing CORS headers at nginx level:** -```nginx -# Add to location / block in HTTPS server: -add_header 'Access-Control-Allow-Origin' '*' always; -add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; -add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always; -``` - ---- - -### 🟡 **PRIORITY 5: Update Player Connection Code** - -**If you control the player code, add:** - -```python -# Python example for player connecting to server -import requests -from requests.adapters import HTTPAdapter -from requests.packages.urllib3.util.retry import Retry - -class PlayerClient: - def __init__(self, server_url, hostname, quickconnect_code, verify_ssl=False): - self.server_url = server_url - self.session = requests.Session() - - # For self-signed certs, disable verification (NOT RECOMMENDED for production) - self.session.verify = verify_ssl - - # Or: Trust specific certificate - # self.session.verify = '/path/to/server-cert.pem' - - self.hostname = hostname - self.quickconnect_code = quickconnect_code - - def get_playlist(self): - """Fetch playlist from server.""" - try: - response = self.session.get( - f"{self.server_url}/api/playlists", - params={ - 'hostname': self.hostname, - 'quickconnect_code': self.quickconnect_code - }, - headers={'Authorization': f'Bearer {self.auth_code}'} - ) - response.raise_for_status() - return response.json() - except requests.exceptions.SSLError as e: - print(f"SSL Error: {e}") - # Retry without SSL verification if configured - if not self.session.verify: - raise - # Fall back to unverified connection - self.session.verify = False - return self.get_playlist() -``` - ---- - -## Verification Steps - -### Test 1: Check CORS Headers -```bash -# This should include Access-Control-Allow-Origin -curl -v https://192.168.0.121/api/health -H "Origin: *" -``` - -### Test 2: Check SSL Certificate -```bash -# View certificate details -openssl s_client -connect 192.168.0.121:443 -showcerts - -# Check expiration -openssl x509 -in /srv/digiserver-v2/data/nginx-ssl/cert.pem -text -noout | grep -i valid -``` - -### Test 3: Test API Endpoint -```bash -# Try fetching playlist (should fail with SSL error or CORS error initially) -curl -k https://192.168.0.121/api/playlists \ - -G --data-urlencode "hostname=test" \ - --data-urlencode "quickconnect_code=test123" \ - -H "Origin: http://192.168.0.121" -``` - -### Test 4: Player Connection Simulation -```python -# From player device -import requests -session = requests.Session() -session.verify = False # Temp for testing - -response = session.get( - 'https://192.168.0.121/api/playlists', - params={'hostname': 'player1', 'quickconnect_code': 'abc123'} -) -print(response.json()) -``` - ---- - -## Summary of Changes Needed - -| Issue | Fix | Priority | File | -|-------|-----|----------|------| -| No CORS Headers | Initialize Flask-CORS | 🔴 HIGH | app/extensions.py, app/app.py | -| Self-Signed SSL Cert | Get Let's Encrypt cert or add trust endpoint | 🔴 HIGH | data/nginx-ssl/ | -| Certificate Validation | Add /certificate endpoint | 🟡 MEDIUM | app/blueprints/api.py | -| SESSION_COOKIE_SECURE | Update in ProductionConfig | 🟡 MEDIUM | app/config.py | -| X-Forwarded Headers | Verify nginx.conf | 🟡 MEDIUM | nginx.conf | -| CSP Too Restrictive | Relax CSP for player requests | 🟢 LOW | nginx.conf | - ---- - -## Quick Fix for Immediate Testing - -To quickly test if CORS is the issue: - -1. **Enable CORS temporarily:** -```bash -docker exec digiserver-v2 python -c " -from app import create_app -from flask_cors import CORS -app = create_app('production') -CORS(app) -" -``` - -2. **Test player connection:** -```bash -curl -k https://192.168.0.121/api/health -``` - -3. **If works, the issue is CORS + SSL certificates** - ---- - -## Recommended Next Steps - -1. ✅ Enable Flask-CORS in the application -2. ✅ Generate/obtain proper SSL certificates (Let's Encrypt recommended) -3. ✅ Add certificate trust endpoint for devices -4. ✅ Update nginx configuration for player device compatibility -5. ✅ Create player connection guide documenting HTTPS setup -6. ✅ Test with actual player device - diff --git a/old_code_documentation/player_analisis/PLAYER_HTTPS_CONNECTION_FIXES.md b/old_code_documentation/player_analisis/PLAYER_HTTPS_CONNECTION_FIXES.md deleted file mode 100644 index 92d5b71..0000000 --- a/old_code_documentation/player_analisis/PLAYER_HTTPS_CONNECTION_FIXES.md +++ /dev/null @@ -1,186 +0,0 @@ -# Implementation Summary - HTTPS Player Connection Fixes - -## ✅ Completed Implementations - -### 1. **CORS Support - FULLY IMPLEMENTED** ✓ -- **Status**: VERIFIED and WORKING -- **Evidence**: CORS headers present on all API responses -- **What was done**: - - Added Flask-CORS import to [app/extensions.py](app/extensions.py) - - Initialized CORS in [app/app.py](app/app.py) with configuration for `/api/*` endpoints - - Configured CORS for all HTTP methods: GET, POST, PUT, DELETE, OPTIONS - - Headers being returned successfully: - ``` - access-control-allow-origin: * - access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS - access-control-allow-headers: Content-Type, Authorization - access-control-max-age: 3600 - ``` - -### 2. **Production HTTPS Configuration** ✓ -- **Status**: IMPLEMENTED -- **What was done**: - - Updated [app/config.py](app/config.py) ProductionConfig: - - Set `SESSION_COOKIE_SECURE = True` for HTTPS-only cookies - - Set `SESSION_COOKIE_SAMESITE = 'Lax'` to allow CORS requests with credentials - -### 3. **Nginx CORS and SSL Headers** ✓ -- **Status**: IMPLEMENTED and VERIFIED -- **What was done**: - - Updated [nginx.conf](nginx.conf) with: - - CORS headers at nginx level for all responses - - OPTIONS request handling (CORS preflight) - - X-Forwarded-Port header forwarding - - Proper SSL/TLS configuration (TLS 1.2 and 1.3) - -### 4. **Certificate Endpoint** ⚠️ -- **Status**: Added (routing issue being debugged) -- **What was done**: - - Added `/api/certificate` GET endpoint in [app/blueprints/api.py](app/blueprints/api.py) - - Serves server certificate in PEM format for device trust configuration - - Includes certificate metadata parsing with optional cryptography support - - **Note**: Route appears not to register - likely Flask-CORS or app context issue - ---- - -## 📊 Test Results - -### ✅ CORS Headers - VERIFIED -```bash -$ curl -v -k https://192.168.0.121/api/playlists - -< HTTP/2 400 -< access-control-allow-origin: * -< access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS -< access-control-allow-headers: Content-Type, Authorization -< access-control-max-age: 3600 -``` - -### ✅ Health Endpoint -```bash -$ curl -s -k https://192.168.0.121/api/health | jq . -{ - "status": "healthy", - "timestamp": "2026-01-16T20:02:13.177245", - "version": "2.0.0" -} -``` - -### ✅ HTTPS Working -```bash -$ curl -v -k https://192.168.0.121/api/health -< HTTP/2 200 -< SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 -``` - ---- - -## 🔍 What This Fixes - -### **Before Implementation** -- ❌ Players get CORS errors on HTTPS -- ❌ Browsers/HTTP clients block cross-origin API requests -- ❌ SSL/HTTPS security headers missing at app level -- ❌ Sessions insecure on HTTPS -- ❌ Proxy headers not properly forwarded - -### **After Implementation** -- ✅ CORS headers present on all API responses -- ✅ Players can make cross-origin requests from any origin -- ✅ Preflight OPTIONS requests handled -- ✅ Cookies properly secured with HTTPS/SAMESITE flags -- ✅ X-Forwarded-* headers forwarded for protocol detection -- ✅ HTTPS with TLS 1.2 and 1.3 support - ---- - -## 🚀 Player Connection Flow Now Works - -``` -Player Device (HTTPS Client) - ↓ - OPTIONS /api/playlists (CORS Preflight) - ↓ - Nginx (with CORS headers) - ↓ - Flask App (CORS enabled) - ↓ - ✅ Returns 200 with CORS headers - ↓ - Browser/Client accepts response - ↓ - GET /api/playlists (Actual request) - ↓ - ✅ Players can fetch playlist successfully -``` - ---- - -## 📝 Files Modified - -1. **app/extensions.py** - Added `from flask_cors import CORS` -2. **app/app.py** - Initialized CORS with API endpoint configuration -3. **app/config.py** - Added `SESSION_COOKIE_SAMESITE = 'Lax'` -4. **nginx.conf** - Added CORS headers and OPTIONS handling -5. **requirements.txt** - Added `cryptography==42.0.7` -6. **app/blueprints/api.py** - Added certificate endpoint (partial) - ---- - -## 🎯 Critical Issues Resolved - -| Issue | Status | Solution | -|-------|--------|----------| -| **CORS Blocking Requests** | ✅ FIXED | Flask-CORS enabled with wildcard origins | -| **Cross-Origin Preflight Fail** | ✅ FIXED | OPTIONS requests handled at nginx + Flask | -| **Session Insecurity over HTTPS** | ✅ FIXED | SESSION_COOKIE_SECURE set | -| **CORS Credentials Blocked** | ✅ FIXED | SESSION_COOKIE_SAMESITE = 'Lax' | -| **Protocol Detection Failure** | ✅ FIXED | X-Forwarded headers in nginx | - ---- - -## ⚠️ Remaining Tasks - -### Certificate Endpoint (Lower Priority) -The `/api/certificate` endpoint for serving self-signed certificates needs debugging. This is for enhanced compatibility with devices that need certificate trust configuration. **Workaround**: Players can fetch certificate directly from nginx at port 443. - -### Next Steps for Players -1. Update player code to handle HTTPS (see PLAYER_HTTPS_INTEGRATION_GUIDE.md) -2. Optionally implement SSL certificate verification with server cert -3. Test playlist fetching on HTTPS - ---- - -## 🧪 Verification Commands - -Test that CORS is working: -```bash -# Should return CORS headers -curl -i -k https://192.168.0.121/api/health - -# Test preflight request -curl -X OPTIONS -H "Origin: *" \ - https://192.168.0.121/api/playlists -v - -# Test with credentials -curl -k https://192.168.0.121/api/playlists \ - --data-urlencode "hostname=test" \ - --data-urlencode "quickconnect_code=test123" -``` - ---- - -## 📚 Documentation - -- **PLAYER_HTTPS_ANALYSIS.md** - Problem analysis and root causes -- **PLAYER_HTTPS_INTEGRATION_GUIDE.md** - Player code update guide -- **PLAYER_HTTPS_CONNECTION_FIXES.md** - This file (Implementation summary) - ---- - -## ✨ Result - -**Players can now connect to the HTTPS server successfully!** - -The main CORS issue has been completely resolved. Players will no longer get connection refused errors when the server is on HTTPS. - diff --git a/old_code_documentation/player_analisis/PLAYER_HTTPS_INTEGRATION_GUIDE.md b/old_code_documentation/player_analisis/PLAYER_HTTPS_INTEGRATION_GUIDE.md deleted file mode 100644 index c72cbfe..0000000 --- a/old_code_documentation/player_analisis/PLAYER_HTTPS_INTEGRATION_GUIDE.md +++ /dev/null @@ -1,346 +0,0 @@ -# Player Code HTTPS Integration Guide - -## Server-Side Improvements Implemented - -All critical and medium improvements have been implemented on the server: - -### ✅ CORS Support Enabled -- **File**: `app/extensions.py` - CORS extension initialized -- **File**: `app/app.py` - CORS configured for `/api/*` endpoints -- All player API requests now support cross-origin requests -- Preflight OPTIONS requests are properly handled - -### ✅ SSL Certificate Endpoint Added -- **Endpoint**: `GET /api/certificate` -- **Location**: `app/blueprints/api.py` -- Returns server certificate in PEM format with metadata: - - Certificate content (PEM format) - - Certificate info (subject, issuer, validity dates, fingerprint) - - Integration instructions for different platforms - -### ✅ HTTPS Configuration Updated -- **File**: `app/config.py` - ProductionConfig now has: - - `SESSION_COOKIE_SECURE = True` - - `SESSION_COOKIE_SAMESITE = 'Lax'` -- **File**: `nginx.conf` - Added: - - CORS headers for all responses - - OPTIONS request handling - - X-Forwarded-Port header forwarding - -### ✅ Nginx Proxy Configuration Enhanced -- Added CORS headers at nginx level for defense-in-depth -- Proper X-Forwarded headers for protocol/port detection -- HTTPS-friendly proxy configuration - ---- - -## Required Player Code Changes - -### 1. **For Python/Kivy Players Using Requests Library** - -**Update:** Import and use certificate handling: - -```python -import requests -from requests.adapters import HTTPAdapter -from requests.packages.urllib3.util.retry import Retry -import os - -class DigiServerClient: - def __init__(self, server_url, hostname, quickconnect_code, use_https=True): - self.server_url = server_url - self.hostname = hostname - self.quickconnect_code = quickconnect_code - self.session = requests.Session() - - # CRITICAL: Handle SSL verification - if use_https: - # Option 1: Get certificate from server and trust it - self.setup_certificate_trust() - else: - # Option 2: Disable SSL verification (DEV ONLY) - self.session.verify = False - - def setup_certificate_trust(self): - """Download server certificate and configure trust.""" - try: - # First, make a request without verification to get the cert - response = requests.get( - f"{self.server_url}/api/certificate", - verify=False, - timeout=5 - ) - - if response.status_code == 200: - cert_data = response.json() - - # Save certificate locally - cert_path = os.path.expanduser('~/.digiserver/server_cert.pem') - os.makedirs(os.path.dirname(cert_path), exist_ok=True) - - with open(cert_path, 'w') as f: - f.write(cert_data['certificate']) - - # Configure session to use this certificate - self.session.verify = cert_path - - print(f"✓ Server certificate installed from {cert_data['certificate_info']['issuer']}") - print(f" Valid until: {cert_data['certificate_info']['valid_until']}") - - except Exception as e: - print(f"⚠️ Failed to setup certificate trust: {e}") - print(" Falling back to unverified connection (not recommended for production)") - self.session.verify = False - - def get_playlist(self): - """Get playlist from server with proper error handling.""" - try: - response = self.session.get( - f"{self.server_url}/api/playlists", - params={ - 'hostname': self.hostname, - 'quickconnect_code': self.quickconnect_code - }, - timeout=10 - ) - response.raise_for_status() - return response.json() - - except requests.exceptions.SSLError as e: - print(f"❌ SSL Error: {e}") - # Log error for debugging - print(" This usually means the server certificate is not trusted.") - print(" Try running: DigiServerClient.setup_certificate_trust()") - raise - - except requests.exceptions.ConnectionError as e: - print(f"❌ Connection Error: {e}") - raise - - except Exception as e: - print(f"❌ Error: {e}") - raise - - def send_feedback(self, status, message=''): - """Send player feedback/status to server.""" - try: - response = self.session.post( - f"{self.server_url}/api/player-feedback", - json={ - 'hostname': self.hostname, - 'quickconnect_code': self.quickconnect_code, - 'status': status, - 'message': message, - 'timestamp': datetime.utcnow().isoformat() - }, - timeout=10 - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error sending feedback: {e}") - return None -``` - -### 2. **For Kivy Framework Specifically** - -**Update:** In your Kivy HTTP client configuration: - -```python -from kivy.network.urlrequest import UrlRequest -from kivy.logger import Logger -import ssl -import certifi - -class DigiServerKivyClient: - def __init__(self, server_url, hostname, quickconnect_code): - self.server_url = server_url - self.hostname = hostname - self.quickconnect_code = quickconnect_code - - # Configure SSL context for Kivy requests - self.ssl_context = self._setup_ssl_context() - - def _setup_ssl_context(self): - """Setup SSL context with certificate trust.""" - try: - # Try to get server certificate - import requests - response = requests.get( - f"{self.server_url}/api/certificate", - verify=False, - timeout=5 - ) - - if response.status_code == 200: - cert_data = response.json() - cert_path = os._get_cert_path() - - with open(cert_path, 'w') as f: - f.write(cert_data['certificate']) - - # Create SSL context - context = ssl.create_default_context() - context.load_verify_locations(cert_path) - - Logger.info('DigiServer', f'SSL context configured with server certificate') - return context - - except Exception as e: - Logger.warning('DigiServer', f'Failed to setup SSL: {e}') - return None - - def fetch_playlist(self, callback): - """Fetch playlist with proper SSL handling.""" - url = f"{self.server_url}/api/playlists" - params = f"?hostname={self.hostname}&quickconnect_code={self.quickconnect_code}" - - headers = { - 'Content-Type': 'application/json', - 'User-Agent': 'Kiwy-Signage-Player/1.0' - } - - request = UrlRequest( - url + params, - on_success=callback, - on_error=self._on_error, - on_failure=self._on_failure, - headers=headers - ) - - return request - - def _on_error(self, request, error): - Logger.error('DigiServer', f'Request error: {error}') - - def _on_failure(self, request, result): - Logger.error('DigiServer', f'Request failed: {result}') -``` - -### 3. **Environment Configuration** - -**Add to player app_config.json or environment:** - -```json -{ - "server": { - "url": "https://192.168.0.121", - "hostname": "player1", - "quickconnect_code": "ABC123XYZ", - "verify_ssl": false, - "use_server_certificate": true, - "certificate_path": "~/.digiserver/server_cert.pem" - }, - "connection": { - "timeout": 10, - "retry_attempts": 3, - "retry_delay": 5 - } -} -``` - ---- - -## Testing Checklist - -### Server-Side Tests - -- [ ] Verify CORS headers present: `curl -v https://192.168.0.121/api/health` -- [ ] Check certificate endpoint: `curl -k https://192.168.0.121/api/certificate` -- [ ] Test OPTIONS preflight: `curl -X OPTIONS https://192.168.0.121/api/playlists` -- [ ] Verify X-Forwarded headers: `curl -v https://192.168.0.121/` - -### Player Connection Tests - -- [ ] Player connects with HTTPS successfully -- [ ] Player fetches playlist without SSL errors -- [ ] Player receives status update confirmation -- [ ] Player sends feedback/heartbeat correctly - -### Integration Tests - -```bash -# Test certificate retrieval -curl -k https://192.168.0.121/api/certificate | jq '.certificate_info' - -# Test CORS preflight for player -curl -X OPTIONS https://192.168.0.121/api/playlists \ - -H "Origin: http://192.168.0.121" \ - -H "Access-Control-Request-Method: GET" \ - -v - -# Simulate player playlist fetch -curl -k https://192.168.0.121/api/playlists \ - --data-urlencode "hostname=test-player" \ - --data-urlencode "quickconnect_code=test123" \ - -H "Origin: *" -``` - ---- - -## Migration Steps - -### For Existing Players - -1. **Update player code** with new SSL handling from this guide -2. **Restart player application** to pick up changes -3. **Verify connection** works with HTTPS server -4. **Monitor logs** for any SSL-related errors - -### For New Players - -1. **Deploy updated player code** with SSL support from the start -2. **Configure with HTTPS server URL** -3. **Run initialization** to fetch and trust server certificate - ---- - -## Troubleshooting - -### "SSL: CERTIFICATE_VERIFY_FAILED" -- Player is rejecting the self-signed certificate -- **Solution**: Run certificate trust setup or disable SSL verification - -### "Connection Refused" -- Server HTTPS port not accessible -- **Solution**: Check nginx is running, port 443 is open, firewall rules - -### "CORS error" -- Browser/HTTP client blocking cross-origin request -- **Solution**: Verify CORS headers in response, check Origin header - -### "Certificate not found at endpoint" -- Server certificate file missing -- **Solution**: Verify cert.pem exists at `/etc/nginx/ssl/cert.pem` - ---- - -## Security Recommendations - -1. **For Development/Testing**: Disable SSL verification temporarily - ```python - session.verify = False - ``` - -2. **For Production**: - - Use proper certificates (Let's Encrypt recommended) - - Deploy certificate trust setup at player initialization - - Monitor SSL certificate expiration - - Implement certificate pinning for critical deployments - -3. **For Self-Signed Certificates**: - - Use `/api/certificate` endpoint to distribute certificates - - Store certificates in secure location on device - - Implement certificate update mechanism - - Log certificate trust changes for auditing - ---- - -## Next Steps - -1. **Implement SSL handling** in player code using examples above -2. **Test with HTTP first** to ensure API works -3. **Enable HTTPS** and test with certificate handling -4. **Deploy to production** with proper SSL setup -5. **Monitor** player connections and SSL errors - diff --git a/old_code_documentation/playlist/manage_playlist.html b/old_code_documentation/playlist/manage_playlist.html deleted file mode 100644 index cc4cdc6..0000000 --- a/old_code_documentation/playlist/manage_playlist.html +++ /dev/null @@ -1,1025 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Manage Playlist - {{ player.name }} - DigiServer v2{% endblock %} - -{% block content %} - - -
- -
-

🎬 {{ player.name }}

-

📍 {{ player.location or 'No location' }}

-

🖥️ Hostname: {{ player.hostname }}

-

📊 Status: {{ '🟢 Online' if player.is_online else '🔴 Offline' }}

- -
-
-
Playlist Items
-
{{ playlist_content|length }}
-
-
-
Playlist Version
-
{{ player.playlist_version }}
-
-
-
Total Duration
-
{{ playlist_content|sum(attribute='duration') }}s
-
-
-
- - -
- - ← Back to Player - - - ➕ Upload New Content - - {% if playlist_content %} -
- -
- {% endif %} -
- - -
-
-

📋 Current Playlist

- - Drag and drop to reorder - -
- - {% if playlist_content %} - - - - - - - - - - - - - - - {% for content in playlist_content %} - - - - - - - - - - - {% endfor %} - -
#FilenameTypeDuration (s)AudioSizeActions
- ⋮⋮ - {{ loop.index }}{{ content.filename }} - {% if content.content_type == 'image' %} - 📷 Image - {% elif content.content_type == 'video' %} - 🎥 Video - {% elif content.content_type == 'pdf' %} - 📄 PDF - {% else %} - 📁 {{ content.content_type }} - {% endif %} - -
- -
- {{ content._playlist_duration }}s -
- -
-
- {% if content.content_type == 'video' %} - - {% else %} - - {% endif %} - {{ "%.2f"|format(content.file_size_mb) }} MB -
- -
-
- {% else %} -
-
📭
-

No content in playlist

-

Upload content or add existing files to get started

-
- {% endif %} -
- - - {% if available_content %} -
-
-

➕ Add Existing Content

-
- -
-
- - -
- -
- - -
- - -
-
- {% endif %} -
- - - -{% endblock %} diff --git a/old_code_documentation/run_dev.sh b/old_code_documentation/run_dev.sh deleted file mode 100755 index cdc98a9..0000000 --- a/old_code_documentation/run_dev.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/bash - -# DigiServer v2 - Development Test Runner -# This script sets up and runs the application in development mode - -set -e - -echo "================================================" -echo " DigiServer v2 - Development Environment" -echo "================================================" -echo "" - -# Check if we're in the right directory -if [ ! -f "requirements.txt" ]; then - echo "❌ Error: requirements.txt not found. Run this from the digiserver-v2 directory." - exit 1 -fi - -# Check if virtual environment exists -if [ ! -d "venv" ]; then - echo "📦 Creating virtual environment..." - python3 -m venv venv - echo "✅ Virtual environment created" -else - echo "✅ Virtual environment found" -fi - -# Activate virtual environment -echo "🔄 Activating virtual environment..." -source venv/bin/activate - -# Install/update dependencies -echo "📥 Installing dependencies..." -pip install -q --upgrade pip -pip install -q -r requirements.txt - -echo "✅ Dependencies installed" -echo "" - -# Check if .env exists -if [ ! -f ".env" ]; then - echo "⚠️ Warning: .env file not found, using .env.example" - cp .env.example .env -fi - -# Initialize database if it doesn't exist -if [ ! -f "instance/dashboard.db" ]; then - echo "🗄️ Initializing database..." - export FLASK_APP=app.app:create_app - flask init-db - echo "✅ Database initialized" - - echo "👤 Creating default admin user..." - flask create-admin - echo "✅ Admin user created (username: admin, password: admin123)" -else - echo "✅ Database found" -fi - -echo "" -echo "================================================" -echo " Starting Flask Development Server" -echo "================================================" -echo "" -echo "🌐 Server will be available at: http://localhost:5000" -echo "👤 Default admin: username=admin, password=admin123" -echo "" -echo "Press Ctrl+C to stop the server" -echo "" - -# Set Flask environment -export FLASK_APP=app.app:create_app -export FLASK_ENV=development - -# Run Flask -flask run --host=0.0.0.0 --port=5000 diff --git a/old_code_documentation/start.sh b/old_code_documentation/start.sh deleted file mode 100755 index 0b27f30..0000000 --- a/old_code_documentation/start.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash - -# DigiServer v2 - Simple Start Script -# Starts the application with proper configuration - -set -e - -cd /srv/digiserver-v2 - -# Activate virtual environment -source venv/bin/activate - -# Set environment variables -export FLASK_APP=app.app:create_app -export FLASK_ENV=development - -# Start Flask server -echo "Starting DigiServer v2..." -echo "Access at: http://localhost:5000" -echo "Login: admin / admin123" -echo "" - -flask run --host=0.0.0.0 --port=5000 diff --git a/old_code_documentation/templates_groups/create_group.html b/old_code_documentation/templates_groups/create_group.html deleted file mode 100644 index bc2ac78..0000000 --- a/old_code_documentation/templates_groups/create_group.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Create Group - DigiServer v2{% endblock %} - -{% block content %} -

Create Group

-
-
-
- - -
-
- - -
- - Cancel -
-
-{% endblock %} diff --git a/old_code_documentation/templates_groups/edit_group.html b/old_code_documentation/templates_groups/edit_group.html deleted file mode 100644 index 93b9a5d..0000000 --- a/old_code_documentation/templates_groups/edit_group.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Edit Group{% endblock %} - -{% block content %} -
-

Edit Group

-

Edit group functionality - placeholder

- Back to Groups -
-{% endblock %} diff --git a/old_code_documentation/templates_groups/group_fullscreen.html b/old_code_documentation/templates_groups/group_fullscreen.html deleted file mode 100644 index 8949dd7..0000000 --- a/old_code_documentation/templates_groups/group_fullscreen.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Group Fullscreen{% endblock %} - -{% block content %} -
-

Group Fullscreen View

-

Fullscreen group view - placeholder

-
-{% endblock %} diff --git a/old_code_documentation/templates_groups/groups_list.html b/old_code_documentation/templates_groups/groups_list.html deleted file mode 100644 index 471f3b0..0000000 --- a/old_code_documentation/templates_groups/groups_list.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Groups - DigiServer v2{% endblock %} - -{% block content %} -

Groups

-
-

Groups list view - Template in progress

- Create New Group -
-{% endblock %} diff --git a/old_code_documentation/templates_groups/manage_group.html b/old_code_documentation/templates_groups/manage_group.html deleted file mode 100644 index a2131b0..0000000 --- a/old_code_documentation/templates_groups/manage_group.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Manage Group{% endblock %} - -{% block content %} -
-

Manage Group

-

Manage group functionality - placeholder

- Back to Groups -
-{% endblock %} diff --git a/old_code_documentation/test_edit_media_api.py b/old_code_documentation/test_edit_media_api.py deleted file mode 100644 index 0c25aef..0000000 --- a/old_code_documentation/test_edit_media_api.py +++ /dev/null @@ -1,420 +0,0 @@ -#!/usr/bin/env python3 -""" -Diagnostic script to test the player edit media API endpoint. -This script simulates what a player would do when uploading edited images. -""" - -import requests -import json -import sys -from datetime import datetime -from pathlib import Path - -# Color codes for output -class Colors: - HEADER = '\033[95m' - OKBLUE = '\033[94m' - OKCYAN = '\033[96m' - OKGREEN = '\033[92m' - WARNING = '\033[93m' - FAIL = '\033[91m' - ENDC = '\033[0m' - BOLD = '\033[1m' - UNDERLINE = '\033[4m' - -def print_section(title): - """Print a section header""" - print(f"\n{Colors.HEADER}{Colors.BOLD}{'='*60}{Colors.ENDC}") - print(f"{Colors.HEADER}{Colors.BOLD}{title}{Colors.ENDC}") - print(f"{Colors.HEADER}{Colors.BOLD}{'='*60}{Colors.ENDC}\n") - -def print_success(msg): - print(f"{Colors.OKGREEN}✓ {msg}{Colors.ENDC}") - -def print_error(msg): - print(f"{Colors.FAIL}✗ {msg}{Colors.ENDC}") - -def print_info(msg): - print(f"{Colors.OKCYAN}ℹ {msg}{Colors.ENDC}") - -def print_warning(msg): - print(f"{Colors.WARNING}⚠ {msg}{Colors.ENDC}") - -def test_server_health(base_url): - """Test if server is accessible""" - print_section("1. Testing Server Health") - - try: - response = requests.get(f"{base_url}/api/health", timeout=5) - if response.status_code == 200: - print_success(f"Server is accessible at {base_url}") - data = response.json() - print(f" Status: {data.get('status')}") - print(f" Version: {data.get('version')}") - return True - else: - print_error(f"Server returned status {response.status_code}") - return False - except requests.exceptions.ConnectionError: - print_error(f"Cannot connect to server at {base_url}") - return False - except Exception as e: - print_error(f"Error testing server health: {str(e)}") - return False - -def test_endpoint_exists(base_url): - """Test if the endpoint is available""" - print_section("2. Testing Endpoint Availability") - - endpoint = f"{base_url}/api/player-edit-media" - print_info(f"Testing endpoint: {endpoint}") - - # Test without auth (should get 401) - try: - response = requests.post(endpoint, timeout=5) - if response.status_code == 401: - print_success("Endpoint exists and requires authentication (401)") - print(f" Response: {response.json()}") - return True - elif response.status_code == 404: - print_error("Endpoint NOT FOUND (404) - The endpoint doesn't exist!") - return False - elif response.status_code == 400: - print_warning("Endpoint exists but got 400 (Bad Request) - likely missing data") - print(f" Response: {response.json()}") - return True - else: - print_warning(f"Unexpected status code: {response.status_code}") - print(f" Response: {response.text}") - return True - except requests.exceptions.ConnectionError: - print_error("Cannot connect to endpoint") - return False - except Exception as e: - print_error(f"Error testing endpoint: {str(e)}") - return False - -def get_player_auth_code(base_url, db_path): - """Get a valid player auth code from the database""" - print_section("3. Retrieving Player Auth Code") - - try: - import sqlite3 - - # Try to connect to the database - try: - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - cursor.execute("SELECT id, name, auth_code FROM player LIMIT 1") - result = cursor.fetchone() - - if result: - player_id, player_name, auth_code = result - print_success(f"Found player: {player_name} (ID: {player_id})") - print_info(f"Auth code: {auth_code[:10]}...{auth_code[-5:]}") - - # Get playlist for this player - cursor.execute("SELECT playlist_id FROM player WHERE id = ?", (player_id,)) - playlist_row = cursor.fetchone() - has_playlist = playlist_row and playlist_row[0] is not None - - print_info(f"Has assigned playlist: {has_playlist}") - - conn.close() - return player_id, player_name, auth_code - else: - print_error("No players found in database") - conn.close() - return None, None, None - except sqlite3.OperationalError as e: - print_error(f"Cannot access database at {db_path}") - print_warning("Make sure you're running this from the correct directory") - return None, None, None - except Exception as e: - print_error(f"Error retrieving player auth code: {str(e)}") - return None, None, None - -def get_sample_content(base_url, auth_code): - """Get a sample content file to use for testing""" - print_section("4. Retrieving Sample Content") - - try: - headers = {"Authorization": f"Bearer {auth_code}"} - - # Get player ID from auth - player_response = requests.get( - f"{base_url}/api/health", - headers=headers, - timeout=5 - ) - - # Try to get a playlist - # We need to query the database for this - print_warning("Getting sample content from filesystem...") - - uploads_dir = Path("app/static/uploads") - if uploads_dir.exists(): - # Find a non-edited media file - image_files = list(uploads_dir.glob("*.jpg")) + list(uploads_dir.glob("*.png")) - - if image_files: - sample_file = image_files[0] - print_success(f"Found sample image: {sample_file.name}") - return sample_file.name, sample_file - - print_warning("No sample images found in uploads directory") - return None, None - except Exception as e: - print_error(f"Error getting sample content: {str(e)}") - return None, None - -def test_authentication(base_url, auth_code): - """Test if authentication works""" - print_section("5. Testing Authentication") - - try: - headers = {"Authorization": f"Bearer {auth_code}"} - response = requests.post( - f"{base_url}/api/player-edit-media", - headers=headers, - timeout=5 - ) - - if response.status_code == 401: - print_error("Authentication FAILED - Invalid auth code") - print(f" Response: {response.json()}") - return False - elif response.status_code == 400: - print_success("Authentication passed! (Got 400 because of missing data)") - print(f" Response: {response.json()}") - return True - elif response.status_code == 404: - print_error("Endpoint not found!") - return False - else: - print_warning(f"Unexpected status: {response.status_code}") - print(f" Response: {response.text}") - return True - except Exception as e: - print_error(f"Error testing authentication: {str(e)}") - return False - -def test_full_upload(base_url, auth_code, content_filename, sample_file): - """Test a full media upload""" - print_section("6. Testing Full Media Upload") - - if not auth_code or not content_filename or not sample_file: - print_error("Missing required parameters for upload test") - return False - - try: - # Create metadata - metadata = { - "time_of_modification": datetime.utcnow().isoformat() + "Z", - "original_name": content_filename, - "new_name": f"{content_filename.split('.')[0]}_v1.{content_filename.split('.')[-1]}", - "version": 1, - "user_card_data": "test_user_123" - } - - print_info(f"Preparing upload with metadata:") - print(f" Original: {metadata['original_name']}") - print(f" New name: {metadata['new_name']}") - print(f" Version: {metadata['version']}") - - # Prepare the request - headers = {"Authorization": f"Bearer {auth_code}"} - - with open(sample_file, 'rb') as f: - files = { - 'image_file': (sample_file.name, f, 'image/jpeg'), - 'metadata': (None, json.dumps(metadata)) - } - - print_info("Sending upload request...") - response = requests.post( - f"{base_url}/api/player-edit-media", - headers=headers, - files=files, - timeout=10 - ) - - print(f" Status Code: {response.status_code}") - - if response.status_code == 200: - data = response.json() - print_success("Upload successful!") - print(f" Response: {json.dumps(data, indent=2)}") - return True - elif response.status_code == 404: - print_error("Content not found - The original_name doesn't match any content in database") - print(f" Error: {response.json()}") - return False - elif response.status_code == 400: - print_error("Bad Request - Check metadata format") - print(f" Error: {response.json()}") - return False - elif response.status_code == 401: - print_error("Authentication failed") - print(f" Error: {response.json()}") - return False - else: - print_error(f"Upload failed with status {response.status_code}") - print(f" Response: {response.text}") - return False - except Exception as e: - print_error(f"Error during upload: {str(e)}") - import traceback - traceback.print_exc() - return False - -def check_database_integrity(db_path): - """Check database tables and records""" - print_section("7. Database Integrity Check") - - try: - import sqlite3 - - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - # Check player table - cursor.execute("SELECT COUNT(*) FROM player") - player_count = cursor.fetchone()[0] - print_info(f"Players in database: {player_count}") - - # Check content table - cursor.execute("SELECT COUNT(*) FROM content") - content_count = cursor.fetchone()[0] - print_info(f"Content items in database: {content_count}") - - # Check player_edit table - cursor.execute("SELECT COUNT(*) FROM player_edit") - edit_count = cursor.fetchone()[0] - print_info(f"Player edits recorded: {edit_count}") - - # List content files - print_info("Sample content files:") - cursor.execute("SELECT id, filename, content_type FROM content LIMIT 5") - for row in cursor.fetchall(): - print(f" - [{row[0]}] {row[1]} ({row[2]})") - - conn.close() - print_success("Database integrity check passed") - return True - except Exception as e: - print_error(f"Database integrity check failed: {str(e)}") - return False - -def main(): - """Run all diagnostic tests""" - print(f"{Colors.BOLD}{Colors.OKCYAN}") - print(""" - ╔═══════════════════════════════════════════════════════════╗ - ║ DIGISERVER EDIT MEDIA API - DIAGNOSTIC SCRIPT ║ - ║ Testing Player Edit Upload Functionality ║ - ╚═══════════════════════════════════════════════════════════╝ - """) - print(Colors.ENDC) - - # Configuration - base_url = "http://localhost:5000" # Change this if server is on different host - db_path = "instance/digiserver.db" - - print_info(f"Server URL: {base_url}") - print_info(f"Database: {db_path}\n") - - # Run tests - tests_passed = [] - tests_failed = [] - - # Test 1: Server health - if test_server_health(base_url): - tests_passed.append("Server Health") - else: - tests_failed.append("Server Health") - print_error("Cannot continue without server access") - return - - # Test 2: Endpoint exists - if test_endpoint_exists(base_url): - tests_passed.append("Endpoint Availability") - else: - tests_failed.append("Endpoint Availability") - print_error("Cannot continue - endpoint doesn't exist!") - return - - # Test 3: Get player auth code - player_id, player_name, auth_code = get_player_auth_code(base_url, db_path) - if auth_code: - tests_passed.append("Player Auth Code Retrieval") - else: - tests_failed.append("Player Auth Code Retrieval") - print_error("Cannot continue without valid player auth code") - return - - # Test 4: Authentication - if test_authentication(base_url, auth_code): - tests_passed.append("Authentication") - else: - tests_failed.append("Authentication") - print_error("Authentication test failed") - - # Test 5: Get sample content - content_name, sample_file = get_sample_content(base_url, auth_code) - if content_name and sample_file: - tests_passed.append("Sample Content Retrieval") - - # Test 6: Full upload - if test_full_upload(base_url, auth_code, content_name, sample_file): - tests_passed.append("Full Media Upload") - else: - tests_failed.append("Full Media Upload") - else: - tests_failed.append("Sample Content Retrieval") - - # Test 7: Database integrity - if check_database_integrity(db_path): - tests_passed.append("Database Integrity") - else: - tests_failed.append("Database Integrity") - - # Summary - print_section("Summary") - - if tests_passed: - print(f"{Colors.OKGREEN}Passed Tests ({len(tests_passed)}):{Colors.ENDC}") - for test in tests_passed: - print(f" {Colors.OKGREEN}✓{Colors.ENDC} {test}") - - if tests_failed: - print(f"\n{Colors.FAIL}Failed Tests ({len(tests_failed)}):{Colors.ENDC}") - for test in tests_failed: - print(f" {Colors.FAIL}✗{Colors.ENDC} {test}") - - print(f"\n{Colors.BOLD}Result: {len(tests_passed)}/{len(tests_passed) + len(tests_failed)} tests passed{Colors.ENDC}\n") - - # Recommendations - print_section("Recommendations") - - if "Endpoint Availability" in tests_failed: - print_warning("The /api/player-edit-media endpoint is not available") - print(" 1. Check if the Flask app reloaded after code changes") - print(" 2. Verify the endpoint is properly registered in api.py") - print(" 3. Restart the Docker container") - - if "Full Media Upload" in tests_failed: - print_warning("Upload test failed - check:") - print(" 1. The original_name matches actual content filenames") - print(" 2. Content record exists in the database") - print(" 3. Server has permission to write to uploads directory") - print(" 4. Check server logs for error details") - - if "Authentication" in tests_failed: - print_warning("Authentication failed - check:") - print(" 1. Player auth code is valid and hasn't expired") - print(" 2. Auth header format is correct: 'Authorization: Bearer '") - print(" 3. Player record hasn't been deleted from database") - -if __name__ == "__main__": - main() diff --git a/old_code_documentation/test_edit_media_simple.py b/old_code_documentation/test_edit_media_simple.py deleted file mode 100644 index 8a4b78e..0000000 --- a/old_code_documentation/test_edit_media_simple.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env python3 -""" -Simplified diagnostic script using Flask's built-in test client. -This script tests the player edit media API endpoint. -""" - -import sys -import json -from datetime import datetime -from pathlib import Path - -# Add the app directory to the path -sys.path.insert(0, '/app') - -from app import create_app -from app.extensions import db -from app.models import Player, Content - -def test_edit_media_endpoint(): - """Test the edit media endpoint using Flask test client""" - - print("\n" + "="*60) - print("DIGISERVER EDIT MEDIA API - DIAGNOSTIC TEST") - print("="*60 + "\n") - - # Create app context - app = create_app() - - with app.app_context(): - # Get a test client - client = app.test_client() - - # Test 1: Check server health - print("[1/6] Testing server health...") - response = client.get('/api/health') - if response.status_code == 200: - print(f" ✓ Server is healthy (Status: {response.status_code})") - data = response.json - print(f" Version: {data.get('version')}") - else: - print(f" ✗ Server health check failed (Status: {response.status_code})") - return - - # Test 2: Check endpoint without auth - print("\n[2/6] Testing endpoint availability (without auth)...") - response = client.post('/api/player-edit-media') - if response.status_code == 401: - print(f" ✓ Endpoint exists and requires auth (Status: 401)") - print(f" Response: {response.json}") - elif response.status_code == 404: - print(f" ✗ ENDPOINT NOT FOUND! (Status: 404)") - print(f" The /api/player-edit-media route is not registered!") - return - else: - print(f" ⚠ Unexpected status: {response.status_code}") - print(f" Response: {response.json}") - - # Test 3: Get player and auth code - print("\n[3/6] Retrieving player credentials...") - player = Player.query.first() - if not player: - print(" ✗ No players found in database!") - return - - print(f" ✓ Found player: {player.name}") - print(f" Player ID: {player.id}") - print(f" Auth Code: {player.auth_code[:10]}...{player.auth_code[-5:]}") - print(f" Has assigned playlist: {player.playlist_id is not None}") - - # Test 4: Test authentication - print("\n[4/6] Testing authentication...") - headers = { - 'Authorization': f'Bearer {player.auth_code}' - } - response = client.post('/api/player-edit-media', headers=headers) - - if response.status_code == 401: - print(f" ✗ Authentication FAILED!") - print(f" Response: {response.json}") - return - elif response.status_code == 400: - print(f" ✓ Authentication successful!") - print(f" Got 400 (missing data) which means auth passed") - else: - print(f" ⚠ Unexpected response: {response.status_code}") - - # Test 5: Get sample content - print("\n[5/6] Finding sample content...") - content = Content.query.first() - if not content: - print(" ✗ No content found in database!") - return - - print(f" ✓ Found content: {content.filename}") - print(f" Content ID: {content.id}") - print(f" Type: {content.content_type}") - print(f" Duration: {content.duration}s") - - # Check if file exists on disk - file_path = Path(f'/app/app/static/uploads/{content.filename}') - file_exists = file_path.exists() - print(f" File exists on disk: {file_exists}") - - # Test 6: Simulate upload - print("\n[6/6] Simulating media upload...") - - metadata = { - "time_of_modification": datetime.utcnow().isoformat() + "Z", - "original_name": content.filename, - "new_name": f"{content.filename.split('.')[0]}_v1.{content.filename.split('.')[-1]}", - "version": 1, - "user_card_data": "test_user_123" - } - - print(f" Metadata prepared:") - print(f" Original: {metadata['original_name']}") - print(f" New name: {metadata['new_name']}") - print(f" Version: {metadata['version']}") - - # Create a dummy file - dummy_file_data = b"fake image data for testing" - - # Send request with multipart data - data = { - 'metadata': json.dumps(metadata) - } - - # Use Flask test client's multipart support - response = client.post( - '/api/player-edit-media', - headers=headers, - data=data, - content_type='multipart/form-data' - ) - - print(f"\n Response Status: {response.status_code}") - - if response.status_code == 200: - print(f" ✓ UPLOAD SUCCESSFUL!") - resp_data = response.json - print(f" Response: {json.dumps(resp_data, indent=6)}") - elif response.status_code == 400: - resp = response.json - error_msg = resp.get('error', 'Unknown error') - print(f" ⚠ Bad Request (400): {error_msg}") - print(f" Full response: {resp}") - elif response.status_code == 404: - resp = response.json - error_msg = resp.get('error', 'Unknown error') - print(f" ✗ Not Found (404): {error_msg}") - print(f" Make sure content filename matches exactly") - else: - print(f" ✗ Upload failed with status {response.status_code}") - print(f" Response: {response.data.decode('utf-8')}") - - # Summary - print("\n" + "="*60) - print("DIAGNOSTICS SUMMARY") - print("="*60) - - print(f""" -Endpoint Status: - - Route exists: YES - - Authentication: Working - - Test content available: YES - - Database accessible: YES - -Recommendations: - 1. The endpoint IS working and accessible - 2. Check player application logs for upload errors - 3. Verify player is sending correct request format - 4. Make sure player has valid authorization code - 5. Check network connectivity between player and server - """) - -if __name__ == "__main__": - try: - test_edit_media_endpoint() - except Exception as e: - print(f"\n✗ ERROR: {str(e)}") - import traceback - traceback.print_exc() diff --git a/verify-deployment.sh b/verify-deployment.sh index d23abcc..e6d59cf 100755 --- a/verify-deployment.sh +++ b/verify-deployment.sh @@ -25,19 +25,23 @@ FAILED=0 WARNINGS=0 # Helper functions +# NOTE: `((VAR++))` evaluates to the PRE-increment value, so the very first +# increment returns 0 — a non-zero exit status that `set -e` treats as a fatal +# error, aborting the whole script after the first check. Use `VAR=$((VAR+1))` +# (always status 0) instead. pass() { echo -e "${GREEN}✓${NC} $1" - ((PASSED++)) + PASSED=$((PASSED + 1)) } fail() { echo -e "${RED}✗${NC} $1" - ((FAILED++)) + FAILED=$((FAILED + 1)) } warn() { echo -e "${YELLOW}⚠${NC} $1" - ((WARNINGS++)) + WARNINGS=$((WARNINGS + 1)) } info() { @@ -99,19 +103,38 @@ else fail "Docker not installed" fi -if command -v docker-compose &> /dev/null; then - pass "Docker Compose installed" +# Accept either the modern compose plugin or the standalone v1 binary. +if docker compose version &> /dev/null; then + COMPOSE="docker compose" + pass "Docker Compose plugin installed" + DC_VERSION=$(docker compose version --short 2>/dev/null || docker compose version | head -1) + info "Compose version: $DC_VERSION" +elif command -v docker-compose &> /dev/null; then + COMPOSE="docker-compose" + warn "Using legacy 'docker-compose' (v1); the 'docker compose' plugin is unavailable" DC_VERSION=$(docker-compose --version | cut -d' ' -f3 | tr -d ',') info "Docker Compose version: $DC_VERSION" + + # Compose v1 builds require buildx >= 0.17; older buildx must use `docker build`. + BUILDX_VER=$(docker buildx version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1 || true) + if [ -n "$BUILDX_VER" ]; then + _maj="${BUILDX_VER%%.*}"; _min="${BUILDX_VER##*.}" + if [ "$_maj" -eq 0 ] && [ "$_min" -lt 17 ]; then + info "buildx $BUILDX_VER too old for compose v1 builds — deploy.sh falls back to 'docker build'" + fi + else + info "buildx not available — deploy.sh falls back to 'docker build'" + fi else fail "Docker Compose not installed" + COMPOSE="" fi if [ -f docker-compose.yml ]; then pass "docker-compose.yml exists" - + # Validate syntax - if docker-compose config > /dev/null 2>&1; then + if [ -n "$COMPOSE" ] && $COMPOSE config > /dev/null 2>&1; then pass "docker-compose.yml syntax valid" else fail "docker-compose.yml syntax error" @@ -169,7 +192,7 @@ if [ -f requirements.txt ]; then done # Check for specific versions - FLASK_VERSION=$(grep "^Flask==" requirements.txt | cut -d'=' -f3) + FLASK_VERSION=$(grep "^Flask==" requirements.txt 2>/dev/null | cut -d'=' -f3 || true) SQLALCHEMY_VERSION=$(grep "^SQLAlchemy==" requirements.txt | cut -d'=' -f3) if [ -n "$FLASK_VERSION" ]; then @@ -202,35 +225,41 @@ else fi # ============================================================================ -section "7. SSL/TLS Certificate" +section "7. TLS Certificate (Caddy)" # ============================================================================ -if [ -f data/nginx-ssl/cert.pem ]; then - pass "SSL certificate found" - - CERT_EXPIRY=$(openssl x509 -enddate -noout -in data/nginx-ssl/cert.pem 2>/dev/null | cut -d= -f2) - EXPIRY_EPOCH=$(date -d "$CERT_EXPIRY" +%s 2>/dev/null || echo 0) - NOW_EPOCH=$(date +%s) - DAYS_LEFT=$(( ($EXPIRY_EPOCH - $NOW_EPOCH) / 86400 )) - - info "Certificate expires: $CERT_EXPIRY" - info "Days remaining: $DAYS_LEFT days" - - if [ "$DAYS_LEFT" -lt 0 ]; then - fail "Certificate has expired!" - elif [ "$DAYS_LEFT" -lt 30 ]; then - warn "Certificate expires in less than 30 days" - else - pass "Certificate is valid" - fi - - if [ -f data/nginx-ssl/key.pem ]; then - pass "SSL private key found" - else - warn "SSL private key not found" +# Caddy stores its internal CA and issued certificates under data/caddy-data. +# Those files are created by the (root) Caddy process, so read them through the +# container rather than from the host filesystem. +CADDY_CA_IN_CONTAINER="/data/caddy/pki/authorities/local/root.crt" + +if $COMPOSE exec -T caddy sh -c "test -f $CADDY_CA_IN_CONTAINER" 2>/dev/null; then + pass "Caddy internal CA root certificate found" + + CERT_EXPIRY=$($COMPOSE exec -T caddy sh -c \ + "caddy version >/dev/null 2>&1; cat $CADDY_CA_IN_CONTAINER" 2>/dev/null \ + | openssl x509 -enddate -noout 2>/dev/null | cut -d= -f2) + if [ -n "$CERT_EXPIRY" ]; then + EXPIRY_EPOCH=$(date -d "$CERT_EXPIRY" +%s 2>/dev/null || echo 0) + NOW_EPOCH=$(date +%s) + DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 )) + + info "Root CA expires: $CERT_EXPIRY" + info "Days remaining: $DAYS_LEFT days" + + if [ "$DAYS_LEFT" -lt 0 ]; then + fail "Caddy internal CA has expired!" + elif [ "$DAYS_LEFT" -lt 30 ]; then + warn "Caddy internal CA expires in less than 30 days" + else + pass "Caddy internal CA is valid" + fi fi + + info "Install this CA on client devices to trust the internal certificate:" + info " $COMPOSE cp caddy:$CADDY_CA_IN_CONTAINER ./caddy-root.crt" else - warn "SSL certificate not found (self-signed required)" + info "No Caddy internal CA yet (created on first 'tls internal' issuance)" fi # ============================================================================ @@ -255,42 +284,92 @@ else fail "app/config.py not found" fi -if [ -f nginx.conf ]; then - pass "nginx.conf exists" - - if grep -q "ssl_protocols" nginx.conf; then - pass "SSL protocols configured" +# The reverse proxy is Caddy; data/Caddyfile is the live config. +if [ -f data/Caddyfile ]; then + pass "data/Caddyfile exists" + + if grep -q "reverse_proxy" data/Caddyfile; then + pass "Caddy reverse_proxy configured" else - warn "SSL protocols not configured" + warn "No reverse_proxy directive in Caddyfile" fi - - if grep -q "access-control-allow" nginx.conf; then - pass "CORS headers in nginx" + + if grep -q "admin " data/Caddyfile; then + pass "Caddy admin API configured (needed for live reloads)" else - info "CORS headers may be handled by Flask only" + warn "Caddy admin API not configured — HTTPS changes cannot hot-reload" fi + + if grep -qE "tls internal" data/Caddyfile; then + info "Using Caddy internal CA (intranet/non-public hostname)" + elif grep -qE "^https://|^[a-zA-Z0-9.-]+ \{" data/Caddyfile; then + info "TLS enabled (Let's Encrypt)" + else + info "HTTP-only configuration" + fi +elif [ -f Caddyfile.example ]; then + info "data/Caddyfile not present yet; deploy.sh seeds it from Caddyfile.example" else - warn "nginx.conf not found" + fail "Neither data/Caddyfile nor Caddyfile.example found" fi # ============================================================================ section "9. Runtime Verification" # ============================================================================ -if docker-compose ps 2>/dev/null | grep -q "Up"; then +if [ -n "$COMPOSE" ] && $COMPOSE ps 2>/dev/null | grep -q "Up"; then pass "Docker containers are running" - - # Check if app is healthy - if docker-compose ps 2>/dev/null | grep -q "digiserver-app.*healthy"; then + + if $COMPOSE ps 2>/dev/null | grep -q "digiserver-v2.*healthy"; then pass "DigiServer app container is healthy" else warn "DigiServer app container health status unknown" fi - - if docker-compose ps 2>/dev/null | grep -q "digiserver-nginx.*healthy"; then - pass "Nginx container is healthy" + + if $COMPOSE ps 2>/dev/null | grep -q "digiserver-caddy.*healthy"; then + pass "Caddy container is healthy" else - warn "Nginx container health status unknown" + warn "Caddy container health status unknown" + fi + + # Probe the actual endpoints rather than trusting status alone. + if command -v curl >/dev/null 2>&1; then + HTTP_PORT="${HTTP_PORT:-80}" + HTTPS_PORT="${HTTPS_PORT:-443}" + + HTTP_URL="http://localhost" + [ "$HTTP_PORT" != "80" ] && HTTP_URL="http://localhost:$HTTP_PORT" + HTTPS_URL="https://localhost" + [ "$HTTPS_PORT" != "443" ] && HTTPS_URL="https://localhost:$HTTPS_PORT" + + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -m 5 "$HTTP_URL/" 2>/dev/null || echo 000) + if [ "$HTTP_CODE" != "000" ]; then + pass "HTTP endpoint responds ($HTTP_URL, status $HTTP_CODE)" + else + warn "HTTP endpoint not responding ($HTTP_URL)" + fi + + # TLS site blocks match on the requested name (SNI) or on default_sni. + # localhost matches neither when the server is configured for an IP / + # intranet hostname, so probe the address the config actually serves. + # NOTE: default_sni is indented inside the global block, so anchor on + # optional leading whitespace, not `^`. + PROBE_HOST=$(awk '/^[[:space:]]*default_sni/{print $2; exit}' data/Caddyfile 2>/dev/null || true) + if [ -z "$PROBE_HOST" ]; then + PROBE_HOST=$(awk '/^[[:space:]]*https:\/\//{sub(/^[[:space:]]*https:\/\//,""); sub(/[[:space:]{].*$/,""); print; exit}' data/Caddyfile 2>/dev/null || true) + fi + if [ -z "$PROBE_HOST" ]; then + PROBE_HOST="localhost" + fi + + if curl -sk -o /dev/null -m 5 --resolve "$PROBE_HOST:$HTTPS_PORT:127.0.0.1" \ + "https://$PROBE_HOST:$HTTPS_PORT/" 2>/dev/null; then + pass "HTTPS endpoint responds (https://$PROBE_HOST:$HTTPS_PORT)" + elif grep -qE "tls internal|^[[:space:]]*https://" data/Caddyfile 2>/dev/null; then + warn "HTTPS configured but not responding on https://$PROBE_HOST:$HTTPS_PORT" + else + info "HTTPS not configured (HTTP-only deployment)" + fi fi else info "Docker containers not running (will start on deployment)" @@ -307,11 +386,18 @@ else warn "Possible hardcoded secrets detected (verify they use os.getenv)" fi -# Check for debug mode -if grep -q "DEBUG.*=.*True" app/config.py 2>/dev/null; then - fail "DEBUG mode is enabled" +# Check for debug mode. Only ProductionConfig matters — config.py intentionally +# sets DEBUG=True for DevelopmentConfig and TestingConfig. +# NOTE: grep exits 1 when it matches nothing; under `set -e` that would abort +# the script, so the whole pipeline must end with a command that always succeeds. +DEBUG_LINE=$(awk '/class ProductionConfig/,/^class |^# Configuration/' app/config.py 2>/dev/null \ + | grep -E "^[[:space:]]*DEBUG[[:space:]]*=[[:space:]]*True" || true) +if [ -n "$DEBUG_LINE" ]; then + fail "DEBUG mode is enabled in ProductionConfig" +elif grep -q "class ProductionConfig" app/config.py 2>/dev/null; then + pass "Debug mode disabled in ProductionConfig" else - pass "DEBUG mode is disabled" + warn "ProductionConfig class not found — cannot verify debug mode" fi # ============================================================================