From 29b302a343aea0e6d36c21918a20613cada0d2b2 Mon Sep 17 00:00:00 2001 From: UserIsMH Date: Mon, 16 Jun 2025 21:14:13 +0200 Subject: [PATCH] Refactoring - Part1 --- .claude/settings.local.json | 3 +- ...ocker_20250609_145347_encrypted.sql.gz.enc | 1 - ...ocker_20250610_000418_encrypted.sql.gz.enc | 1 - ...ocker_20250610_015849_encrypted.sql.gz.enc | 1 - ...ocker_20250610_223246_encrypted.sql.gz.enc | 1 - ...ocker_20250616_211330_encrypted.sql.gz.enc | 1 + v2_adminpanel/__pycache__/app.cpython-312.pyc | Bin 0 -> 172201 bytes .../app_refactored.cpython-312.pyc | Bin 0 -> 172148 bytes .../__pycache__/config.cpython-312.pyc | Bin 0 -> 2358 bytes v2_adminpanel/app.py | 717 +-- v2_adminpanel/app.py.backup | 5032 +++++++++++++++++ v2_adminpanel/app.py.old | 5021 ++++++++++++++++ v2_adminpanel/app_new.py | 124 + v2_adminpanel/auth/__init__.py | 1 + v2_adminpanel/auth/decorators.py | 44 + v2_adminpanel/auth/password.py | 11 + v2_adminpanel/auth/rate_limiting.py | 124 + v2_adminpanel/auth/two_factor.py | 57 + v2_adminpanel/config.py | 64 + v2_adminpanel/db.py | 84 + v2_adminpanel/models.py | 29 + v2_adminpanel/utils/__init__.py | 1 + v2_adminpanel/utils/audit.py | 37 + v2_adminpanel/utils/backup.py | 223 + v2_adminpanel/utils/export.py | 127 + v2_adminpanel/utils/license.py | 50 + v2_adminpanel/utils/network.py | 23 + v2_lizenzserver_backup/Dockerfile | 18 - 28 files changed, 11124 insertions(+), 671 deletions(-) delete mode 100644 backups/backup_v2docker_20250609_145347_encrypted.sql.gz.enc delete mode 100644 backups/backup_v2docker_20250610_000418_encrypted.sql.gz.enc delete mode 100644 backups/backup_v2docker_20250610_015849_encrypted.sql.gz.enc delete mode 100644 backups/backup_v2docker_20250610_223246_encrypted.sql.gz.enc create mode 100644 backups/backup_v2docker_20250616_211330_encrypted.sql.gz.enc create mode 100644 v2_adminpanel/__pycache__/app.cpython-312.pyc create mode 100644 v2_adminpanel/__pycache__/app_refactored.cpython-312.pyc create mode 100644 v2_adminpanel/__pycache__/config.cpython-312.pyc create mode 100644 v2_adminpanel/app.py.backup create mode 100644 v2_adminpanel/app.py.old create mode 100644 v2_adminpanel/app_new.py create mode 100644 v2_adminpanel/auth/__init__.py create mode 100644 v2_adminpanel/auth/decorators.py create mode 100644 v2_adminpanel/auth/password.py create mode 100644 v2_adminpanel/auth/rate_limiting.py create mode 100644 v2_adminpanel/auth/two_factor.py create mode 100644 v2_adminpanel/config.py create mode 100644 v2_adminpanel/db.py create mode 100644 v2_adminpanel/models.py create mode 100644 v2_adminpanel/utils/__init__.py create mode 100644 v2_adminpanel/utils/audit.py create mode 100644 v2_adminpanel/utils/backup.py create mode 100644 v2_adminpanel/utils/export.py create mode 100644 v2_adminpanel/utils/license.py create mode 100644 v2_adminpanel/utils/network.py delete mode 100644 v2_lizenzserver_backup/Dockerfile diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 207df2c..09cef33 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -61,7 +61,8 @@ "Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"Dashboard\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/resources.html)", "Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"Dashboard\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/profile.html /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/resource_metrics.html)", "Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"BACKUP|LOGIN_2FA_SUCCESS\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/app.py)", - "Bash(sed:*)" + "Bash(sed:*)", + "Bash(python:*)" ], "deny": [] } diff --git a/backups/backup_v2docker_20250609_145347_encrypted.sql.gz.enc b/backups/backup_v2docker_20250609_145347_encrypted.sql.gz.enc deleted file mode 100644 index c3e4eb7..0000000 --- a/backups/backup_v2docker_20250609_145347_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoRtlbzRM_KAWmOXLbfQTGXD163aDi0Cp0GH9xaKsgnG57O_YQzN_adA2EZCc3IWmRhPENQOHw393AZ_kudvQGzK-N-Xpz1A7-lL8som1E2gAH7FpquiqSEIIb6f5N7QNMcnzqI5Qy5vqahEOVRkfEWj6n3kyeF8pPVoNZXP9LgzotE3G_ROC9t9QojxcApfyUWo0mY87c0iN3FWayK8AveuQm-GR3OUkMS3XvBrZb7INtZ4gk1juwBKBDKc5FKP30TRuhwoDdsXeq6cIK3i9aBLcFvcGQTHAlyMpfIkbJxPaExrppT0TFix0bvY69c7OmTcds2SZkICJIzVo49j75bP8LHvEqBzFE6oGSAtPE-wCgYMaNwdHc_MCvvadGxW9rYTOJIWFXdrWj8gBty4eOY2m3m9MhNC29VDR1KY0fvWBoUpzLKWIXZvm_uyrLY6Le4EHJL3PmkQ_XTSpozuNFrzewphk1qO6rU9x8Q3Y4DANNcg2i8jUI8JhKEOVqv1NuUUdrJSzhgjnzSfm-WyQ_0gQi6cAH4ywpTfJPnZ3avrcpm1bskAI5rk5hhIXeB9U-dJ3wTurM0gvS1TPTpurZiHKBrp3kB_lqVmLDfblfOWYw7zdNtvS-SmbrBQbWZbOOzPodhS34u5nJQOUzn0_8bxBtwnC7L-0v4TqLCA5fBQhmZ6CUokU5aEFlTSYbGkgZCcP-KEjiVAovkWsmaTEViYraPfAzwT8FdXD7Sbxvf-a1DiXTt-tWdjAyhLVYBNZh3wKeAFzNF_6L_d0dtL6FKIOpyy__mbzE0nw0eEF7ZM21tVY-krFu4Tm4u_8SxkHO_RBDtflddha5ZMi7XVP0C5TjWm3qbLI4FOLWbU6ltcKo72_G6ZGdOhrQpzFvQCsplerFCIWjW2WHZFcZaqxPAdAjxh1GF9b3eGWLYlWeu4HGp-7JUdLnzGQ9Qlw3s-4ZPb_jj1tVmwaDVybgGKbqo8XO3DSuvxO1hE3abinH0-9krhTedOH4pvrnPacqRXCuAk7k2VcDS2m-3hlg82jOO9PX4anYvfRjiWg_NqUsPaKjSI8t4SW4rUVK8dw7016L6x389OebHYt43_2B0cgDv48qqA5yfwRVH6C28kKR2DlPkeGGGpQ2EsEY_uV4nw15rWzXOVJjHzHz0kSw64iWpVc0aka1TEy8M5ePSGeBgUXhVoLcpu6NStbts3l1O7CsvlpvYP74ksDmG765KxA5eGaziOhj-4YAlksD0CQ7Vg6XVTeYyZsxpg9d5DOmrunB6P25GIAbEpPkN61aJ6-UHuVisxTfxp-Ac3ADuMyIRkpzlDp3es_ocqXk1Vozo2xcSObV50vANCrT4L5dQLbA_tVqGtrK9l0602sAG1BV0_gFDp6s-KhaIlG7EgX1Ow-xjrDUhVcEbc275kHTy1-YFR4eMqPj1hvpM4_5MdMbjsFvCQBaRNwh12XMQEaaMB6SPcqJcOfri_C778XWrQqjNFWpnY6qaM8bWqN1vc2yyY4bNpCioC-nnQLlB3dmF13LUHT-2mOpGwo4QsyH5ooQ41YGEgJMFchtZMesxYZpE8k16vgBiLJswWxTm5I6f4gznk-ANjxZCu325D8-iKr9wtnwkEeUKAztzYabmEytV817R0nmVOlvDQIVTT5oLTWL3ZiUFGEhYVcxv1xsA2DEBrhiagv6h-akhe6Jo5ThkzXx8dPRfUFRBw77OpLKNo7Q4Ajk23QLi6SH1RMqAE5C7ol3bK4z8PyX7G-Z5nljd7YZxvaIaOUUict7yt3h4hsdQPg1YtAk06BpF0XAigFgccGD6AGc1IFU9_5UpiUE4AQQBiAs98MPePbBuN0-fz8_xwn_UOAsqea1DJj173j5mrvDLZvRDladT_-0ucGqkagzPgONA1JDTTkQ9E9wEfn0wubed2tOk1pa63WUDp8S73rzxqHp3mvWb3izCdGxO_DR__B5PpD6sYDQUB-J0ViSKFAca9ysI9ZqwoiXwwClblPDN1hBmPLsXJrt9ouQaq_ibpbOSxrVh8WfXL5pK28We1vASFap1PeOiFeKKsSzfURkzDs7lQExfuVKqEMJsYLPORE2-96NQ2_pXj0aefM7voUxsFxOQu1z2dKmE63Pir3QqCX7hq4TVyan_R1URcCQqS5yG9W-PMHMaFmur7IMxwub9fNodX4MlxXwlGdO9C9zgmk16iqphnGVKrVnlvRDKug_lcbCjwJSg_HyMavOMXZjJ1URtfjdfgub2wBAUuSA-wMoQJa1elMywY4UINiXR7fJGW8BL8TUOrPgMztQuTrxL2BTSRMOeZvC06cRYUUuEYDbv5JBSI_KvHd1AvZVFMSy-hfx41t4KwLicTJN5IOLkh8oD6Rhp79oms0vmh7CX5rDVgiyQZJ4rNhy2_v-D4Lh2NPFD3Qci4x6lQaM33KU4avlok3ZZiddGTbNyNZT2J1OnBsjItQGuLoehLnPNS39VX4xV3a5obhZ4_zc0GxFzNR85UqyIQHeIVkPLN68TwIg3FP63lQjA56dcoNT01n9j61XVfmRPogF9vQMauT10fhOf-LzlHGRp_rMuYn_WmC_o1PATDq0eKKnVrU3tiOMSSMKJtN5k2l03u208XXGl6Hq8rcotghf2u-YGKZrG4BuXhMwgZicpiOhk6OleMxarjvb2A3wkdKuP6iP2XMwuOW3K3TnGim6-4p_vvz83bvnPe-inwLyVkGHqLQVnTLG95_Y1arLQGPBtIYpKlIvfP2VsdUlhcCSXKKZQ4vsBFwMGbOVYAwFAXc174Mq99hBk-1j0Y1KzMO-2Pzb1jGiuX6ZQfNnE1o5czRLVhMxJ_cD8otcYBvGA0rMJmFoSP8VUFaTtdWRyZQYPPobB3Hi_6VTF6uT4QK-LTHQUhdo62aVKzzomqebOgESGWOSIQ4Ja-utenEpZZJ8Zc7XA5Os95D5K0ssS1Z7h_l-3njq70Ly9VxCv9hQV1TX8zIAHKOA9BWf469MC7SSNbHMqxGGd99DVDFwd4KgrxzuJtmvrf5EMIbfs68muR7mOA6GMV7XGiZ1a9cYlI8D0iXwBAld62Qn0iSnbI3FjrPHgUF1sRS28nXJdIb7SHZ0yg0VaAjogD2BimcWq9I93LiSvbFELQcrbouwJCbqDtY6fT_KJQGOCvlwWI7LUtgsI_HRweW-ix14UFKmt3EMtKRfvVUiegPqhVU2kxy_2M6CYbllXQX2riR4-UUm2gIAxcrdkN32N2MqiHNoNbX-UnowfbSXbLgu0cQhJf8_of76JSQ9B9jRpeE6Aenh4WQYFFvdE2_cBgtuc0pfeG86iAmWFxtwXcCQ0EKHaSe5MYle7_LlwArte3XalxGdPAov7i1T35ChP3QKCbztQ2Olb4F1n2maI4D1YgJOfmsm75z8_jq-L08aZ77Ir815WG37jPPsJfgDrMBerqRefr_rQt9zBErGdMsNiU0cnPB6LBj2BtdJcIbhUIwPs592MKiVswqIq4Mmv8lbXd2k4wuGAPF_BqSZgWG3Rp5yW18J1JNFyXdPBPhD5yF__aI1vCjsvnbgfDMjeuFllVESj2OWMgtpGpsF6CKgN6z95UMolA-2sYmpGrgLuqZy2zMaK86GQMAIqdmpEWMF4ot0jKYYmR0UaWKaZSMEkOtM30WNi3onDwTl4Kc-Urn3VgwRH2Cy3fWlgw3zNy8guZ_egyGLiC5RuyKjKifSxgEwsxYL7-3Z8ol6w51BA8AGNU7vCiLV0tf4MDmkrqdAHutddVvp6EEUlqoSM2iHqLO9ikZ8QRpfvYyf0s87SN9z9betRjSUkLCWk0A1Fnu8mxGePghiVByrWl3L4-L4AiQFtXQugg5MSazYp3q5_3uAazAG5vjBXnuOkG_4g9rBjwVFF4NaVJZtGlmV0hqnd4dZDDNZASJOUu2F9vJWfMwBiI18VJOtIQ0k2K8ozi8SDydg_Yqv6zpjPpaa2KzMnWkT0u7Q04nML7yGhJDOAxCXr4iIrFZlu7MKRrowLn18Exr_viXUXTuvMCFK9dyiR7DmUypi2RoLHYMqcQt9RoJCCVkhuoVYp3h9axJvwqNpHbJGb9L8vA02O-aPhfzx7ETIbIuZuboak9D8DC2EbphLoeGO7OoxOzLKAEhyFXfQS236uoW33AGihEWRGMAg3657Fg0sFyYd4NzbH9GmQNlHj2LjWeBqSBgZdCnsLaQPxZgVe3ta86ESlFo9g8JI5m0tABPJ1IkY67n6CNEDfXu1IP8N9UL7f-TY_HGbl9_MlfjdY_kMzt2qnt985CsBWXCpXkS9mAn0DdpXxG3R6yrIF2i2qzUpJlvjb0HdV1eN-KUyD4xBfpcdO-RTkg_XQvIihUITnuAm40q6-vuFb7MqwYlg9OVgGlsrhRTge3cUnxzMkDL-kjbahAqLwHTIpFxT23kMRsJHniJMXW8nELGHXEJ4rLiUGe2Vf2-mlR0W4AzwkZftdIO_opADyKVC0Pl5ZLWZGfuRlFt2SgTYaysQFqDdqq3tz6sHYTcvEyNlDI0KAULikYxH_wNI1-TcjopMblHxlzCAtHag6odm_Q8imfqN-gTNwGCtdD4VX_sjuItq_snY71H4pwK8mfnZBh_LyJzuYoXDMMr4A-zKJsTCIJ2AkOv6X2NtIL1sYxxEApmo7gjIgQ5PdYTGIC6wYbk97bJb5ndiYqGgEFwBEp6GxbhyTWfBDgJRcNZLGmeG61p-xwvlnNASOhPzh_UulSuPFUJnG9xYPxds3uBX_6hvRksWaalgFEDeJO8Iwc4C5gbOL3l5A_NZG4T-mjK-W4q3c40oesGp0xnLc_KuluR16lyvHbc9n6DIKYwVimgsj1IXE4g76VSBJELqpN_nsNJ9rY3njS7-oLjsZ4eavfdk2HZNG0fMMwQ2F7u2Edul1DltygMadNE438Tef1nkRtzNjxFlxYbsBWd8UqKYsgM7O5OffWJU3jVuYIYc1Y1pD5HUK2gFhbuYPO70Ysg7hXAg600_N9ATWD4TvKs5m_4ziDrlTg-DdZsrsE9e9ioriCpwAb7xzn8PfGt0SU3EPpjqjykK-flqwI-FkThbUBKJGe1hurLm_G-bRFIeDxw5rOJXT49nR_IPmPBfW9gB4S_p5--NqIQQTFKPAVE09wZmrbi908N3vurtHjsddIryz3_OLU6KBZaKstVDFdCd3vmmfvXjLy_z1pOps3UrY6u5yFUg2GYrPh-IorJyZ8Ubo_1WlgzW-X_3amURfBNq0Lf1RHELCbTBcyTUgRQEQQsNveHrwRvYZMmQhrEEtpUVK_4BbFca0U4owrtvcGHbox6LTqWITc_bzLnkdLOlvYfnmUURrNFxHO73P6corNymxXrruXyEm35wZjiRmXeaHqZbWVCX4fcOD11Df6hYG5fxZdHKqnhMXK1Gmrs5JUMn5Om_2w7TEdeE_iBo7h9cdOqHemZPQaBYqxaweT80zUpKAMBK4W0uTgNNpZEU_myucHgeGVSaQeN47Edz4aFO0yRpiPLbUydnod2M3xqkJ0JpEz19lnruJr7kqfizA1Y0CVXwotyjenv0jj-N-b5ucFYdR1q0Z3wJTFf_RK2XqOtpryOq-WIXbPvQ2hb_lKyYWBBiPiVqq-T7etNCV_4RiwDJhRAomBFf19nwvgNa_6rLDhvlP6Omu7SEhSLTNhShM8i-H1U-ez-JXB5BCRDDBZjGCtPDB8vC5JJ-NFW2l-ca5ABnCnXjYfin4dxS9NMLUh53zuCCL26EsQQBsCKbM3KcIqov6PZ3pDFlvwUCBiN3Jiihr1fQrAtiNBmoA9l4p4cdLtowIYHLnc103h9iyUov88ngTQnpi6uGT3GzwYzM2liTKd0c6ZxjsSENlnLmav-ESixo1jDYmeYIFofAEhIjJSu1kIla44pbTVBLrj5U3DPe6JuOHtAXOOtrzP1e1fMUYweAFrqY9Sb7vVcTg1kDLdVgXp346mJaZQ_5uF6-ZvxXJ4yP3Abi9VK8sEA2igyxPOLE6MSqFngIijkWB6ez4_8YXlsFphIY6ELw1_13Tzcay1YV5JzpHl0PdNuZ9b9efvlSa4VAuGKAxUtyAxfXrNr2RuGyhP2dmpQoph16b114kAmroe8A1MX6sedNQKa9BCfcjSqkKY44h8gCBOxm1hABsPNUlsV2oVtN_yZPE3HHG6zV28nLE8RfXF838oMc2UFjkdY_TMXM1sG-I9Fll4U2Y2YPEJaOwuE3VtI2lLEwyA6CDnnyKdzucBYjZ_Y4fOpjDzoHYrNTxtokBQ1bUAQ5YscRsdfDn3pfKJ7V5wmOfisVhzCg7mfAUqEUdi4MEx2zHRNqx2XDpCqdTGhoLcgJdRu-gvN410KQnEetywFWG16rSP2UMQfRfDJGbpDVxItmZTmjowuHC2oAyH6iIlG7xubydi0VCCdgaUHRDVw6vMLf0ezOuY0XkFoVWMuwA1fEsYHf-kkEIGOjmC2TIXdFNlr4ViDKvtLkae5X1JXLKnBSj4SkpcIbT0X2BXUuathaq_05--QfzNjrsHCPPDgVviS-v7u2GRx2_Bv4Ce9qB1-qV4xtLA0L7Ls-x3wv3q9jsAGhzMHLqWtUkkPOLf28Hs5N-WsBEX2YmpdIPeHlg3zhAdOIuP4tB8AXgGsJcKnWPyZG4rKyyCtzFQKdOIAKCHGwqG6WtAdV254K5HiuIp-W1krHDxRmsfBTiEXjlleVuEo-EgYMlLskvvzBMaTFf0xULx4SppqFPbZW7RJDS2CxfCDDq6BK5xRo5T7gdlBoIT05oKoEBq2omyzbHuo2PhuS_aHcF61a-dgj7OQ4ImD-yl4WwIg-N7owPTFZ1DXanlXljJWVoRD1wqRbOTug1OvatfdOSSshf2IRGgYto4I2wVHMdzVsmrrYH477QHXPL8noEvhe3Vy1LHRL1TedAoiezYnWVBYxVK0gRL6ydNc2p8ltMIxXnHb87tYDmyHtJSpzrW5gUwU9CJ6DRDGylW0CHHyLIPRS7AHb0sMNF1iVfTdYgdX_hgBhZlwpUOO4vVzSY89yC3eR1AWzc0shVfquj5a5Q5jAdl7paNhH59rwN_eo-fcjvTG3Iw3F1zVdYsnVP0b2nGNMeaIyeQCd-lPAH2StH8Opcuvvs5Z57qAvDwHgJbbJyJalWbIVNcdIZbVHGlNIMsmeYBefNjuaF6q8wvV6QKela0sJJ4qUHqmYJPPSdWxVB3Y9hQJQLjcnYPqAGBMhdfAQRL9m-j8dKXp9btjNKU1QDBSiwRJa_QZsSL3bR_pPOH5EQ23WWvjMnwe0-9X5N2kuNxw_tcm3O-G3lJVwMt6Z4IQDOkz66R3AYqiSy_tR3RNGJPeXUZrRT3Hv-t4YjXAPor5l--_--C0rU7xNJHs9lPLy0lhlvjd0lB2762yucoAKWQxwm8xn-Q8GQvwir-KtNzENoHKKdNP27nQlShREewtPdMCtOybF7bJFS34Cq9h49Us2eIB3QA9yLIJ9TtNDxVD9b9UhJcJkQ9VrS8aAKHq3j7_Glq-5kltBEBIOagsY8f8eNwJKlCYVCYUSmbtw3QdlsUrG5Yu-S2_FFBO8ZNAqTww0x1R1ywe6RDJXRQ-AyD7I5T8UpTcSFYwYo1damXdUJhQnKzM69iVBMLsNTEiwnn1jiYcNSzMSsCylh9M9EeTLSkBc5hH8EsqYkbEM_lTNiBxO2sfTTYveW_gdnUd4v822nS1E5yIRk2e3G4RTNXnpbS8ys7SKfVRf2esAqLvKE9660hBH1F7X8l-T62JDCA_id3umudDGgevYxFEDFddrvgk0LMel_seFQ6k7f2zjgGgkQl-DSQd2To3BZFKZi_B8hPk16XtWrIANOazwOmyTSsY1CptJq2s3nQg_f6b9-TLDFCZJFdnmURQirSyrX67J-Z2BL6amI4gJiUV6plIVI9OCW1_gutzQ0YizwXLcnVdf-wdt2_yA8Js34Rq_5EJ_pUKgufU2q8ZiQetthDgrB25rk95wtBWemW9-cFAEqQXaGXgmYvD95OdNUVp8_oIvbzX0T0RP2ryi8LFJmSrtv0X8AuNPp946A6g2s-kff4VfFlBZU0NpH4bS6wHHdZzZVp6b4WdcnYLjvUfFQN4zpjKwfoB6YZvCKDuK4mWFBwx83ghHHKoNOeGqsbx4wt77V_88MR8-Q__fhwERZngkXSOaebfyFgtJ6dY8KpuOVoA1M4kFw4qbeU6nm2WSm_nxDe8ZjQ4IdRNWRerrgUAlzXcwiX3F_O2HrfQ9T7iG8xa8BjVuLwOtbW3GY7e7dfyEi5vBM2u4uYkHXmuqI7hMyr84r9ltdZxgngQWVKiHiccN8IOt9rn88nrqBlPckOxu9EhinTs_oJqxXht39C8v9j8qzOzuuO07Gfadm3HF7VIAFRCNED9HPbzrd32TJbwkLH3u0utCzqm29KPCZemT_YDKhbD_nEtAp2C53kzlqBCiJ5j9STKWxTTne3O3ZUtNFOElkywLAu2B3_7CO73r9yKIMVymWiy8Qbwnh8R67qRMH7begZ1OfAjo1brKDPaJe0ITYLS9EXCkxcwSvJJtZbB0yR5a2Z9GlDCqKgxOnGrcZRCYps8oS2SUkmztYvuytfIHJkAUPJOvK2_JrpAMBGt2avM6LcIZj2CtiH0XG3DtIOCZ9vioXYydbwUzKAhLD2eG8Kxz-kgba21CWX0uA52l1LRBqCyP_R3dywvYC-6ApY0di-FUYoYXZn8LyYUpAVHBUbdfTwO2Ix0xPk_RyWO3gs2qZ2w-AGmyVddhOaDogu24Cr0loI-KpFQR5PTHLE6jSNhgepnLectEM3l-IHP3nKgFo3RhYJ6nqEu96juAE3JuQ4XBwURTADCfIWYHytn96yIHfV3SZSbJu8Z6hMTgr5iM7lLTBxAwQPsWmA5QE1Q7flWzyzFginJBuJu_XPiGtsYbWXwjh1ga7xiB3R4xxRkREUrv9jXpkrYzIOCs7vlNLDkyMPwAc37o3ehZa2JIi0vno0G_XZ87mcfDusAtfe0UVa8J9cFbfdezfuaBq9LbbOG9Ffcq2gi7QgBzhlj_rnX39YGi4jug7b33BWUeB0yWA0K5_sURbOoGA00m89ptiQc4ZF5O3o8QekP0EZ48q0ijxAmkW48Yon4VG8J8w-nDpgvCP_9ZZhhJ9GAiqIwBa1cvRYL9GcZUKuPywPnw6u9tqRj2Xav10pe4hEzga3OLVUPYEW4gOpKwn2cgGRif1M2bBGypUTU80AcFWDIzktiWB5gDmuJ_ufP-cdXpwLtrP7-Utqz8RYkd1oLVcI9dfWosEjOWzpuTQq8-zZmAKXkPEhuwT5Ck-7mWh0wkLl14cAh-N_w3DkEm1RIbFOKObESdS38iinnesnrTo3iyE-sj_jPJlFOO6CQkcOsXMm6hEzAF2YF39zIWcnATZJx1NflwfgnrOKTZtKFnP3sp6DZCe2L-HRxu3-4sBHiySG3cdIk-uwoq6Jsi074B6QAej8ZTucUGAmhcT_nqELuY94Y80Iikjmn4TxPcA62w6Cl7p9R9VWCOJiCk80KsSkVleGrpgnNLkDQAnsOma8ghPpjaqRk9zMr9EP_pGP9gASkoW5WV42o2wxUXsbEg_FeXafLtiTAUEiNTrTrWceMYcjsreSnoZCm9K6pJhNFTBmuChS1KL6brS6wXzXoSEeP_d665drx0H7DSjNW-ZK0q09o23oSOU_82HXWIPccNbGkT-dkHStUX2vwbOO1ef97_ZYHsgCnRVUubY0v0dOlHJ_aKXPAOWhMFz94vztEHcD_nMwPz_uvHQU0inJATvPtAFiTMNFDCrCWL1pQE9IjHi4jCcNHvIbb2d2ig4xnEsu3i__8h_QtyzAvnxMXk_9b2t11gLK3_yGbbFy3EVlCBkqHho-neJySmsxB1qF2Eb1Gg-NLG4ktPEr4YmWFwnxQh-QqUQeAdsdgGldC82CeWu7Yor2iNuYEJ8tkGROhm0sUf1ctkwLH_-8zUuXW5OEPNAJyFc9aIq6h5UueVnE6lglIK6nS9ysK3VDaVUlk5aFssVkb2mZiMZ42oyhpsNL6kqrIPbANc9fmjMGTSFSlKPmAiyGPgSFzbVuGkDzCuhQZAx4FX6WNDImZsZcdIQrjDdlmAH4QEBYq3EuwW0yiqcfQ2VjbkmneDY_hsXj8faT30NxeXKsQPg73QI-_PFl0qJnxa2WuY-9QkD4f924MUfJ4HvAIwNkQ1hW5fJRCbGoZ4HJPcrK6niKl5YOCbVNUztSdaef-RqYKu90hX_0p2xKkAbYIKnTGGM1GoneDqTj076wCz-nCZruxDR-tawaJzMPbwL7iCCriOTMwz3i2Zwm5WHBu9RZtrQCSxH07XKvHgWLN5p0a14NX3JOpmbzm4uyxauNl6ppWNLEhLfa4b90OYncumCCtDe4pCk59_cRaLNFeZ_lUebq_wHMxwhaWWcQphUP7YyjKviJO5W7zbeo0gk-3hdX6j0iayzY-jzb8I11DIhw0lkIJ5bDXeLv3sckpfkEOzYGjuvb7MoJuFoTJNzT_ZtCHFbAgVsrWDWRZmcIb5yI71o0FgberxH1-jemDLJjWo09gWMQ8AAIC3XO-_kW_QHqNtjBlhB_lHgYzX03cMmKJ7TZMtdcqpnEqv39_IcTmGwB_S-XSDHcqVR0XFFs2LaAGdw4u8eMKw91s-CfZXocSTgQ81d7m1XZPIuLxiU71Ag_jAeBGyZP3DRfqoyV3JUI5-yr-aJo9SMw5mstwdVvT_Mer9HRPaFVq49GWYxJhtH2jLnXJmwFv6nxu0blHx43DjaD8MTo5E_7qCuRO2NzGsZKkSe8LfauxIAR4srhagdj-5T-Mz415NSUYHRq_S59Ba4AtNFJ9KnDSDZBTN8sHFSizeRO8i1KFfHriY94WPFUblCurHP80d1C-p-m0c9gyxCWz4MDqiIfegIZLSnQ81gmP9jXQuTRrw5BR8YVwfQFXMsrq443EGD8dbMbo9zju2BRGqEDFlsMjcqIHtHyX_RBovCqobhgTtfNVQs0igr9rzuhuQy6wkKKCw7JxBG3n4igz8tlarY9z4cnORejaC1GGVim11CPKK6Y6aCdARS36e6eMjxLK8hhOKzM1RlmAEf2aZI2vvkpHYZGCrcrm6NMgD701Wj5du5F-Ct7tTofVvQKGAIe2leOrWkcFothK9ZIEZjCLY65zU1mZENRW4TOK6xYHEzHMp02ra9mpx-mVOgAUmPrY8Bk3gsF1_VzzuMfpbPEnBYCQHjVLLz1soMiCKaP0ZKFvAgOsnAUWFVNjMgdpQKMYfjjuAGV5BjlzbwLa7-L4tqsEmgSjIPA6GYNjwE0vJJAFIrd02Ti8x9gbx0ZdLDh6rHzCJWNmdjIFht7sqXSKlnPE41_OiCRPpsjqlxUyPwmsuY8svZOQSwc9KS5iKsqoM1hvPyYVViO1ZS93GytnOfDUxLXlE7Uk426wvWZ7ToJ2mFjnEguBXjeV8rQ__NuGWgjiSXUeHhxrP35GXmnG04fN6zOv4MPzo381YjhbBvJclTkfdgqOIqxMY827tdgxUeQgfRY8HGro-qyr-wmhzxyzDUR4J9yT9MxfnDmEsqfEoh7_ImYd3d7YuMngL0Ps0wU5xuYszCWjOO4xn5VAvIDfB6qOix3K-C4064DlcchUhS9KzOv_BJo9FI2ce-Y-vjTm073hufv0RSpCFbYig5axIsWB02jh4iQ6vFQaa8hWtUP8ZMJuUyLXgkdlkPewTSbAJwdiAESxXzq8aQls9C2ZZ3d7aI3xD26SNke33nEgpNkVVE788dLAc-jQMP3iy0kRin7yvXcrBp-7UfMO8fCfcxWS-mulXmfuH59EIL0rm398qMPI-raxE_vaz48V5UGEA4IM44WZl0iku_Jr_aCiTc6SQ3dll5DiDnesbUbLx8Fa-lIYbbEDRQeeVj-t1UpHaGKzQWDFGgVmrXMWkTg-x_AatqM0MqRWkyY1rKiDxNt9Wo5IH2O7gLcNdJcizBfNHIi_p2p0WshFftjGq8xQatWmoqUZbry5Le-DmyFYCZKTkKV_oAsjZUToZ9IuSNSzH_TqBuihAoO1Mau_U9qsTwai15Sjqs6k4Icw0yCJ1YjAvyphE9t90Ff16RYWjhDmcbUB_5tiMue2S0F6rcejc2shlhCEPYJL_FmNg_7oBlHYYyvyH1sZVqnE2ZmlvNfZjeRCNfLzd9hWDWlLfR27J1l6SPRH-2FJygs14UHyY92xCXsjSDZduipqGfHVh22vbPjEiKYrmXp0tbCG_3x9W1k7iB7TvAGROW2fc7r4Jgk_7XayCjowUXwnMSRKm3iLQ0i7Dl4m1wKmX2lYx7tnw9BlUOQg_KJwGw8S_p060vuSS2WJFMPto9wtaOP1UYAJXwrrvLInyhAVS8q6_vLlQRILiHbN4eE60OkaVPKrPn7E8PiCM-UlHsDjjE70ptdh5c7jwhKlqZerxtYm_9Fw8jS5skf5AQxG6qYsqZAb6_Uwp8LWN6XYovr3uQHj7y6Fefl7IMgruK1pNSVTchNrVbtk8YubCkC1XbS5FLiOTPMQLI2XwNwi1Qj_I4F6JqgL_Z1LU0v7wKV1Rn70rZ8-ZBRG0rwVOMr8KZrtcNim7mpKJBcAMGuUWl1-coh1uYmWT6BLWewZ8OjDwA5ld8u6OhLpkxKumcdTRc6RfIBCx_qvds27d3kEVDG0zT28dV3mtZdk8rRMY5b2n5GBFCbHD32stDE9pos-YvpoaYu9WF0T5Yh5eOZbNhp1xJe4Do5GVRt_15gFEK5EFyZMrkNW8QLYVSlQ4TWNAIveWO4F_65cRJFyoGZAc7f7KY0tQ4sty9nrs863HcZP7rubEdwemTIK7nDsvHGXb6zOaiR68T5CZMYYdedSPRxfgzafw9M1wsVZMxaxWoBxXVdip5FknnLvhwTuA_-rR46ZbIEjGRt-5F4WMrPl2YMAD6ZLlhPx5qeS0_IwqhGFdET9detHI6sQJDFRGiPB4LeQMinGW7LvJxRlHxYEDaDa21Aq3UR12oHT0UK8KIR5ortDr-S0GU16NNFLzL2BQc3sPc3OR5kFRGJ5Tdi0ggA_N7nvr8zVQrOs7aHaCOyOgW3YnkbuMODm4nFC4i9JakUWKLV9eAB0Ilw== \ No newline at end of file diff --git a/backups/backup_v2docker_20250610_000418_encrypted.sql.gz.enc b/backups/backup_v2docker_20250610_000418_encrypted.sql.gz.enc deleted file mode 100644 index 4e613f7..0000000 --- a/backups/backup_v2docker_20250610_000418_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoR1piwzD4J479hhJoAAmwsMx6kY89R6SSKTT2FURHMIQ3kwrCmN0w-Q-whwN6AdUcbk54Pu1yCKE9RaWKExy6ab9PyDPq1mz3cReDBPaFcKufB-6gyRDB8ySxyhsLK4-bE0Q6SjAxbDd3wk2l8g55G2smBR3MGwtwmV-ehSZ4ttWCYWrBIOUSmvMuJro7B2BzPplh7ybWxTfJGsY5mHPZZANG4A4QeKwmsmcB3VUzzv56DkaVyoYwLD3T2axA3_YsezQNS51oTUhV4kCImsOqt4sCNtuGdnFHM6iP9a7C40YRZSYXbxbJqRY_-HcbapsjtWA24tyDOC3hys-ZVQMB24j-ePhwBe6bhpN-yxPCVAspAVOTw2SXQxjgAtLPpakFkAVV7CWmNJ8ssRsfNPTOF7gbi3hb13XwJhW3PKnPJXENM8bXCjdjhl1QYes13eiFnJ8j05PWvvQkvKpwAKfN33fsrSUkuB-fkJC-C3RqLS1rQd8vaF5W-qKwOeg1A6P9RSb4B4fDD_eY8uOJFI1BhHbtMjgB3L7hBAsdShaqpXAVSG9lOKESkXyVkwB7VAnJQ7MxLojLrJ0lMVCMdBMtnHu7EngCkfRd5RFcckaH9p-IRH4HSqGQ5kZlepN-9LMRYWWWINHGmjYzXrFQjHTqSVmP7q-neMjFlFAyFG94q9vfqEnPqSfzcKRpobKHveH-MQtX6zGoGp7f_ylFE0VCBm8adzCIZOrQCi1SPbykYShlC-4x9akbfSA4lcuo6tK83bUpvyQ-QDgZ4nzg_C9DbBvjSxoif_FwhD3_c-UiQfOho3xbXeMn7R2fbgPevNy6lR60x32cnEbVXLLc5H5MQkRfSXBmqyNDn2KvZBsvatIasaACL1s1OUJhIVTUbHpOe18lK-jAw744FhCb-SXhy0t5FnpHCn59F3uzrRZdpCtoh95vQGqJw9nkQId-R5sx54A8z4rC3wJwLzptW7-6rt2JDGmgpy5PAdey9xOcSMNh8B9zloVUqZSIiBu9ogPhACb02Wp0cLpClyVgG0v0LmuZVJ_BL94kz0FYkCN5TekH9bDNDU7vO4vqsBB40yQ7Z48GPFVnRuJNma8JqB0lOZexv5Ykb0yNmFOY-L2Y9O8XpmTAYlShM337Vt598shGOW3V_OtBFLJ7gjHwYiWgloZTzIlkMlysFY0PWt5ev9ZisTOxRB0xmMhVPS-vBSlL-oMf8gDaWek4rYuUimFul57nj_avhObdkGdwnayXaN9F4GSL4nvu6NUvALroituwmenEj04VYgM4j7YfW-Fynb6JA2s3t0oo4y000-QhugrDOR07Bc4g1cEyy6Z0TCdmDHCVKozxdy1VCJio2VwV8QxYITszldOU_zvJn76hpDTMbvA8Nn7HkkW3Je2jsv4t_zqezb2rbEh4r1ffHwfXaFBrxT2t-Nb0WYHgdWpqedy9RsarkMrKmtchY3jpaeDHFQl2TyJvQVQLNfRzGgg7SW1pnp0siCkiy1rWhMT9Ip5bdAuI0De9SO-o0WCCqlcH2r0UhLloJsvxAHngtxn4dkmXqcyhKLULHra0T_YU6CbiYus1H3QH9QdUgN4n7cwDau8VVWCivBhif5vzSSJ2zim-Lcmk0ZNERCku9Z-aV1sHad1uWfFGXiPkBe6Dwk6wRBmYZIZw6OnxIsMhKzRCGsxQggOjflVIjaLiuvUToNvnBdVq-B0lWtbduSiDKCuPMgUQoA65qia8HB1v4yPRkufm_7WkmAf1CNf2gvS2yLuDSbKukL_GUjNnhubryBFZX1ibBmTxTHNOFH3OwbkolUfTftXJh83Qiz1v-yt6QS5izbq3ytCTMerw_tc1E84fdaqsNjW7OQVtz1_7xBaOkDuF7fWhR2biKPLxwRLyloHaYHi4BbpskNUzBJIPh4UkAAi7AO6lW3tLj0pWyHvmPVqQjpP-4G18z_B8MpV4569ciq6YwUfJeq0-WkGxCq59Z62uvQJWoN0-nA3UJq_YuHw5YQruZs6K46U1Hj9wdFe2xQ4HvvJUW2WfjMBkZwdH-9ICkA5oW9iJhBJRqvnqb-5bOxFLclLl9VkkCt5gJRMlfzU_hijUtuRtNifSMtH20KyNRoAJhD2BeiBqAtgaHR0If0cGgXU072e1r7wu8pG8hg94c2leijBGMr47fa-W-rfedJr0EBSobW-fF5JmiP_JXD5Fozgc9u77nGsLz4VeBHfo7Vw9Fcs5OpALlzFL_Jo1edWjYEFfzXrhE8ELqSpB1rp_5ywEyaFm7JAc6_8Tu9V-14PYT8eYzTZlhs9fsALIj8Fm4vxPKm6QJ8uQ7T_KW5vITNm6IJq6w46hQOeurs2Dlsafk8Tcnw0k4ZTZFl-9yOKgdMTTkqWQ4H4mweBuitXpmhqeb7mhvEeuE6m5712VAUkLc8eYDBN30sFDQX2kT17eiXUl3ywPRU89Hh63PQG-KA1ZVsESpHZgRS3zx27ee_re6TCZdSMReKP1bkq9h8u2QZTG2P_Gg7jAbE4QhGJfE7ScAvsHy3Tw8I3yx16l4glEX4MWZRkP11yQXda35c8zzobRciLSDpOJDeDoxAo7K4PDnrsMCuhsSXwtdlotTCLyXfQdQSteQKZWLzkSIBIhlm4qSMb-gJZ2NC9jfADmLfPKt6fFHS5P4JnKwKv011f1a73C7NUGGXlgQogDPGtkWwfhA4GIlD-vD7KJ-0b-t7SFsFkNuoVnOzxqX6vlBS1nRjRPIgof11ge6ZunRZ7vZuVkqG9YqfJSVw-tm0XcPOMaTLFhAxkPEhk8zmCF8WVYJ6wfd8zSHav9xv6mVRz_IBQrP8FQVbmLBRpmNYR86oaPC9vx4TlL_9qTZKVastsEdy3748gngedTICf_T_SHe1ZZyGKpgQ-9W_WyeFRMyj7IUzw_kqmfs5XDeUIZ7cp_j-beMePMQEZyzDTOlPaWqUurZixGHq5CThl-CU3iOKlaKCxvZBafXppkSwwC8eVOHllQqcurJ-6o3l2i0jCAo6lCQujnw4C-jf9GM6eIpX9bZx74nbWQ_yzM3QDLbNMlYFOKcGeNDCcWFgt5oDSRdgKXkSldc6difHWfqW3fmsOv3JbyJQ8AtV9sLHOGA1A_FVL1ZBJKlalvmDVbqHhruwJ7RU3oxCUmmwLA0EhzWh6C5406EBSm3g66qruxT78gxWBAWAs3lMCVzkKAZ6VdqVBcUD9cIv2wSLaTHIxq3Xm3mMynbFL0lW9DKInQo5Ugf-zGe69GsilGPZKVxSoBO109Py87R9wY2PDXTIBoS9zYRDK59AaCsUchXhXaZwhoUqUcO7x4f_t2Me0pwd-dbtQMiLrMKZ_roekxTHkOMqQ1RxSlqjY_7vrDiztgPnw5Mw4y6AFp0TqtYYcrLPAe0QMywAbTFqt9cuqC8WOLza4bHfEJTSyarGEYgaiXnTt4rKj6U0bTrvsohZwZLscx3UXpXdN0j74FLZqG_7azgGUNeyLzTHBz8Ii6sb2kHoVPV-IcRJaT80QRZdQB6p_iorDrBfxltEdkbQl2GMbGdk3e5C1SPAHPslPhHYGQraeVoQP-7B0OyJelON4RIYU3rS9fqbZG2ImDd-6jWLY3Vd9kFqOjyndWq3O_6hwXsjsy-VKU-aMVf7s77FlmH_pnc07nNjwXf4K-wYjulCgpgRe0-FKXvlQoFYkjqnYd1lMrm3YePHfxVcZMschmaClnt--Mm27KNWxRMNcCykeJKrFhDRcFfhWuCJ3C2iOuDWIjiGwdTbGB2Lt_rh_VJZBMelEXePKHM9G9BQKcDZ6ZHASo34I70CekLNVM8AERwrxm3-DgLpg9sJVc_kTfOPuzsJhZk1hkXiwNjupsu65XP1MNtidqDaVX7-kwYvnAT5Yrj0j5nQeiODjwoOlAwhH80EHp59AG5sTMFsMmZl8s0HS9HcXMB2MbJZ01_lGBBH6Eewuf2AiIkorbEJbFh-ktli64sANI94ynO_MZ2GRJrLp0e7_XZg-PsnKGPJhdsJRN2CWCZSVwjngAP2-Zyu9oK8tNvSCBuLaZ-2n02Si-s-n85ewkx5n2N09wE_4wl68j7JXpaa1LjZBuK2qgxBZ7ypKep8GCghimtY1somLcPesi8ZlX4WFXSYT6AIFx7NXHzNUQB9Z2OhLkTchty2rSTeEkkLPk-FXEBhWWblBRkEzvbhnDK_HaFOOIRZLf5PP0EE7Ny_0oVdoGSX_p4gqa4_oYgsYVejKSCoDqyVXu0l0SLq7y-4eZ19wYCwIEBTbtHBhHN0K9XyCEhJM4XZeRhH0pejdpojfXgc6kyDfkCq-SPdKW2BcAiFsIOkqWRezp_5fi_ezBDeiCFXxf1QoD3r7wXapFWSdhChERukSJJFT0rG1lh7uBWXDO66Bvqks1OnCBzgwzyU-XHElYLeFcOH-ahIbxeNbAsE6es1Aef7kEWtCvhMM756CUU_5RqI6BrP016D0Q63XLqblCfIeNFOFyIqrVlvQXYBwUR1k9pkTcXoKUpuqbZVkIio2CKg9aQMB8s4rPvT_l1ZIPNgza9RsZmQhAAq03y6osVxNwE1NXVQCEKAQBCzDF6xsT7roR3oO6y_Mx-iicrwIMd2mH5wAIizjDso6ZwaroIsipqM_lX3ywif092jmvm3prvjankJSdjpnyTS6sCx7S1JyloOR63X0AIB07hVILEfTZB76dN9haEcjzvmMdQJhB9JPTFwTviA6tzD2IHyUfjqOj8qmfxDe9rfOiNcWoEkp0pYmXw41Dc8hVV9MzEgfAVJkQEIsqiKSZnew3fOERHwpne2JT1YN-w2Zn9_ZErAx0ASasO9XCiq2khC9abv0FZU2h6ZcHF4qey-ADIuoLlmZxjrh86L0Wo-5FK6K6Q1uMetOi35ve1BUGu6HTEM7doSUsJgJRfKymzewQrwEi7OVVZ7CwYKsPkwcqOt3xuU82jRHbS_7sUiPEdnQCXBMO-uU3MPUM6aYeAcZeXhy9TGpD_I8z8wOqqYBv18Kg7PJaHYzRZc4iW333XOa3PTffqW542Mlcm1veW0-85TKr6Rp9-MAXPiiPFmdilvJgqNaIVgzfqfFRZXRKJHxp_yivQ-fzmMUkR4LowyGBybB9WOpFdjNmpLr1ouZIR7eJbkRVeiTRDux1718jtyYegUt7fwMSx5ezdwB9leRy7hpBWNWg6xhtuzTupuqt_x9qihP3PfnNbXADk8p_tGMxIzMR1mtbZES1oT-J7gKnKSV_QbCMfgrxPFqcxtQtWAkeMVFS6xNW3ezFE7CLIhyVeeGLGVZMuJuorABAcFdR5pFhl06vkzajTqDOqgz_6H5oPhsT_YqO-MlaaVfOONLHVSIcOq6IzQ2AAkznqh8JRDjf56dh8Fgbna8Qhut_GjNGWii8nVkmveIzPDEgeLsT4PmsR5vLOdxSyZ2pHXe95pLPHNET4VqX-I7Gg3ky3ikOK0TgkpdmPTzKqRRoA1YwCak5Pe4NzIJu-wnFvP4DML_UIJF8h_cT3_NxXKxHkUTUO7csjPUHWpoCce6hjfjwXfDKHZbnOxhni9gZakjN7F2l5Hd1pAN7_ABxq-bOgMADZ2WH-MkpWhujYT_oYlT4h5zFiNjNUmKUGexfYzZyQ4ld985-WMHcZp8x9WY7teXAVjqtNp9xZQ0jxYW98Tsk1TvmM1YaKIHybcxwx3-i5tlL7gYz83PyQcoGbasJTzAdjzBx1XAD8ouXOIAw17004pV0lV7H5eQE5im3wqfeH35EymC8pBTdWjkNO6n7UOE5_SXmWhD0CnrRQqohYpUF5k1V6u0rAG6sUXUQuCTEzQ5-BbRJm0rO_GsB34eM1C2-siJ1Jcf_fLeLkZ_cyTcqeGx1NyZYvyoKAvlNpRrTakrL0kU-6nJZUfSW19BmDC5Y6bz0Mxcyv-A9FBOFk9kDzVfBYPmNt-9GawfkMAiJ-XZB_wVcVeNnR65Yy6f9JLdpoPcfhiUGg4se5AORemqsp3NxP-eUpQ4RPbdlvgk8mqIt8tHQtVaFvT8PJt5Ip_KGAQIrvZCTCk3nf22ZrSdcwQw6lKj7Pr_YylQE6HHNap9wCIl6dhNebFbFHXvNvBnd0akXz6YSc3F9uUECVfI0iQj9Zwf1PEH3h4ahai5C5KSvk9mafsQossWSAmaZX6eLhzZ8bYiKG4lSx8sW-_ZOGKyBrNXu3-7WuhAerUlCtKaI_Dxjg4R2vIeqnmVf7eoF3EJWpuBPM3R05KQxWuBPnHGknV_bpTkMB3ANLsiJsEnOR684UOQ0MND_ER9iS3MwG5vgyGNd_lkBi3AJeBvR0bBQVou-FbShq34dA7TN-iWBQpVPoQ7wP2MlCBRqxiBpE_doSZoMbDpdYtE__wfKs0VUZTmqyKNP3KJ9eIi4if_OxABFKxBuWUtxuUXYVROpuinnw6MlBvqUVRWXsBJ9UV9Tm6vzMXa2bP8YHCOWH8AnmzMHiaAeMPQsBDcTIZegaOtrrH24ybo0oPevFNe4fGs-QMZ5WjuhyJZOaSnVAv6B031_43u_mhgi4LsuyA5gdhSi6rGVY0bghl7Eo0OwKzuS3CW7X5v8dNaT3xFivEvBHjhzYS6wKWlYEzsnubzOSpYCajl7lO4qeAV-rJv5q3haUnvtbFdWIjGMxejIDpMqrqbxDakeDAlxaopTsbQ4VTVRLqatINc4AdkbIlNHWt10nuVfdf2kakVeB49EivcdJF21WcRzmOMXNEDU7mj4ckV6eX_pzlKGkn_QnYmLuHYeOVlTmXziB2RmwikSuDkYX1LSkYuqLd8BHF2PrAIp7PB55-GPGK642K4sieygVBAejj9dPgxUAUzA5In68BBGO0ZfDhRb56oEoUqt6SLRXjGrbDlINuFKQfKf1G6YQBpWaFH4rWfdmma4ptHTxF2rzR8opzQGefQRcXH5T0m2193WCBzakHx_yRo2xHzsTodVYDu4g-VY4vGlagRw7jsMGhNTT8w3cmEVEnoKalBxUvM4WikjQSgSSYU-YmC4UDhL8UxPglk-k4hTH9LKiFh98PE3paanR878LKSyVLWb1Lf0x_-u0G-RfFfstjnPjHU6PMUxfjlmhdkdB7CaSeRf5TmTT2XUvUOz_56ALGxdrmQS74hYqBsE_f8IHBQ5u8hb7jnVByDhG7IrTVBEZ_Hjh1PUehUbOP6xAmTw3KTIbngrJ-kQlfLG7BkdaN2gZpse6opHNSPV8mapkHZB5MFLaovJvDcNpIU2gbtOsvw6puWFy_oQB9khE8vEoeFWV-tpPRj8PS84cj1IGTptCi8aIMXiuvoRYfKf0XfJL7hrE87bFuKW0wFeydIH78cTeXHlHYC3Y7Qc62CZDNS_-M4-d-f6q8HtTTYUQylc3eMYL3Zdxrhws7MqSKmGW9dp8zmYIyZf1TafriIa2oNgj1SPCIbICKpRg2qlBNnCPDo6zSfFL2k8tACJDaXGXREWK8y6Nuir3sXOVwkiXHN359gyyubil3OoM3-CvJyrVBn5PGpIuv178Bjt9Eh3HkUgJtMLi8PjVzvD0mzRr0Vq9FK0Vy1_851PVX4z6zkq8GIBWBqP38LORxrNYJPMOLV9DNuU35-lvWQ8GOZ_nhv3ahtt0_p6lSyOdGV--KlPqJg36EksItFy8D8HgTfJqA9aXtcwom4RNXZ3a_P4bcEky2Ctz2MBPwTyP_ltwi0cETbej5rYfi7t3ImWHk84ZTtfalHaT4G7L7VnzneAUXlJHqtdmltrG8KcUseGNwXsMKKfFZ40NrHFJqY0LqvjEXp1K-hCwzUVg4PE52NLOai6xQX1__77YOfgzRSQWudPwCW4ElpAwhFfaY_OURyTHutoHux1vitOD62no0lhaN0NWjlTy09QOmmsJtBS84vwnfzT3UueiR0EKoc6Rn6ol8ldjjrL6Rk0TOyRxOz4TvZPcHKlpaI6tHLFVtSZXzt3OLcswKwk2OplNWprql3hAXkgGUCSGDce3hXJZ9xZPh4lajLcH49cuCYW3O8CywYjtPX1n7ELQaEpvruFjBN4q2SYrZLdem7j1hJ1gNv8xsniV0UfljxLHnG1RqUTEX3pKk1VjtcV9Nn3iZTQ5Rkbpel1Mc8H2w7pHEtpVapW8-o5_ugUJ544vP4hQy3laDVQI0F1bSNDayh1ceJhXpcI6uJRXTne0BHhvht5Ed85u00sTUg8jgPCCDCIhKvP-uRplvBxbRXoKNndEG3kY4ui6W3soC3mAvC_sDT-If73aviYuT_X8qdXIM7ynGScqKdCJEwnz2mJtpbjQ10kLnJbfhNcQg-MRq_x1ucTM_U49wf8eRo6YRSSSQBDENMCSlLC7ZT34S6H1noE-NhaNxL3RguXZO2qvK320XpBdZ3cUIZuJwCFFSwXC68J-OlPKysJvAKGThmRsTkpjjOxT9vSQ_sRXI2-ESVpn2bBn1E8xs2-v34Z-5a43mLcqS100zloXsoDDvXH7YbCY5WUCOXS1EROZM81KTyl_CtKrouRW_D5PJfcfQc58jFDuQ5fk-ISs6ONks4CCZKyD1dvVAu-m2sfCIU9wWw0dlDISkzLvr8JZtj60KDQFQH7CoGi6-q7tSLZY6NmH80VA_O4pXjk92jMjbzrR-B4i_n7LcS1Nmex2mXXGyajBnfUzJ7GRSjareUZB5KygFTvQ4hMwbd_YDbMcSaITsTwAWycfLxLhHBs1j15G7ZfQf0mg14rPC5DUCXDeuEe0H7ncDJy_6eZjmcwD_qnN-jdr9kNgT6vRnazzk1nbgnmp5_vkXTXFQtNy4xQdoo65I2hAC7QIWMgyroxJJGPutSWYyG3bP4RMxbPzc5m8AwyxFEPYyxwhIu2FS4TjyC7kM1mKzI58Tl16KS5A_qHcI8TJVGl84oaTMTCobapyV_TGV29QEnHg9PwbvQOjLo9xbuX6h8tVi5_SXUAv3L8T-T7mcLzbIozJ8aiPDcclIgwvjyFzQRCkcqNrLpE8AABFCoAM7J8QOaQ0hOq070FPhmb3lq7pmdTHe7j241QcJ4xpNwGNgpxDz5o-ji0U6Lj2bGwoXYy4lk8xvEaYfzj9PbI31TlbG0MAIaYENYKyAB0nj1T3AECXrK4Loy_UmlskEuKqq7x6nap2OcFiiaB8yPAk_lHk8BvEG1wUiR8vTfeni0pZvkUHdnnvQCY4gnDJdVIvG-pkH6H7twT3B3q3N4SRDugy5sDPMc9Dm7jWUzyF2Vi1PsnvvBQ8tUOaZGFC0ihxF79wNr1lOIm1HVnRokAPBYCv4CF8FGqoMVQ0LhPt7-rDM7BNPcfemFe8ehoi-Hvo5QsgnwJGUiEh7tnVLWDaaM3Uh41MliriS8ihyndN_dcC0u7jPj-LH6Z34uCSVf2kbqSRqNJGSiNngekyqoQeiK54adVfuolJhbkMYbzFH1AauzkwXGQRtdgASp763BPPgnMpBqqO2CBxepoZdIqjmITQ7rf-4hnOFe10mp8JoDmntoD7xr5VJ6WgH7T0CsxjCG4jkwPU7hiL3j8ngkkV5CeyyEKg9NHBF8Th2CvTjydUXuSLQRPztHuOFwqMf5_EWXYClLrYYkwaLacP2HT2Z7hjYlMITlI86hCgmRFdAQMPpzf3wDyAobqFXknQpUNjvBnCfh93JI-ayHQ6VJ_65m1PgQWDnTRKrAlYNSFgGdNt0IDrxxwTqGcHqtfZ7iQLL4C6rUkzjutuWCvKariW2B3twaPHOsgCDU_1nmA1WfVQ3gcKCVpbJu9wGCco4KT9SfSgp6z4AtW8oYFqrQ4fljllFK0Oghp5ocz5O9BmPUxOynn9HZ-bMVyXcll1KxDFaaiWPm0T79lauhY2FNC7Co-iQt20kqFJ_PMHyu6aNz_UQuIhb0A2wBpNErr4oMJqEFdG_8qCZ-exhwnrPgpoALENXegWPiHqzlMwZysc2mudQPMvzP6hp1jEL-AwWvGIoo6rVliPr3Qfg-IffRTEuxnVQazSpq5ECYGd4D4cVRGl93qUP9jboHReH_gjt3jwiWrGLTj-902My3-6RLHR4IqBJGeLQME60rJiz0PMKHv_jp41pnYKdh5ziIgCZ9RSorWxA1bi3QPdZNehMYzIX320USI-HSeNrVdja7-7XvLQ1nJCY7Hr6871hVgWCFm4Vi-z9tfhupumziOn5jog2nLzPA53m4ijDg1EvxRGNQBWttnuTO6jSufLgQ3Pd4_kjl27uMEcYqi3QCWQ0mkTalsmc4ZdyFENMNpViKaAZZDs4_dgH_DRUvR4GGP_vedn0bzF21AskS-1xWluhY-hDGJe59fs3V_XI7PoTyVWxl3CCwZjeBvTD4d9qTsrVpZFqTur-QySJheypY_OzumOK9sveodkbpYRdGz2j7ELp1m_lgbA38LTrf5rQdIHGCbCBAAlq5XjPxF3lV-q2wXaBw6zjLGBUF3Ze4yRFgTZbbXlfQOg1eMSaabGetBatK8-7or7VXMXMQmw3h1rLmZx7LybKHpHAmtTM4h08aymbIfF3oUN8bzdpXZ5kTvH6gT21ft9yPscNLmm5VszS9Mf-5yiMFM3Um46QwiigkpRrRf_FVbfjsX_y23DDfNQAOSJ2krd6R2QkA3N0XwCYp_efqXfHw2zFXJPl0rkuYkKDjGu-YBD7NmE0H8bm6_Oo--WZQpstqBYGPNyhOOendrknKduZGGfDLvhpO7322EtX3Ndm50vfcmujvXYpJnyx3IkytQVSYBEED9aXxfjGnmKPYO3t06Pdcy0ki2gQoTd7peqmoqdunIduoXZwwFY7gXqwKBfjGiRD66cDOMZbIO8g80XKIKy_oZKxr4eMuiTI1BwAzoERzvIyJ6h4g0zrny5BvIw9f8V0KqYEuF0bnf7M_mKj1FJcThibaANmhr2Ipb7VMY-KSQ_JPASdvm6nCbKXqFrgmUXCpq_CEtt5J6iXapII-947viGmSq0Av-V02Otr_ERkpAsZV8WIixNBFOvGqek54CK4cxxEudfgGruquoEtI1Tvrzfa30ltaIji1smKZ4FYgJ6ukFE6IOnNFekOvdxBGDEGDqonQ1Rh_xqnZXTZXVvqfwRK6EE0tfurNgy-0qWp4wJKLvE4o4cmgbqVD5FhUQ9jIBuVOonoNRvlAMla99gcafrTGFlyvb2kpdC0JU9Fw0kpiTi_SI5I07p7jfiEKuEKdKbQzCSjvOlycuox7d7wFhBoUqyqJNT9NXB6l9SIazJMc3oCRKTpTEQXMLl7L649PFr1So7vemOkuizUcSzUtct0APLq1498NBZhDaiFBYYX01jSceSFzxkaSgoxwaQcj3U6VaGBD6LN9GWg6Nkcj_4MV6VZCMQBVestQRl4PdkXuoc1rwCOQxHoS8-7l-HBdLgSz2ZZFVmLJptwhHV9-FcYjl6d3s4YCM0XgS6Whsc1o1m7dY0FCHteA2nqkDG2MYqNbdA3ePh7pFsZ99RmxBi-Ii0JXzDYJQ8hfsoxb6k7ZLBB9-2B8CgmGomGq_MFcH6iK4uWJDygxao0qy-LF92nI5tXdRw-h1vJKXUzKd_CL7a6H2TVcuLOs433e5vfN6QWZv_dPBaiIGX1JTOrdu_SFqSfUA5uEHCsHA8uavAyuqcUPZuHA2M7rktAglHiyEsaFWmt02PkCqy-Myau5NgaGYYnWjivfoxH7w8LN2D17jG_KhUeBKcX5hj8NhIcBS_BJLE_3SBuMj0KScKgFP_ZH08SpSAKrWzjQ_RkhCxBnigDxk2b3dOdN4cYE-QWOs7pk_FlUp-GV6AXc2ponv97sl5SP883buJyYPL9EQXq6O7R1A-uoU9P4x_J6Xv1NDLKYAThZRNttuCQfwc6qXJop1Xl7YLOEysDZajjkAgOvB1s-DOsg9XmwFswH9LpRq2eNhzM5BlKIJgVPAWXpeGsQR-20bEq_0tOaY0VymCfMch5ZTUu_kryZ33Jd5Zo8S5bG40Gt3mvYkmE9Op3F-bb9NLJpudJaXnChd_ALZ1ofFjOjJ94Q3rSwg8vxUZfSZZvkAZy8_hbtDh35ExZs-rs0nSBwS9AcHJxhylzIyYJs5A859rdqCmib3l3nFtpaJQ25wAKHCtpw5Qxp7VsPYw05a3qPrgBln3y4QxcWgUOzWRQWRC_P0bXrVIcHLGqnss2dR06Zp1hgsU8xOvwMOAUGtBkG4b7vQjBte7TjPl5etTvbc83A5Gcn8IBi-SBx7qyl6aslvTNQyXbEz-MSKITrnWIAfbOVkxa8t2diLP5-Cc_-gAhVz-xrb0vxg1w8dPgY4pmglyjhl02ItcROzmDKJb2bP1bKlZ_bY3VbbD80l_kNILlXvm1Hv4STnYnPJz9mW8Iv-i_v5uJ7RwNMYFnznNiEBee1CDxcuDET6_My2oREfDLqobEIOBl1qJ13wXgai3R85dTswmepCq5nKJmP6LfZYjmIGvI31riKTr_k6dvnZTFKt-3SPRG0A6DliTZJ9eimOs3qaYdnn4-2lOZKTfASzuU3xEKBnRl_435Y5aD9vPaNzH-FRg4d8NwQuoVLKfOW-JeT3fNmFpPiDObti-MnqIull0pjThMTaQJ2KjDj1AgAz_6V-7JKtvhFzwEk4QDFfSoRJs4fKCfKHJVhu6FqmbwGOluV7dDAACgtWQDg32cBXVt7RZ1mIbEPKfS6f5B-0oSuFsjtJLeRvE2KyT44CSF3gCkpXXP3kzHdivxMNTcBV-wJ9q-VeMpApTptjagRHJL__IBQOE2KrLAYj3Qu1lkMqK8FOvFt6q_4zKbQ2vLUUJdNsRfR06IDkoNi6NMCFqmX40injXt8ULHFcK1qRuv43M_CRdPCVwdTyX2vu3khNpBPAVeHtk6bDXoG_pMcqAkKO54XfrxUm5Vb82z39tEt1vT3Zn90rfMmk0Ek3qF2AwKTMD4G2Lq4MKEilsrDyrFqORM8kZgk2hFPyQr9xaXbJn6DPEgIEZSSPff3-1GrPOGgVSG1m1gTtCayaWjk_DRTxk4fCJunpd_2Kee6Xj5Xd9wUL8JNgZondrScdxyz87EvQdXACRI64Wc0Qoent38dj68CsiT7SFYa3q7oputVKxZsRZ6O21WeYg1IJjmyD5NKigGuVmSXuwwjsozjZua2aBh-Dsd_lIEy4rTPbFcEhD5OAY36Ke-Xgpc6xnD4xnYq1tAyXuSfvvQ3WQI4gKrtP5ozas5Pp04MdQn7O5hV62WiPgae4EP_qRVHc9JENky32elcoU-w0czMVJXLxd3u1yFeUNwVzcjqgETxPJRHw6fhGSTOtHhbbe4KbAJSQSy-t0QgRET42f8L1rYlXtRq2aBCyS8kNXsiTddWFHK2bQd4peJCHnjAWbWOhio_CZ9J9BdbghukoCeyg9ySH0JyA4875-07ljnAupN8mFkymwLHz4Mjx6GU_mCSFdOfnhvNnEUnZSQQEFwzkTlwddqXDt_DlpEscOQ9buEtnlN68ac5ldOEReWyrfr6bWJiNn0SiRS8jgMMaPtG_LJd_pU3aP-_BdigBAicHiN1v3lpzw7KQCMZxKovkBDtsDzY5K3Hfp3O-zP7XnHI6KsnAqgi1JYAobr1SLFZRkH5hq1I1K1WgB7sAJKEiKsI_SixP8qAI0HJosgkngd9qBI6CSWCn8PJasWdjhmt2QnLsc2Me1-_q3KFThhiKLn7MxY1k3w40YB1ydLnXNK3FOBkyXVbOvzbVfmHspFrjK4BhKTWDh2cAgapG_uYSJQCjDFVZTzF05ihe4BHi0Tq3nyXGIAnuxdYDT8qofH9gCOy1JeBBvV1irqLYUdn9lXlD5I9lfMUepQVSS3M77e8b_XU1SJvpe8lUShbVlDn4f_E4hzTTF0WtPT9xucymr2S35bb_1yffgrfgTn7AF7BCNVxGGAiJ9sOTw-4WmjIBcHQZ3S5dsdf3_FxAKOcUiqu8S5f_VHp9FVfbJtu2bYVm7r2a0A7il4_3uFmniZCNbhZVuYg2Vl75bMKaQTnEEF-UT3xG7tOurNQa-BN3mxMzZdP2yC51qfAVkPajnernYDCE4eh7RKOqOqrzFurkgUDDcQqKEwIAwtPwfopT29cxkaemK3GVV2QnzxiCzk4ATTEunG6efw1sF3z9e8sZ6p-YTdCqtFfGpYWolaPUrbitsD-9l5x-z2ASHLgTGwwd467c6rQ0vU9v6xstYRwD66N1azQZ45kQ_N5HCVBOriM2qtBrqE9lrpBLP8Xcku5Mg1hbgc872k4JyHBV6AWl16JERitQdyJUa1-_y30pyYeG_s0XgJuJ3yc7tuAaU-Itw5IQydVwfB8GcNNaV9x4NIPnKHcvjBQAqDu5fpBJnKv-Z2XKV6ZhCOUUmAMVOyw3fCD3epDhFJReZXO5t8Pf9kZ4qXjJ3QGwd0aANpMJsyS9ONgRQ5u3MOuWMF-ekRtsgoTjd7G72m5S2yVe4EAgAMW_VsNza05Dmg4aR-8OKbinR9uQobD2cVy1xvWjyxpqNssounFKFhh3tr_9mRDmKqGrAsZfgVRhNuhoY54oshtdnN_3EU8g7gbY_Dgq0XMJmTXkvfDUvINvqInE12Ew6zOP54iKjO6HWskvpRTvVaO60GcVgv6DX2124GFv8cODYmj6MumqG3GtxR5_H7cerEdlpEU6m8taDEM8obBBB1gF3XwZxjkY9mykOkZh4k70Lw07Nk6emVljUW8N5GsK4It0bg_6lgLqf9zMHAhTXN1-toVYzECsu2-2WMP5v0twWkpbA-6UUeAksUsbHN0flMo79cyUUfQ6d-YD9V_awOk34N4zwtwYjohK2z3pTxVLWvdDqAd29xt89RvClHdtTYXHgnt4mM9JdfgfVSFX9Zy85wNsVWGcVyK5Lkk4706AebQvw3E4Pv1DuGBX86M_Blu1iooPoaIJ2o72FfgCq01hchKvRtfdpAy6Te0tQw73CF3Rq98_QBmA_Yt1YhnvyuaaylC_aJsExJ64OGEkgDFDgrmmy97TQS3I8d66KDboqsFjLpxg9S8jfi7ezRtfPHWuC9EVXi9Y8Z8cfaVI-WtXAN8R1B6DyhhMXA4DJHyf7lMe3Y5rrlojK1BYmxK76IYOE1Ky3M5w3dUmQCR9hm7ABNCbG079YkVuJJDfjgxGIGjH9jM8BU3DrU7P9mmdN5-ZmAtKHe8LxPXtJp36dw1QrLqO2RpiGF85TmLfdYViEVJ812KBYslYKf8RWDMDvY52lGqPcP4Ej8HF09JCeal40o69b_p3ydCOhKxjVqfRJPXf6klAOhnN7wP4Jd2J1lvJAy1cTdrqOLgsl8EefaJiALac_UNd8hpIbkzSbU3kNdShjfJ6PTkh1uoraPapDAJekAjEyaKSoWpNt2af2739iAdysRj0X2ZZUEbsDqOudycBbO0wnPK8xZT25H7pBMg0dhZveArqOcVQGjXJCiR6pM_rX2bAK55YSqC612owHOKHKhyZi3lo3HMIe-ayZjXVyyVlX5PbzYYQZ72aWaOJaPUpqFQKXfDnV3tiMlVTlH3SdasNv4paO7iMQd3hCf3oSWN-vlyfJ2_-ebOLfBBaQRHuq1CfrOU0chePF4xappF8D5gupBeO6UlS_L0NyYhlfBjVFWqyogEgAWP9Urg-Ps5N4rcaiZdc93LLeOlP18nSlvgrhq9Vf7VK2z3sLdNOG9CpC4DSPSRUMBcPrUuf-xJDMCtvhkHKRMK8r1P7A5VXDnju8MbEgS9jwVv9Ayon0I3CqFzAdPdQpZ2O7dIacz2ureGWGrLRiPM9uuuWBVkDGNI4p7OeyICdcKVuGJ0Vb-5dpOEyKU32SSNSW8QSVcW-UzNetuYoUf__hfjmA6X1Zif9adnkl2FaXcVTngYG0kkiNyFtbjfoU1-B89yDLdsiAGh6H9upAwA7zXNRxVZZ_uYtFhPKrsyvze7Uk4Z_p1qEzyb-sY6mHGJWtlBMa2zh2e3N8mrVxdSUVQP8hZXwksiZEY0eT4QaNPYeVR6LjZWHztejQp3kF2h2KUOmzkSv__Wv459fEZV_IBVDcHDqY9Sn_L8Kh0pf9FNGQGMMLjQ_fp8mUq8voLUvrknW-4P-VL28czTIVzLjLupKichCeFQ9j_Km_nuldic18wz7n5xyFy0mxCap2fM2E8PjTQ-BIWXHR7XRy4OS9MIkByKYmJNMVWSbf57CFWRQ_Da844UP_UsrwNSAMYZmW6F-WkC4dRp1mNurlnubCHsSxMQUIJWepSjOeLj2gFKCBwaV0VtPtdToDkUlhWdnGWIo63LyD-5PpRdfDQnzSOFx2DZ5CrUfaE4bg_hd2D06o1IvR15XsGW2Ggjk4evEoPZFdbMRpNwEX1-qhjilt6OOGrlkwhvC-hGtI6HuavTND2cxj4zXOZIkf4-0Dzyzu6MkDuKYjq0_aCEZ2JcI0HWtBu34wVjz1uJoGGn4etcdd6VJMRhDx1lXYLzSA8kEYRIyGc7LbtdHfqAKk6zMNGlaBbMsDbSFWCz1SKBaRya1ZN9mbonOpAFZFQ4pmnFt5iEPlhiGnMRGnJamijsGI_GJZs7Snn_UrP7Ad5J0Vnx8MCZjTL6pBWcDCUvSrWaYIIqzIfPSeJL6bMJI7laC1ZuzrHDQQWCbPJOaUuDhjJsrOrzC7ugwNHlJOA7EgOto0dHqnpv2X26rKYPOrL4FTvFv8hgYM2zZg9SHy0kX-NdadBRC-MiD9okFxv5ai_iIOiFCsKb0Z9oKvdIDmmj99v6Hwa4tInvFJ_FQfkwL4LfYayDWFkgF8OciWwLDbNXqFIeDCNODEvrZ8EypmpgjlBjMWAZ9g8FSfAGJlWndl2iSYm3Cl4o468y0ieuwZWqOI_yZUWAc_MNbqfN5ywO5VuoT69wQbzBi1OCaqKsXIUJLOOm6hmocBRMH1nlUSnx58CRveUp0FUiPRmXZQz0lfkBDvhbtyNKpLb_yrTKRU0jyUQ== \ No newline at end of file diff --git a/backups/backup_v2docker_20250610_015849_encrypted.sql.gz.enc b/backups/backup_v2docker_20250610_015849_encrypted.sql.gz.enc deleted file mode 100644 index 2e803b1..0000000 --- a/backups/backup_v2docker_20250610_015849_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoR3U58guaWR_rHciy31BK_ecTnBwVCTlHC8ISVUPXxsPqSygUhHCyZn8X40Ymfjl1e8nL0upjqgLGjIiZiMmIvJji_mJ32eKCMJJnRXG4mzMVoYHZUaCtk1vOjWjopShxmeF1k5AwArzaGduTX1b2owZ-fOYS-xF5rQyTI3Y2oBRZYOA4RPPfwtm7e6up_ouSFFcDfBSFS264u0WE7Q-45KnnP7BO7yTwwe9uelTV6dzzyxJwIWEihv9IMUJGtNn3OLWA3IOrq6HAltR2EI-07_477021v2_QKQG_7e_PLcW22299F_211Wx6n-lu9Y3c4huSRV-yOIsra_abqwJwlw9SnkuIFTWpFQmm40-bQD3OetrRBXGnNa0gmk-rTSLgqlt94tG447G0BlpqtybZOAsWjtDFZLAj906EmD9i693BEI9HWCIHV_5GZ6gjUPEeoZZtC6xSY-HJBAwhhSuXxWrzv80BnHDQtoOz1iuM7GO41OBQbIY2vnujINB0O0e__MoC2ERLIFulaiiRWsJrU3Ug8F6l-mPTtxckLIaTlwdXfBwohvciaslgDL97bIsg1Sd20OPTSrCaCzVmiCtmvbjINWdmAmA0HERSm5K2PYTy8_rT8-cXNTzMxMy7SxSbuCJLp-S6bNPaV9UDF2TC2zYli_3pAfyVh1n2WoDoT4V_qulF4Xe0VtIbIKmnQZoHMMhiaJyMJOVn4B1XHU5LhzRncecQpjGtFXMd0Efjd_BypXlHRSJkH_7RaNVLpMVGiLMGzi9oLakeVnNVEbyUZHNwm0XJoaPydp0RBnpKXjiMhGr3NMJrewpAP6L15dgFmp8Vt2nGdnAjgdyg1btmMXKVFT4f-EzxBxV0RSPuhqT2_R6dQ9L__35VaQMsQRCJdpMbinYMVQFcTYmD7AgZgHwUVM1HfKQ6F_fLK69odQcv_C-vyTBNQ_NahB7Kg8HukIXPWzW9OHS5B_eTD2Zx_TRBs3lxza_y0vIPmHjSIJQrGNmPLXjgicKtvGiVmll9uF83tnnpomw0s0niUGVgU8izXg7fet2R52KV4xL5G69tYwX30ipfHrrQMomkqVTx_1bomus90FVqz7eNqeSjantHXLqL97DNbTwSpXMy1LFXz_bC669dVv2g_eVgAfOAr7LXdkw5h618CPKpyP13TUy1L-Z66gWL_pNvWOv61P0pPle9rtGkd5ZrRh1qkZRA0UH8UYeJAWgvdY0mQ8NTYnxmmnFYkKraXlfRSrG_hqpP1Y1nQ7meHuzjaTxIT6PAH0cwjWJEN--GXDpIoV_eP7p-g-lsYbUz_t40Vb6wnLcLSrWTpiON3j-pJk7MkOed5T74fEJ1KtPBEppbd1IJNOE9ey2tdIQM30o3pLyk2QL6g-5-Bk1s9f2slgWDFWEzf7MPBpc2HeR3g3V-6a46YUEN3rHn8uivg6LjqyEuLn2iesdbRjhHte7qVDO1gnJEgoCZkCERPDyIUkXussl8_Raa1GOeeg5vmgodQeID1d4I3-kYHxwfq1pbJTRc12GOktSlU45rblpumCMZb36oaCOUGVPSvofQQ3sLLdSS7WUJdSc8HiUGI_Q8KXln7h8U294_C94NYRSAPuLVozIor_3odgu4r5W6A8p4O_FHCdwV5XamNpM3s6NvRWIrG3k19OpbFhdh4cEC8iisO90XeiFncHxvixIqsCH0voB8eJxLMTOR0bv1TS_VEsPjZ-oVtVtMw7sSorea-P7KPsAaWm8sTR7A37Qm10m_lwdR7qg6zyl8YRfTQPEq9kjC1rG8BdcxGx3K8eEQSVD8UrXpyqDUdDG-YTFqs9390_2aGi1AyCZ_YNnBPLtZvbgqqYzuU0vx7JQvOjqq54IeMdVhoF5PRHyYM1bHc3x7Otowe0QkO_zyygb-1Re_0ETWhVKNidDyZbbCUfC79pEdTTTFJqznAzqc-2WJ-K7FHmjrvC6ODPGXB-IZ7eccFbJV8tF1bCAAOZfU_7pVIPJ1Q-RcocjKGak15j0lQ9EzhuFLurGXHM4Fc-vPXIbdyFpx7WrOttfWOgy4v932qW4zL6NEzL8myejHpNlN5YeMU46d_XinrZlwbyVBITbKJQc4_cd00jEiOUYUaYbe4W8zYR8SOz0VePmoPUtWGj-xinnZilkfaBX636SuDiZiXztPwIR30XYbWO9mr2huJF8SRbWl_tPqmzBUFMceJdqAhUAttxPCWQlzluR_uE2Z-b3lkghH3j8EMh7Tzw6DpuvgOTBk3JO98sAo5vmh_DNw_7cn03k1wMTrUTU_evFDXJrLa9q9X2y85X6fOegETc039Z3BWeYzvBpNfgVe0QS94z9yUxlGdXrJ3ENtyLvP8HEp8-6Gbv573Zg0TS9C4WBioIXC8l0yBU7M70YIg6Fmtt8qVkGehrGoaAdKW5tsxxZDYcQguBK_EcjzHoZn23Yu2I8iD-l-4MHNPY_Q2w-TnpsHbqCvWaReXEf7I2yE0qt_HlGfkDE-Vv1HidISVTjrekrOV4hrUlxmYiVTbHBorNWQjLVR_dVwprSGmKceQNISs5NwU8ZwrF7_MrQlZaPChKHaM45s5Mlh_Zh4pfYIOg8H5_RSeVA3vXO02s17ebZaZOmAkdVEZvBGwgXxp4maENRrgp1P4WtFC_HagjYxxkb027rl8NEiZK9GJjkYZfeslh9OS-oBZ0WKsr5petl2N1lmc4oUrmvq3rdni3T-51-eqA2n4tNOu56ccBs2_wHOV1TxQQ4K2JkZX2S_0BT0mAd7XUhXKXD9uz_i-XcNIyXAxIWFxNqQSvReM_vU9Wk6WbRz1NYLENb9JHsHn2j7zVyoyfVUUaNWFFwOOKOWpr3mfagVNObu7KIdfSqeLJ5P3ym3fXjY_Ko4nXZthMYP1JX40P-ptcUuM-YiByFHnP_cqBDZudH35CbCGuCV8ww8Oku6iSxoj1HmZNjTzCG_hp9zT1oTOldlqSES8mk6eI9ZFSoprjuouD7sYUw4SdBsvn-jWDwQxJWuK-7BGuPAUfEf_XhaP9Ua7hlOAYqoSD3Mi6q755GqhL0zuZyNyip7m3LUR1Vfq85VbrXqVN9FBycPlb4IXKdxY_v-uj27OfsDnoNknHzsdLRpX-uIz3f4QtnsSKxc09hpgGwDeL1xTCf-n46kWWlHbP-kC1hH4LnFNQanpZuFZbe54uaEYqzoT1jLUQpjDJXZvEjDBd9KeYscWdFQ8W4nx_--znP6UWCL4esKhNRIHv2yaxJ6rzJFYRVwHOsc0ZagsWcNNEatjeoK5956qE8ahVmIdRurX3f1ggQmRB0nCEts2OKziX3rTv__sOSo8QAsiuW12OwQD_NNJUfd_bghJ-Uh0coOJ98RczEt_fPPdkmYh01I8f5bc-L3xUC1FJsW6UwvE-XvxzZTMgp5jmu-2PBOzUt5rwkrueAzN2vJBqS-Uk3_3YE9zBFGLNNk_xPZrHX06NmFNNUGAcbkDw8wf4LjebTyw7ZOwaRqUenOmTwdQS2Z_PrfUI0HCoPPjgp8O8hZQGEkamgD6IS_x--CvA0vuN2xc_A_1B7nWZP_MrJcQGb5CLpWDHWpM1pMbCkwbGTDn_ZZAV2D7CKJzpdPywUkjmUPwIbIvANDaZtMjFPlyFHBkIe6_6d4qZHiMNDy8rsKy32vzngzqGHSQ4ktKgQX7sro6FLyc3mCyE_Bw0j0K0wJpU-uYRZg6lpJDmzY1EJJOa2SIW23fAXpE4SrvcExFs_BopLvwQiKZel6Q3PMLNt_X3u9IjAZNrKSPQCv0NhJrzjVZ5mupV49aue2z9PFd3VOHjW8eUqxhBsKlygmFOQdYUJuxiN6WKJSQ6hv8_Hl4mj34ia1Lh4toepLZBhsBLi_3kWiHuJdw_0gUTyn_XtxObd_oI2HeS2YKHdIYY3qFb86Xcg6OFbxodYHkcHVACef-bQTLz8M4IZdx78p08QwJC_P6SpNusx97DZwslFKD7NSIT8o-QQJexJo7T0amlt55mbv2O3OhRG3lAcgOTZ84o_yfyHVvJGLcqZGmXkVEmhEebfKgdYDj0J5LRGzgTaFzpLzq4cGflIWqdQ8TBjw7kHDz1pXnbd8rouPMxv7JawHDIt2dh4NZ5QkqhI_TJcYJKQS-0rV2EYQvcMhZV8_Kor_HGBoLvRh9L1b741G_lPefrBNdd3ro7iwGVLZrVQ67bwAcV76hlsam-x0No8kEKBwDftwCk10MCUCHDBA9C5IqWVNMRwgMZWOhIPH6RiXbRVYgWeOBn-vCkevyFAQVxBKh0sHYJv027U_gAjcrqDqRH5YFwehauAStxyHvD6W2WC2m7DbXEQbCq2feP188Rmi23wxizOIJFsCWp7HzGnQyfOyieCRHv8GQg9mPoM_YRAaxupRPUsLxXgARGrkpNu8zyN71wSc835S3S4hcSzdkv2p1JbR9aM6B01Yto9STL9enm3zn2CU6FVbVhZlII3djp8vasL-TnPxSwYBZUwX0CGfGhcxvPwrF0MbWUJhk-ditkpGFIzZ0lf8zoq7puxtYfMsiq3fQPBGuWEIIQwEd1AyctUyaRNqEb7zvCtPluTq_AHhDzSfqB6m-F53wDx42ptSKr1r4DbwN22bgo65ReZjHPRapAeBgD8xsRRY9O17P8UE1azrJK8kAC4OK9IEVD2Qhny1Ierm3rVUkxvl09XGlSuZl9pJzThor9uigOfGpqhKdQfgp6Jaq06Y3F4VP_AP-hoT7zmz67xy8FZhwAElZLFV_iCMQy72HvRlF11V6DY3SbAopDUBC7YMYLSfqchowffaA8IvnexwBP1vp8JgCON7oLYSBKEU99_IxFzFsGpxM6fTs08OTTylEU7TkhfI1GzKuURk78sARLk9FPBYGBfEXi1S4cO6ZLKrsivcq4ZmUS1p-eClnAlp50SJ4lSEuCs3jZdzYa-ns6npxbmGDXrGR7IBiTvhfzOzoyomX2t9hb06cQN6DHTLTkSVH7iy_bnJxCjH0xxoWK7gnvJY_tBO54anj0Z4YjrBoPBIte9nrxpbYYXTp3BqRYkl9jV24nTi_IKl0NaOqht-UGnzxIa3fGgt5uO59IpPqGeIbmhfYrNlnPJMqCPOZPMtRw-notFiyAngqzriAE9xjlvTvQEB1JSxycnhC2iFs3Kj0LweECl5RZHHvwBiDt-DCHZ_8EjCmZwi_wekwHzIgJH_jWzfc1XNQ7WcmDYtiiXqc8mJm-76lcfGlhGcinTvfSRfUYMpPnEGsgYLiulUSb8OtaDs6SrEoA24r2ydwuFy-gPP2ZQOLhtGW7ERgK-7WeGFPS5djFrLMu5kbhIFdCTnXNUQp5tiH3e-F50kxXf6UiNO94wI33xzv_KW3OyMAs4GP-LEVBV9XLD89OKHqKp-cuNvaEOrOtdNtVMsnhzxbRNgmWKUkb6sR0Ar9QPHHtc3AMmXjY9c3re5bzwwXSNppivNnafz-kpm3_F_ZVMCgWrU_rzRjwBtE53y_9YB8Bn2uBFVVopHCIysMvNlf-uzbbpx_pfbK7_41ibmS9gb0gEJy5umWcnPemO56AtrIg1n6ukA1a34gjDDHQCaCRu9tmPjStouJZKwDe41pPLm8-WuyGAiQptDrmLrWuz-cuEZ6l5WOQYQLqCzrd7AIjkNxAAILOMTiT4WRudWnALeEnSukIwa10gSVSSFrSuqzyXZLDMtf1jHsBJJuiloX7UR3MWvOJdii5yK4XORZXK3ZXSiJHKZHlY0cIaG6lxtWbKOUJbCrFeSY43AaeDEXsRpMqSSaCOY8GP7ugunW8JvEcJXffLxoglQNh0KbbydnNrrWR9mtZPtnaCPy-jFyk7OojD1dvkX_ybVfxRNEs45ZQQXlDmkmNWC1gxcZ-6qJ7Jsk35XlF-BtW0qJIARJJykWU2SNni4QsI68gmYNu0Ht90qagYqKnHiJ3ZW-JYkGwD9inmJBhG9_zK9ha5Vsu7UH69tgKC7z2CFutgtprzabSLsEYxRcpiTP_nH47J6GHzbrwYayVjl-_WRGNO06Iwt9-CpZB0V1yRorSXXePRlUWjElWBg7un6Swg8Pg2veS4doOhH-Q5axKZw7SDw4n1BHa6yUveOcukZhchqI77BfCEbTnJUFtYnAQma38REnILEHgzVP1gzx7f9z_ara11X6sWi5x5OH9WN4POtIdmeMsa_A3ypOsWqkNARaDICtNMycEZP5JOVEoDaZgO_m_S9CeLKnkdCw37arvVCwvLlNTycfwOEiIDVhKsBmKBqFd_MnY-UmOxmWdOsYYiQcb4TEnUy-L_N4jWFioDI3d598y3_OAxhLHnbgsG_jdfekjAC7aroFWqtBxCDdIAsqUHwvAvi01mKhIYw135qOt0lIvTVIjQC00SzNDNQGlxb2FiP7xVuePj9fAgVQbAAagaTPHr-rmDAi9DdM8usrIg6lZSEFLHswPdqP9r4JPmv8CUnlbdg1dN_uebhPqGQVPcOk71aUisZI3WkM0OhIYDN73XNmRqQT7b3QcJEopciF_62ZHMZJfI7uoTtmlaMk1QRlvCvHUUb_C3GwtbhUEGES5KCMikkqM45PmHWci8jFFHFI4MMwmiwOEL_ds73MgbQdKkUX2Y-PV4dSwwl3el8BABaqa-HKrWKfazsyEQzXQy3RwX7BSbbd3J4VWaX8DLmSjHpv-kxXtbIpFT0YFtwc2UXY395nivXBpatX5-JesKcM6qUlgR-nUkYgqAR3-dnAGbDEvAVXNq0VlS9cyhnUEs1NIduPo8yCJ3tKU1auAI44jowCwKPnKRhJxKEAxheIsbyhpH87-P1NXo81m9SInddkLyAhFv2cK6yNzX90Qj9qdpDw2Qx5sBjz0FTO9yjrHa_QJG1ysyRbVFuk9qH9jihDcOby0ydBQLF1tJt1gej-e5uTCHLdPzCWK4vWiEg1tV-MutNIwq71VJALoqNCERzj-9pft0MbYdsbLHwas5nwaFnOLfl3buD7vGAjbvsvGcNw4uaR7pZPdbqk7eNq57GkKBUjONgWx-cJLiVR1BnPuWtGrlZ5OLVHLNk5Ox4Wb2Z90EWpKw_QkxEDt7qDn-KARZ9gds1mRg4ec-m7nkAyApJ3J9GFbX7Ve8WeB9NPkWceDFFVJoaHELxewYKFVtn44WBnrC5aQVT7oUT4N8i7YX8wRF23sp1eh2_HkdXbp-lxPGahezAn8EUD-EvralQaD7xHTkIae3tWDBPBytQbX3LKOtDw0G3KhRZs_49Uqf8zU-MnVctk0Z_IriKujFa97cIkj44bHNoBgvxa2RMjIsiHKeK4wEDzdkqyhgfhP87FgsnD0TV43ghKgQQDK5dEfD821i35J6Y7udDR0sDsrgMyFbue2gCpwTbPpUl1Vu-wFSGRf25xzCcn04TrOvhZUSXxH3zdvsORxI7nJBR_wDh1IZ5fN90KRU0sqoe99z1ZwGddfvUX-cqMqVkGd_q6n0-LK02LN_r3LGV7WhS6DA79PUKSqSgjUYMI9G6LI6BZNLs0b0o7LA-SbCjYQxdhZmo-WUta4pQRa4aHA-mevvurur-cpZvFEZ2FfSwO7SVAcCxmVlamGUagRz3_7f9jytaCTL4jrq5kvbK8i1hIuVLSNQZjjrCugh9DxqRD8wUM1i9XqDDlpeip0QCaIkL7dFUmNvreuej0wp5ZCdzOMpyRWtc7eiZZhMBcxBtCb2otBdy740_43I5Q6Im118922SpzD7iZKdMBI2Bu6W6u_fmNnrOtxbWClpbw-4wxzj65hws_5h8QJ2rTKH6evHf8xRTJf4tWhHqhGPpkj_Z3nRIqjN48dLfLXnzuKxWQE_rY8cp-5B7qs2U6BEWQyIFqgtYzxCl45zRriTtXfuvuxqjl0Yw_Xm_wsrjFDN1M_pio8mrDPF5HgsjLHo9_Z663b5-2HKum2uH6TbyCzgMLBqdma1cTSqPGo-00I5F_BpGZeBhoO6f4o_BjR4VSmow1e_IX5VyxpSgUlCAB6JzGDx_GyYGGC_OYEjXTm_ssvjCbmTEs3psF7mKGkktlw64mw7dN8-XH8Z3dJUsEcc0lKWO0XvPOaFbK0WlDpgsLm5iCQRArTSVWwsvaKsIxxYS_QtgjBqbO3QQhzDXGZIgdn5nGtW24sLGMVEeagohWWBGDASF0RrunEbRFbVVf-evo9ga8riVQQx8d80U8etzCQmVdZYUNYrkQKPcHUz1gANX76Okq0CHc9oXSlQSKSiOxrFyjq0yE7TK9cTyZenQr56GcykVL_ievHoRmQfs43dmR4C2U5CAHp12HgwOG67JEKLFx2o5UBe_XPbsavbNEmqFrVQ6TQ5GScEobZG2op9bBZO7fdQr2SXkmQ2zcEFs_9P--PPPpovEJ1OfQ2ktRBF25mlQYWd3NqyBJehvGfSvvFHCcDIXdRJwf0VTa9mtz7Q_-6nIxdh2NuzukxhKz59viS74Id0O4CEwgofcwnuZ3wnbtdeWZhTf-8yS3iYxCKTIZQ7sQsaQtM-Yspwm0XVbiI9QUEvQy2BGwubn-qjvK8l3hhXT1-wwtr8R4Y4gC7G1xFSn4Hzh_PxRxAY3rE0y_iImCyaBcAiiXrlYzsow5oPrjIm0NX7JWGFPMDSQ0x0SSXlpiJEDrB8r71oGxWw2undVCVUwpwnkheodgt9NT46WESprE0ZUpPt32kCwjZ0UCdFWZ6x75LOBMzSmHRvPAAx9l5sDVOjUIq8ppxyeBkvdDc8cXajHsg1dlXLgT3UlxsXnCyJyWjdFiRLqqV5AdFgAq3tCE8Yan0lAYcBFYo8aekn-D5zUi00UByigS1zrgB7F4J5uPIzOPflxzm14v9uSwQJf-uuZTPIxp7_jDcVgyKsIi2CUKjdYBQbC_xL75piMr3odjPweJS5TdI1Jm1OuZMOkvmfS8j28Srf7AsV7jQq31cn8-gM6yBVszqz3w4LDchqnMgaC1wyo8a0QacMLqF-W3wYpUId7b7PbP86ajkquEnmy1hkQD-TSRo7N3TVV2JtqGvFjcfHkTr_W7Pu9RR3KkEcTfRXUGzTZ-QpdfXngPHc8QdaKQrC-YjAyNN-S8Ugn3OxZq_2TzP5SiVl9GYRU5MCeQfjqkRaWdt4vUggdJlNKj9coJnOvf_H6hrYNwRtPQlCINUz5oJ6inVj8QHpBgaurAh9eig8Nauk8AHi3oyfMML9rvPRR3nzzFhL5QFsDQTYXqv3BJGYWcPnuxQylN5dVPzUM1NAXiw-YjVN9nWm7ac0RZaTKnMUQ6q5cptpTUYYPBKRtOHL9PtRDnPCXCJDbn2y4hKqeCndRLlZb_GadMEt_8Toh2wrCmQVfRdGbfl6HC-gS2ZmygrOScLqtppXiAmAK8xL2oe-d45w3_Nsd8cisYKiAE12bv2pc2cmGZycLwU0vXseEbm4u2bAGqAcrnp8TrzdnprjqkbxzqJ0AaEMh9_euzyaHYUXzu1VNCDrLI4B8XPwB97g7ip-xzmnjjmioB_7sEYYDkCKU0kP9dgnC2-2ZKPGQCPN1ZVHJOBOXKDDWBgyY9dKOY33my2j3PxIz3LVHmL9ErvjfgR8o1KZcs5Yf3YTwj99ofxBEoSEuUS43vcyLZnBsfgeUU8nrJTHnFBDO81jJQ0GxDXhpfvS8cMJqMzRrCkiYYQCXjlUDcfHpT9g-OkXZXBhXjaKZ3KdkgshPhVNRCxGGlFlYQcPc3jdqZzOxW8dbBCmGy_R4ZSvKE1mFXX_uGvHbLU5OF19SuYkhDeLH6CTtAKwVbTFISu8lb61aBsJrkPJwCe8_xeyPH0KBJD8o3vZJ-nWNjx3mheg_I_bywO_QgWa910c9rzoMSI1dqIIZc-KWndud9C_fxl-z5XwfZJX84EyHI1GfahufqkCzlmwZ17zfC_KmHPCrvZ40Xg8T2ke77hsG3f-p7VJDobQR8VB-xfocbAhlLDjc8bmU08eI9_3Qw8wBxK3SCUJyjILcYuLmIANr5IiamxxcLS83rJf5kT32J8qB9dtbZlkGrkitV8BtM_5e8nH9qtArEHPtYd9OvodMJ-UA7HwBP8PVRXNnsZGRgp4ZNNQbXYcBb0WkPFH9sizj96IwX-4vyux5cqn5sYcahoeVT2OV5ITakZG-vSbYcfO1nY1X4oIOakmlVpCU4Lpt4Zc9pUnk5VbrE8r0fgzrSBuP7rVGFEic-xYEdrqRg2Jb-vsk2au4w6rZNzg_PSpnWVv4q9bK_JhBN64JtBcaDsLShBt6HtC7UTOFhLaxOu0OSJyn1OyZx9gwP2QWPcpU5PdYq43ugXPxSpX_0mYgrre4Lj0HCkCgjUclhwMcVtO_G9EL4bHUY4Qzg_q8Ki9lvYoC5fwbadA-xXZ3FibzBSdlcJ85g5jcVzXePUCV_DZ-0twul8srEPBYubB3S-Aupv0GgZ8uu8KggkixwvsFCcP3YSZr2RX-B2gTYNNw6DuQP5OMYGgu4NMcMTufspu0HSsuEuoy7pdKXhDBSJLfQ6b0TUoko_qa01ybTZzJwXqB_BN-pAEBX2iJ3VptxI3KGzLTBS2TgDZA2zbiPo7T9uJUX2lB5ptbLbMHem00t0fJyKKx7CUNBsOF53YKKs-zIHPzitcuvvGsDcd1-4ATk3epVFmML8KmI6i3zMB2bjfim7hZXnyWGO3x6i2E7GWCYE3LtvO6qnD4FSR4U000YQ86diwtR423a3XE0kjli4Wm_XzNV1n755dIrTP3RK7gU2ZpQusbO15CTfMyLDmM-O8r3yIW87h63tEedUzcWnL-I8dkHJY_ybp09Vo_gmJ32jjU_pMluMGF1h8LVOREUKVsTw-D6Bk4TcDAmneEo0N3ueKUbvvufVpwPZ9R0hsKe0_jeVAYe-w7gfrvm-_xbniOCOs8GfMcVl5jSBDfcrUC-JjlyIYBb0koPHEYT_9ETd0ri738kTEat-inTFKh52A6tEWWR0mrfZPfarR66WIiINX1fD4ZPDqrVmUQsF21cjuPXomDca6VBc4piARsZBS20Gm_ylDTlQwyUFIOVhWMW4WDWF4UOLduWXopedC_JB3pFjSP0Zdkky1v9pKQPZuaGgYWxZjkMDfUrgZlwdt-zPGmJb81EINAFpiuBdBZg1kYY1evd70ZTH59HTEkRC2lUwq5w0GUGEvV1cto3xp9adRlTgMgltfFTPPiH4UpPWqse1cxVbKvBxoT6t_HUHIoxVEfIzwP8Klc9s8KzFgTvTiNFUYbs-3rYcVHsty5SYq_7Cn6zuqKkLDXSCPfCc3Rpox2xf-O8D-5HOIpBbn76K_02H8afSKJoA9XrwZ43kd8blfshDu445QDwSzJvp8woXP0RUAoo9On9oY8rEUNNnaAQX1eaEGzTlIMUDFeMZS1r6GuQcANEbtqNLcSHM-XEu-GQjXcaaZwd7zimzWtQVPyNKkjFawjZM7CT8lWcfjRhhoU2hzVtV41MsnAxJatTMG1o_1GQjXUG_krI2LJkOYOFNhVY_zCCL_Sq6_1AbApQY1rOS6n8iAqB05_c_1DjEwTwOTiqpeLHWrObL2kBokI3PqA4kTbhG9wu6dqz6t8gEAsvayo_eQruWb8t0S9NF9UJMzaInEWx-XItcW8Ab0tpfG7zkQUGU2cSAGAiJWaL1Q-c3UuOIvScgNJqpeNxbTRjWQBjYn31G4-J1vK44zE8F9EYoJmZjCTFhaUGfXIKbjiRMZKVob9GUlAuhyqlfgme6SUQgXpp_ZuJcwth_faqyO9r4g2rsmNUopQeu_gnG0q21b6NEXeXl_G3X-G9UZ8hYyH41llE54yi8bVOY-fLL6e5jLNdtNNfaD2mEGJsJx3CKqn8dhPcHouzwU1iBtlZvzRw3cXhpYQoBBdPLcHauDCleXpRkIoy4s87EZCPN1UAGf_LDcIABqZxiaO4RzQNbAtG8AggVm-0J6d6DZMN-g69DosGZa28fXh-B_REOKyZx8MhhKYxiR7Mi81TbWOk4ENdF4A8FWSGKBhr8tBDx7AuT6doYAaBdZxUXybAimDWMOBax86Bqi30lKrnvAK4x1sUul-de8U-WoaU7Zujo-vWMleZqtEnIbI54Dn213cNT4pXnveJVPoLjUyKBVn8Ye0i6Dg6dtEANYnYrLDnup3XoXFoYjeUQiwPxsXVC_c_ocicbULTaKR6v2jZSK8gxgifJnjPk_JskbakTRyOSpjGcN8jc5-9bgxoqULdhWtv01V2S_zdLodAHHg77RGGyF1E89rbVyYX2WfZ9Z23CETdKZO7qqsin30E5p7qq_wefhWIWQPlRlxAqvg3UI8-qJiOkIBi57ZOazyupJGyTz99JHC5OVx7d5NMzYQLAB6dDc84gXET_Be2ICca9QcNC9eF7Y9GxgdhK8wrb-bk8N6J-G_CIVuSeEt4FXmYb2sTVcJJyunA6fSh2y6rzjVX0QOMThKAUVwfqHgTOjfkf7Jrx-jkLpreYoIJrQvFTC06WRtIuKEKJ70dHnOTVFKCbPyT9u9cl2PKvUY59tWxFL3MlWfIHIYVTCAyAwjALoj338a5O543OXLSj60_ACI7N28siuibmwgiPz2SBjGfYwiOlB8AKnHWQy68DrgD2VFvFt8Ar61GKDtTtOp8VTgW1zJqgHEZNO5NiZ65cvhfo_IOFpgc7nUVjnyzg2AYqMThY_8YLiUEWEbcVvamUvf2yORyyTBCH0LWoGES6oIiSSDoC6JYlzuyEqoY3qhkPu7IW0cjwi7bZoIC482aYjT2o8NSe1bfpleI7kOF3FAe7UuRCAdf2smsiD-MaNzSTW69Wa7xJzTZt4-bNDLdvhgJ7QVvlqeT6xu9_UngcJjM5Ltnh_PJVESyi5LHgaSQ13jJ2qcSa-uYU-KnF4eVlHi60qrdZAd5FnXIax9uV6Oho48LNtqGIEQ4UGXSv3kIIi0EexSlGgfKK7kiOgboBonxdOj6FXN4wWXNALitRAt5yj8fyXFkfyhz4_SehRy4d2jQ8YCXiO39Jy6gtC_qxotBniJ-MlZ9xMs9bW1dKS3zw5uDYGUqLGEXbJYAJGzPFwcxn68klVAzoxL2nPc6CLmVmun5h5ldcaltu829b20l0vq79nUiMG39xAg3aMFkH8IxY2YGQSjceGIKVOdewwpzcHQaFmsRIAnStY_mmnkJEuvZQ41c1wWH7X-HqgHbGbZ4rXCZeGCiwcJq1-tcsmgh1WBgRmAcESDiwIoq_zA8YmvXrLv3KUSAEQXbz3-3MKHkC3XY_v_uvVhgkPPurkCt169IxKCZFUksUybXVM25VZEaq3CQwwtJ-n-HyivySqVUbDSCSC-f5pQoMIt37xfxFa_IlswfuGgm0MWS9VKbF886BsqMKROKPzLQumx5Dr0T1SwDBrioQvza7nwPHL3P3N8VpQE-q-gAxms-9kx3PUUslad_ZDPmlyWjuU2l-82-vl3ghtUX00ZK3Z5_--hISlFuZ6DQArwJ31irzLPa2ZS-uQKDLusd5ODh2mPksPEjiCm7uPvE7IUpic0nMUqrcHFUsmafv35aKg6UwKa9UChWsmEBg0f_y-gN1UO3y0pY02yppUp0X0tAsX6kLqXnF4ZfVQ1IDu2WSpqqp8-RW3VRBhN6WueSRhCgpGvvsTg-JeYJjo_s2vCRHyqlS0ntd4omsDO0y8RXyLdSwZmRVIF6cZzhwC_6qewA5KWUUZygw45_S6MYYlfi1cNyT4-eC2CzRwLBpQ0DX2hV_PWDdNFCRWGxY4xZSCm1xAPs9eU8lXZ7UENah8Y1N3-6zk9dGmMfKcfjh9ye9SRVG8jWNr2VjtHGfHEdZ19ouEXLjuivMw1Gg2MaD3MuxDsOqBn9IzFq5S_C1zzInjpPUVjFJgCcm6Jz5PLJQjNHbvLLFkddHu3Bn5-7UQxjuPbC6DQSH1Ze_znU1HRdghe-aOZ4dL1tbBWL4lqJGK6neEOirONADwI1Uae3Ua3THrXXdIoU9q-4Y106UbeV5g7Vxx4ErMxeE7ZTBnoacfxQwCFANWdh7UrkRloK7SxNoKqCBQtzeNoOjK0Hm8UkzGbUmC3vzOV7qo3hmTiP-XqqstcofWT5A5LtQg74wQrfJL_fcPVz4_bjbfS5WrbmmA53Pa8iJkle64FiUb9XtxsyT4g6uawVvOJ94ao7ZPHSU0ijdhOkf5TSBDXMRBQkCaUGkD79u98J7O5HiKzXtayJrqcl2WgFkyldcBDm1i7EDKYTx8BlNC6UQ0zdsxflOfxjD9UY5eMa4Ee2CtPHZbavFkJoiYxSt3mNHcgdKWSnRM_wY1S6GgKaZvuoIp0AznettrdTsFlCGE30CHthLYrmX1JzyjR-A_XHjfEhqjlAza_go7a7ZpL-Kz_CFJkwl-QDAVpadJchTjRKzOi_XEyzBCsFFodloB3ZoFzIfGHfZqrK9HSSbSg9Ymq-TGEUlqyoogNJx9hwlmiMqSOJKP1DXf0Pjjt0DAVXyHZExZyOdvqV5b_EZnGd-OnBSa8EBVlW2BxsR-ezfUFFVf_4gQ6P4KkmkTNz1XKgmBSnNbNS0qL4dKlmRcovxN-MM15qTvapuw0CqWTZNvfs0KdgUxHh3haYqnzwpQ3x-Jgv_PQKVtRrs1biKFE2FOIZEFI7qmv3eMtAAyRbaoIZPL-mqyfZL3S9dpJ8O1HlA9K6thlTDIDS3PxGjEePTZBYYqZsWjxmRN0z1SPRa-QlBIdussY0cXG_DpTf3O3xHKhStGG2zNlFnCW8ytHWXGalAHNCwbqwuZvb7b3l_tkHAykLGgyKHbzmryfiggPjk16hKtLXvVOwU1Binrt5mBvOUQcDSwyy4xr-nelllLb5181e5nPLhFaxeVij9DVeMZ5grBpumJF1REX1l0IWWWq_bxE8yn34_S6JqhfiX5LpHctp-4ZYEyZ8x5qrsWdwYdPXip38ZFAV9Tug_2rC7z81bFbgkpL-5nNvLTc58mj2aTKI2NTjh4kRdunMNmM7ehGFM0k5iY8QbzioCQLM0SOpI_0fzMWp8rZTWRjECr87JXyaEbi2YeYdGklgnlSvGK8tfM09uXHnei-juMP_koNOdW1-E-fmxdVVNxIe95SlUg9UJ6I6EfMcSB_oL-E7zFOywlSlXEoOL-4eckmc0mihrwAXoz1dBZPXfxRKgbme1x-GKknrIsccgFQrdsoyk_7xE-yd_-wVU9ttLwvWgWmpG6qRkZVOO-DsxTeeLzYjv0J1_89uF8Y1jNp8ClY6mmS0vbAd3NKWWZFaorwc_A_TKjGkpePXsJ2Dhd4d90bUSO8V38r9VNqascyzX9dzW8VtPRn7-JL1p3HaBLwDd05Eg40hq2HQbzgne5uznDr6YntCkzfNvphSdkpmnBVZrYMf1CMGkimkW-YUAMZzxtiEVLKRvFJekMFBvG18hC6cwmfcviEnBUOubAZvySjsQwWWw45r2nSX8fqsMMkOEPYkw-9ZbqXxMwaxuk3kLRxoOtszQgEwTXJCN9A7gK1Pk6u31bphdkVpg_qi_DnNHIOZoLNDIbuTYRjVTNN2j4saOP35BS-FBngaYesh7OnOYa3DvaFAvkQ1BaZYNao5AKOx0TI_1OjQL-oc654NGOnloOEbBFZE0sMEn7sH36xleuOa82gtIDiK2l7qrVHO9cfFcKc1LhN3op5AXq9EJSmIU8LZxelxqfTDfDnysd7XGBWqKRrEpClyj-m-nEbeejS6tjZY3GPp3J8WmqeFbprpY9LroshB41RI9E05m-yDmyWOQ5Cg-6dTps88larhU-geCXQBLSOoJ01Bpnu4V9VeoMkgA6A1WT3iILey5EWvJUt8-Yh4hIownuSdYByA-KRo0m-cMqWyuuAtgIuimKx7iHSbHG4k0ks6gIaUrWvSqpjOyk4-PgI5XssQ9ssMCxFm6ecQeDXOOQi-UU7Najd7oY-MLo9jATPzR47k33pLF78p5w-y82IkzbpiUL51q7ALiYsixzSpZLFN1zIW_hIv4ZkQwJ3oXaj8w4MLRMv8xAYjmegXhZtJEBAAOxg5noy_y0bczBmUr2zKjYQKFt5Tb1hQ1WIqPKI3tEMm_WOfpxS5spGo4Od4cn6JMnHn33wOq185P4ln7aicfsEZI7sE1kIfkUOCwEN6vqm-K7Y6gFgp4RhQkIZgalmVKSyiInC7AR4KQfC5UEpR95dKCEHbyaL2cyRUvd-5SRU3HOAbS6wvXRiSKEabr90dlgkvJgXVxyrNFmBtWjwFuWO_6tiBRY7CnSgXbpa8GYbIcQn9Pk0TMXQxkkjcOL7h1P-OvDF3feoh_YAC-l5YxyXWWY1iRvnNmyEt-i4fhoezmkdR8YkFQHR3EC0RsrRAr2L4UuGStOLsD4JTadZH_GIXaoP19NaNWnhLJKrQFH1d3RF_lQaQSyNvgpimMjqby2zYQGfyuQlqcYKM6izgS51PPHoQth8v99RH47O9YYmi__vbDHga9e0EHLBZxb-0ESmdOB2XOIUeo75UpzOap_e6iV-wtcyxswN-8hQDgiJDqW_DRdUiaFSBI5XcLp0R_vWxdiDORfbdtYHNTRbpJF1ccQVIKwNWHcahjWA1HnvosVubBbei3_464H_vi2bwP2uuPxXJILeYDD-JHaH61QyaLt1ZcIF_Hbt8IVywCCuy6DkV9KY5I2Dqj5FL0j39Y7m0mLTj_zAJzaHBA5b4nsBp8VITMM3BM9j7jIPbVW225fOuMyzeFOTsOO6UtnOo8utX8GGKyujJph4-JO49GC8y2jaz-_0EoicBW_w7MrmJP8tURiOS5T548ijiaG0thViCDo3EtsDoxqUA22n0iMDXa_Gj_fND-lVx0B3kZpFrGrS-4Cn1gw5LzR1_NLlstyPr9mizB-q66Gp44loF8wL94XkQn9vWvAp4UaHDNIBxwW1Spk7jm_KLPUIFIT8As8NlGVSAywtEYt2fSEkbucoXnSvQCm3N46WmBDKOWUtJAorc5K_lwL1FgR4f2Sxupp1WzvXjH4c5g0305mcxNJZyi22wjdQ-lVV-oohRLOaYILNxMmjwHmGc6yTU54V9O4Q35l68v4RVZFudK_C5T9XB6HuhFHdlt_cl4SZffVmRqeUIs8cLcelBmf9Z1P-2LLGNegP3-wd3ehc7ioY-NfdUvBW4VXWN_Z06YUH6yYqjPtOASVsQhFbqrzTpesf7w2MGcy-mfWdroqKQRMp5LJcWTweqj4Q2JrVFrDYNitBH9NFIapmnw3zC4Lut4XvubH0hjgDaMtClZbqcETnScGODFDzwXNDPxLgik1XAPvy47ysos2shIdW3k0lBLm7mdJIsTP91BprzbgUoxne4DgexAJYarwX8nm57yow5WcLK2JiXV2EQQ9XkMx17h_WZX2vRnsOjTnR6zobRBQrDBMRvlpumGJYqim1hyZ7Apr5_GerSW3cL-zkjiJAYply6U04JlPu0-4gU92u3Yp4NxYmdMR_hTF78RpItjdA95X_PVerQp6f5ePvXpKwJXXgYS275UmpH7XPDFrNk8CjdBNL63OdsR29ILCDDP-tS7kuDojDv-Hi9yLyF-hSr-JPxgMzI21zZsueBLJGJO8JeZoNHsAGKNJuE5w8_d4G9Th9cWd4keJfcFiT0jFj7URfzRxN4oj9RvL4aNo4TjNPdDcX0yROKe53pZ6XQsW-JiKH5dHo0PdwBtFeZ9Pe8CifVpSS9VjsjFSMZy56u8Nlc02B9wpCYOWpYcfCHX1VIliiDLtfKqbVEhaMU6ybUlEolLRViOj9z8Tccm1NtwIEKV5T3rQp1AWFdYostIua9wW5RYsMX-FsUFj8o2ETIdqSKB7LZMIRJ6fPeEmL4yH7sNInmcJiTQivE8x309gCK7XPtSslysa9Vy0YCiivjhLqDcTUFtKc48X3Sk7LjxUuGoQ2uxx6CWD7C4wOMklD_-41bzElT8NgHkEeFe0OMmmmFV-JVIWVHle10CY-7FO4sw== \ No newline at end of file diff --git a/backups/backup_v2docker_20250610_223246_encrypted.sql.gz.enc b/backups/backup_v2docker_20250610_223246_encrypted.sql.gz.enc deleted file mode 100644 index 217128e..0000000 --- a/backups/backup_v2docker_20250610_223246_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoSJZu7211U3shJldR00Vp2Dbb0B5oz3PBJvpfmYsyVCONIYQbIQtzXA107NJcwep6Yl2MZ6GUtySuMH8JdmrbMylNfqJwOdQz658ZEcqKqr1P1-bIqbACrw9NL4bquudh8_CpcVUBMQyEDoEh9gMPRpRHlDJ3Q5TD2leL_19PWnakuGXNeUrbFSL4xN9CkWKpON_7PbsKO1N3JTOrg5YM9w7zzFpq_5RO9HqNW1QaAxaR7JXP6CMYO-B9of-oNuVN_AJmBeW49Dg-0BemlSIQHT8lE9gDK_Gtcbf5_0fpzMc0YKvKpfYkZzpwn7blss2F5aDEOymlunD4U0vMXrv_Hcl_wwxh8ROlxJUStVG4iPAiBTMaFOLr5hCsRxIPFnJzGsof6PFoiuwosmtlTOP2g8_TAon-3wzZIMLyTNU_FNFzO6p7dRRzy5CKUtlT-ToYfZBO0TMPiuoQhhYYOQnZ_iptCv8k8b6aoR8HjHb_VAUi_Jz-plJR-Lm3zmJXH0HxpvgQp6PkXBTpZzq7uX01C5Mb8UAJrUwJEhNbdlj_t5dwFp_iWn5T7VKEF_iEQDl1d-0foZfA3jODA48etr8swu3Uz41SYz0X1Wpq5UwQLRSmw7wFaxaFe9ZUP5prFLd_FxHXESI0XcGXVZmY1HRS6OxS_yjmou7aJmhKkQOEGBqH0PVPy6nMrbIzR0g1Eo8ic5NJcN1EMuRE2yvg0zu8MzqRDLAGZrfkrNbQWDDDwVt317OiZtQF0dTx0STIkbUnwdoBc0pLzkrHThsdlmt-U68JzJf9Crm3GLPNCuIoaVe_HZTLh7GM9ia2iVZ8Ag9oDXNxxPaxAGe0v5MLcOCEl8bFHsd0o86BoLQgVwmkVKrkO6Cnd5wBEYyDQLf_FJPkY2lic7YBN-fKCFjvPW2yi-3POZM1n_tPPlXxaWqUyY9o4ZWBrIBl7Wb-GGHGLhSTW6s2027W5nrFSjV5Li1FrXhEu6cx34CZy9Ay5JBgnVEHTl0HWMhQxUDhw2PQ2VDk69ttwZQjhztrmDd-4sAYsocyOt4aKELKtZugjMwUiu_i4CVIc3LxM0z7QMRP-PK5UM3SL-9OaNXTiHuevriRVNrnxaTmAMOwCZtu1I4fgQcnl_gGrF4SKYrZKyZ5TtIlEB-JQsJ8met8U3ZsVXn2yO9Z2IfGyyRsW9MnIMriqfeh_jQhi5ZxSzX9jq27in3nnoSA0tT7t6pJ8wsPqhhOa8Mlm7a-NIJW35yadZ1DzUlR1JVmvalacEdUeqdYDNSy7xMWnl1n5HD-mXOzpkkdU0wUySHoytYDqB8SyaT-JHbZH4gLCGMWEfQT39ZgsMg6m_49d9Mzzz_zvvnG_OmZGyRjzkHv8cErqojN_SAUUaFD1jc2OiEDLAaDB-9-f1P4JSHB465ykj7sWCRZXppIu6sQUsxiNuOkEPSmCSI4TOJxXwkd5RTK1B4fgGWIz9tg1o5sU1E9GNnbREJqwBcoAqzOyV2TwQnvCe1sPgo4wevnG2TAcvhA8r7tt-UFdGVAhwH873FVcB2TUeishnGcjWJmoNTsDmiWs6K3nbB6Nxk1PyWlrAKGCYrVwtN5eUAtlp7_hTvBYfqAb7tlXqFnpFAoZGzRH2QXHKisJ5od4f4nGd_w-akdH0yLKyszqT1iCrp3SX9ZcS0PqpgJ9qhafofwRXgD8vJT0F6D7b23KVq6FuHKMSTic3ENFs3S9zGVw2t7gpWTc_GRw_X7eV-flgs6b-Io7K_2UJLUQ1HuRueYHJIIUnOfMcAwpLA7zRWP4QEfSTTyzyI5vjetv-tvMYWV1NRAVo09jGSsDLfhNr6utuYa_kNaayFp_dGAE5JTdQkVzAZ68habkrnGzbOmc7sTmB7Y09kH4xIroxf1CG4UTPadRy6oBGa4BzpRLiOLVwjC7Cd2iO322ZKSHr14orQnb_-tnvcFpXz4vBoTG4SebjpGb1GA7Ixz_iO4SrEqB4gQWUGxPx9CNP8BLBsJqjIFMEDnK_aLX67VLksZTlDyGmnk58wFFPyNNYrK95wC-bJ08DKiYN5-8IhnFNoahsy-Rnz7M6eHedYeRGIPWEnsTPSlhUcVYZVYArTU_TOVaNhpnoUevPCLr5tvluzqpGwKQ8o4PZ8uYKAoa8FPEaJ49Y9__unpV2t61D4fraEC-v1Uf5K1RGhlX6UlOU3V_L4XdWben-anem-4zfMQlNbJ3DN8y_2zca5i6qVQr4znFoEe9nU37ieApLZjf2QMIn43f5Pg8H-Qs_TsAAc17kgw8Teu6jV0LfMRu-A5etu-leWcD__ZjT_SyHjghMVKNBVmoYb_Dy_px78J2QA4Lu1YVLWu9DoGA66NGg4XP1W-nzF4Ol7ib-HseKUvdAe0QiYLGpcuC1rSrvlB1MOr603grMtpdV1V0iAKohbByAk4F4eN0kG5UDpjUx6Xgy7qpqwzCeiBCRmpITM88AqlO-0aKisf5BsxpFc1aVMSQZ-o9mJZQn2Y5v246NqqwQeGc9N9w0dZ2DhDZ1nAyzFXlyYtcNSWuHAlA5J6LWcBgD5mQbCThAMp4oQMNBKRPlkaLsVkpKfepHt8athT_aI6LNKMp7g6b5dew1SMJVVKmwUeh1US8wAS00mnfsygwrHVXr8fzYRgnw3cF3tXPwG1KTObvm4Bj5hvNzv-HKCqbS8foUHw4VmbU12S2zMhqF-1uk3FtbA_i4OmD4rToYniLnArXJla_q08yjnCs8LUrIIipozXmpdK6icb2fcbeMa4aGUF3a5CEkwYmfa7h2vTAuMP5-KvRuvE4W3eF6nmQ4DG2GAxkngLsX2UtwUUR1MVyvtBHKMKQyGysPMld0a8uEWXunAv9M6ojHgUKs-X1GsCx0WGrzhzdWwnCFAIsOi0GK2Hgh6QgPFx07Ay5NesI4fNxZ4E4Y9bWcRbY48HTfu1XJNO866r4D_kU5m68v6xYh0vqwv2jLiTLUIkfVyvHmtym6l3UvuT0Ds6mj5JD6FsoqSsUTdTzBU4oEN0SRErTEUdScABUfelTzhn8_VEVOjnfVyxT3GP_G9laFafsqWAJgZcb7BXMyZ4VMw5b2Mbtyg8f62k09DfWDRuUUieeWQZ5IVkXIXobkfIsMHPwv5zcbCimu5_nhAJhlH7Cr8BKgrQNTE9XFHlIkUuXKG6vI_pS1HMAEUJcjv6pxrLHu-09DfHL5aR95Q-RhwwTeokrQ202SdA3v1dxa1Wr6GD220xOrI9-3T_AY2pkyARVCkpsleZOxVUxpSmzdI-5OoxWW4YNXFl7wJFFmRSbPZyF7_d1fOJO_8BpFNB-phejWF0Ymy_Nn0baCMJW_bVV_G8tpm96E_ZStcBkTj_sFu5-r0KUo07RrgttRmjqrfPF_ZY52bZwRpeXt3hM7YbfukNlATCvrI1Dcr4ZlI3iXvMmDCp9yr9zo49K06iE4VvvaaootmXyFXTuufo3aQHg9Y3NuUA3elUHmezbPyLSt7Mhsr4WIN35C-xv6-KSyJ3rclpdKSfO1SV5ZhQhJo-QM7XHZQdV0zDXTldTHa1gYFSBM4hDwRYg9U2WcbO0AMmzI5qDLk1_R4wNXsVXDwCEKfSWw4W8ygnmJhy3e0Jpk5H1liewabM2d7eWAOrRiQoI2K-IiiJThLw6jzC-TOsj42qzFdT3jQwZa8izHqBBhdRBmzI_fApV8OSPbemmeez0fQwz8nH_OpXYl_ZZxNnJrb6BELTXKECJMsMvkWGpAwSXH5U53ypfiiBhuqqUCGDALeRAYVHOW8nQ_KSLZs4rwn3jPTsTR2uSVl-qZD1lJyeiWAGwqhjBeAyUC8kWn3REn-CovhDsh4QCwopQZGDfDHwsh7TDvda6otvQZtlPohDlAjr6wk7ycc8pcUsZVzluEIR754GpyFreNBsMMmZ2WI51n7PDw7coQydUgOE1N1ud_iPkfp3tP_WMwP_xRue79r6srf-ERuiTZ-FZ-3_glL4s3_HBx48mpWHD-a4Q2vK7L7_OcsSn-Y0UWORtHOv3QRbAkYAUYN97tcf0pUWFdcTtjbyhRuJmPrWEhfcs49OfIJcYb5BuLMmudoWAeuFzQDJdNo7RUZMb8s4_fXBFlnFYPkx4xVB-SwkVrJt48Y8b8_Jqau3IdiGHZIRnQoXcXJnXs5z8VrHWuJbg9u3Tn1LazPLNZB0LFd6W-1tEtXbUyqUd0gqtlvfLsbNBhJsiGC2Vgjo5qgJhbmRGoXjpS_cQEM51X56Z-diMVzZfE-CtgkoQjnCl_6CXW_4jL_FrHBI0-19TKS_lIUSuiUQ-kI7ygUHrLgeIYuj0a4Hh5ovwBPvfCheC2VSg9r1LhXQ4rIHkcmvxUgVeDQenaNIy1kdq7X19X0UvGHKLzXWLJXU_xh1OkwzKdNH47TdkHSDSAIHWJy1mNmvQMhgHe_Fk2wIQpROt--t1xDYFA8y5BAWDDqx9E7wpBuDwqjSjFi6plIBI5iQAXgTXescB5nsoZKYYZNH3Pr6VRHdBkmsgzoYellFexLp0MIZDJ5rSdCS5S16rXT41XCfu0TfI1UwSmieEzw-_vX7yJWBPjnYZpc8llK1GqwqKs22i8kMPoVE40Aj8d3F09rpeOJy-2bdur-uKzDJtI6oFSPiOEYHIwjCapzswvQZKaqYC9m9UGe55g4GeiYwOh_gEwyiIAZJuo5ucL0ytB1COqOPwKa2FPM2XE7zb7qE6sEPbxrgP9nrDeC8lDKg32NAfHv5QwT5QyNaN11aUKnKXAbAmMyYmsDU4pjsROrnPjT8oQIxH7R54yKqbcs4-CkyE-yuiyA14JS7XBufqifxA32Hd1Jk13hY5C_nvJu7Jm_FL-6qidBhn_dDmzWnLVngUBLe6EM2NaA1pt5OLD7kkDZVTWB_gDQdnfQz_qeTebnKBs3OcXVOF7hdscpfAABanw2wURJkrAfCAfk20xfYO7rN2lDLHCsWTl8bdHFp19QxYvE2tJrBaHQc2uzOnHnO3mx_TGTPQfrJ6deb_AaFEWkXWXKD9TfDjNvll45xNn2BQHP8nhJ_74k6boX3AYA2sR3i64_KDZIwZmDjyfzu6t4zWepbY6-kvoyt3fb-qUegGUhnz0S06_BWJ6PCw3Z8eHDOrImzznGIRJ0f-APwatbEP8OCPug9OwZFFzzJHXp8EnKQxhXZSKwJHJugbmugiPIuTQQzKsQqc27mlZat4gKoPSJ8WxZzNV__YOqPUdGRazFV3H8bvfXSBrLdcUGAXI33oizoi5FeoAwYozuDAWqxk9kKGKAGvmv4RetiZ1wq77oG0iXoxWgQBc_52Jfg_ErCM1qAhAPlC3h4ryOenqbLca-uVsC-mZ0nu4-QUUXfPhGCIfn72ToAFNJ_U3mG3-LaE58cEOFAC278mBlPqcl-4_f7KjS4XqlDWbZbuA_IOLAK4Ha84irnv8lPwbFdO9AEot09TiNdZp1f_yTjcmw926UNJCA5jhU4kxJNtiGkQME2V1TJN8YDnKyW-N5P1o1zc3lIeXmwtFQwhnLyHnTQq0Y4Rx2SI6q7wLm1uo-dsoUftsUtQGW3lyS7RxlYCf4YIcFduKE49jkaJd78_0Qu7ZSqfSR8xqo_nemTYkKaSQ3aEJz7buvwUuP8vun7NTrMu_p1YZ7Et7PY94rPWaj1rz9fG5FOsyMlVeunou-y0M2DlSPTnYEfp1CLCFUfaUhSjV2vF_Lngqobhh5lByxTCpjHMrpXDQ3PmOMauMiZqK0gWnrTj2SvUSBCEuHPGPB4b248jbxS6Hjyz41C-ih3RmKA0Pq3BPnWsEQ_fPT97ha9hzh4W2Cq4oPWEczFenvo-2Kl8JIMGY1SGve9TugrmTbQtPF4ECLM0eratcU5iYabuyeL9jEh2FLbrlvDTdhgG0TvlYpRTUM-imIwbPsPH-xUjyu7kZVtAXJ4l76wPZ0oSSzkdxYZobpqHR-4xCFcSWsbPoZu2kUzDHpOO72acia57vKEqwfhxdN8VfKb2rxk1Tm-Trm2Bun_0gmXI4IH3y6kGQ3WbElBg-7OU7W2MfYtlMZWso4EVLX6jIXBdsJEaTNA2jdbJJ778Z7nLNxn7qOvZ2Sfpgl04XVscVMitXKD1iF3V8-Jr9yhFrH9r35dlKJzrPUk7BNAXR7dd0q8rkvdW2lglHVnFWhMu__rGMl_B2sG5oOOT60rHyuQVwsCxbZ-NMn7PH7S4xRnBzl4hkBnz2lKvR6uMGCw-XERpqgG9xFErk5459XeGTamAv_Y9xnRuZcBlSV49WvbzubNy_zkpsVE4ArwWRNLzSWVPnMsZzH6nEzTV7VzXDaTFuH2F3dNxGbVYuKMmDFLUHYxc2216FZ_U8j4j8sGFPIo5wK_TpQNzGygQpHH09Ar0viHg-L3wgjSzyNH-MI6JWjyo2X4jSNwA0QtOwiDuhDY0HFEoqKrRWnPMkDfmGz3-AS9wY_WZwl9xIZQdPtr5mkOAxhz3x3OERIXgwU0k76S7iGsweoMkiICNPKOXFzVxTRp01oNUosMmZhE-sgcco6XuI-XSqhtzopREJsAl3bnAYT9f1fc87LiMy2PA4Ma1V1rQzvoeB9XaaxCo53AxS8BegkqkgA8E2JHcxDL8DqkndgfVGDwU0081nQ02mhb_rX2n3fYRUwC4HvbBhxkiacEP2UnGooC3N1hMQmgpiqGlKANL1MyPvbk3MDx-ZKCSZu-Khbkxma9f0IPsTO27Qvk_xuvDhzZ1HdA-ZLiJ1CPZ0wT3H2dhCRjj655IuKit3pFV9AXpv3h825KeALUNwjNtBlh7i5DIR919yUVLy9_bZpiFIw7nWxekG_ulNLhcqcjTMmVxftbKOot9rmrav0w7a20q1XBOLUn1VCQj7BjB5Ga7vyqELAahj_HftD63OGTtL8w7Jg2wy3d1eBd7HdzXuKIJqsVNdBtya6fyhURCq8sbkyUwai506eZ2eS8hXk6OxKVclXD6FGLVJNEZ78Dbxq_TE-aQUA6leNvFfEBQJtyGU-r47Y5Bc8mBU46oV7eLX5UHdZLKC5pSIAVitIlW6rUS-FfOGKP1LXSPckWYGp3Q27NGhwVXBP9SsJj0XEgxqchHlZUb8A8S3mT1H8EbY70qr77q7V0eu3dpPQQIx8NzyJDaQYHGwZNN5ddS4jI77P6oP3kuTRpHj8xlAAmEs1y1RkNey50t95wbLxtBGIcj706fylL65uuB__jqBSOjy4kegwsowym3F70B_vy6h5_g-qqd62J3EtKmt9ZmurF44YG0yYb5sZ4a1JWZFMHGXFuioxlHhfi17M_pPeE2q0YfSzZ6u_tThy8sTMQx1hCwgxPOcSp7rzmArSifHmrrAiAM_6a7qeJne2llQTG_W90HvbfofcZn86UL3_pSnrl4GsB9SJHR6nZSTIkf8HvD1N4Llqup0EBNGJDpCjjDZhZstE6dDaWQkTaLmf6zdQplGl32YGqP0alXvZzetLD-mbYrAiOy1ZhVf9XpbPhfgKtOTsc0IjNnjN3Yf0xa2TrJULmb6DRg8zvJpiCxjSHJkB6veEEs9AEGRlCZk5ZTvOa9aSx1hGWrgX3qZAEu7IV-Oqowi6FtIheEx1CCv9Xpqr4MimoPPYim6pe6DqUyXWKfRr9_lJ1UAm3muWpJEIB5Y9OLg-JPF9hn3OjWwwliKPghjCvqWJGkWx008bbRWHVBkPwBBLnbXfcyjvE9FBIpVGXCBR0Kk_uAWIN57exoiEoB7UncsMjzWF_IH3PVfxA7GY7aAHyK92zYbigUHUnstZV0yi61z9INWonmMhXcXhWX6-BQKAnpMUDVSc98IqmsDPj57XXdvihvMaV2E2iDTMeUgpLx1Bjote-xe6Q8s0yHiN6AhPh01shDkYWVm1N0-C2COMvsVz-QUjjq7V2cZslT6Udk08g_iXgF4Y8MgR3RfGTJnyGxaagqM95NCc4RCmgb5cs6bxfVaZPeZwvhtAnIjVB1y3WZ_3Di3PGs4F82AqASYciYNm53hya8B3Bn1fgqOHehs-AfDKaGKstYTlsCy2QKJFbDKO7GTv8Ui4xEvW7zEsURNxTH6ymLke4FkCxaESYdPk6jThH6ja64Du30yw9nd8Z9_1uxQ6aQzC529Xomxx1avKqjv8swf4pVoGl8AhWQLZbAbmGrk6vrg2AHCXi9K8TNFPVwnbh4sN0OuJOv2nEoLQkbwK5LiLX5efsmMVnhZfonizos1MxfJNMZT0nbPebM-yU83aNmCN4XZAAiCM2xrg-rT6IDN4jdezAzxTtF-ZCCrZJKDyLiu7brb-4VF5b4mvj9fljljHs2ssop4mN7STZ6ztNZAKcnF51OIststExs2YV-K-kwYQc4r3Nx_x3prKUAdlntSriVyzYzsAECa_qVF7zbFgY_CQq-IvW8apD2sx4e1Bria2gK-h5zKgLD4tbSmyMlhvZxOwiRZZikRp6sDgLpK307dQ1-PBBBO_s51WYOMwXiGrAwipVi0lzVt0igzOojnIKG7N6TTfU1R6d8qgZBxLlj2HO7eEmu6pxjQld7sFAc8Uy8r1eKnJWVov5x4XLIGeBjLEv0UG14acEFgoXaStMKyEknfoMt3FxWqZv-dVC8rbP1ZWC0JqRvdrD17w0SKO0cqDMVN_p-bbGQ_CF_77XmdK6zvhcb0pMOhhjUP25by7Ex6x_JWIlIVHKTn1fqBv0qu41Kjd2SO9z4FhfQ9TEKX021MGADr8w1oHO61JAp55y_4DHZE00ZLZnVabvfXJXNENxqmz872isX4hzmGIH4bv1jY_VNalzmdUYHUG-uvyFEEq71HFoFVbDrpyS3Y1OddaO1PklSMHwMfczcrknvoa8kOGYjZN9XeHcIs1F84qxnx_jxlsXL4Gv68LeUPfS2UGD1s6J6O41yjQwGofB3fmRudHxfCJbDdDMao-I23kF0GGnoNff2qA651G3JyNPuk5ciLeRax6H7szlngOKJFQlyT7DO6NOEXf-clscovhFB2VMVHPKywcfKcEQf74GZJwoG6Ihn2RuLMFaRydAqK67dbDybxKRgJ_YqldB2DVMbvWEZRZkQY4NOcZ7K-7gpsiyPo28ngaXkoWRZV6bT6KCORzDjrc0Z3ulZ-5UxjR7YPkS48kfqEe1V-6yOUbiFgtgDgiH-dYOIWQ1843CUgwjK-CVjL5rkl0J16ltHIZG94eBGRAAQUdslDTQVU6eIja3aa90fkB3Hx1eLPTyTpkBH74MZqCKqYgofEBSd8gXPbRY-NsvyUXlBF947DzHYO7x_-w9VZShFeaYPGc3Bfpa-TucWaRZ-YX5SkwqtshGdJcGXLp2VqAXi6WPg-djbdY34dPIh1MMoh3LQhivuR3_FIXPtAihBY03ViFD0RxHeHtkipZ0G6haQZ5RAU9iL1LE3KiDBSbTEU9QieJQGhSvs6WgzxN7rHvepQT62b5PIG0IiKcqxcpo81FtkUhsuhwe2PRJ5lUf-VyVYcDafid651Hp9GQLh7h9Y8NRm-SgsCmyzfsySMwhqTQgnN7cGFf06ZztPP359BW5PkxEBMKZ4yR0GTFtgiXnCdYe74PdNKMlytGOZCmmFCT1oMb-iA8z-qD6F6bLGjV2I6YSldhswLzitMBQ9gLkLexes2XW1pkHJjk2VQurMOBY34fYzd_uBMi9q0JPBI0Z6E8wu22lI2jogokUYBq962LXXKd7TzZkxcji4LYGjITev-T48PrZ994Fqd_DxxSy5SALVR_U4XjQl8_7OKel_dKSmF8_lPgr7U4vuIwkJtuxHYD36wx_upaNSntfIExItN3f4xscmMXFZGfKhBP7ysVjx9dAwhCDnbss1omzi4xbdJDe3hw8D8YOdJlJJYOH2TEkzQXP9_4QvpcuhS5cElkIuBhW60Mf3j0eVeAEcvjhOaEWezOwzeQwxeI6mQonL1hhqBySyJO1GG2LTV2WkhpTQsWFZCd76Nl37iopo93JikQedCZPnM0wGj8lWn-awfy06lcmUWipN1H2fZmWFwAAHfY7No7Fe5bC04cqGT_rB13bienk3mFWRJgwCFQVJfFn_KJS2E4TGvw2Hv0mmzsEV2AUN_Gn2_rjFgFGBGD_Viy-Cy8wo8de0Bp22uA5Np4jLoNzYjplPucFr8weyQkhknTRN8SO6Ia4tHr4RKRcajefmM17e0dW4Mf_gunRQYBQh-bFUtg_3ToUKkbsTB34dILl52u7sJ6xrRi_QlGeYZmN-J2Na4yV3vDDFkN8WzG-uzCQqVrxahBHNSTlTNxhq91WLATNV_sYv4BuaRk9oOUt4uh-qxL4N3BPYukClU3zXJwOSosFCqCu6VJQxtEukWLnAJspsg5rTCOGcG8QoxS5B7HMx5Qp9f5Y9mZGewgDmbKBZR3169JMRZLM2bNa3QNWy4ggULxGlAS-xTmEKrJVtjCW0Xrrqpu_sx6RUxx21AyYCC_shwCxNLavj8fhaHuutLLMnJhMnph8q3WSGSAf4wkaVlw_Q9f0nQHUJyRKxP4WM7wCCK5ZmgIy8WEyCXMqD2gaadYrCgHG_KKXQUejf-wXMDEszPN4ndq3SPp0EmDqs6Eeibwhp6a2Z3x-mxwI3MjPtjTReWxBNi9mfsCKlQMl6bJ7uL3KzWp7lvu9uSchcwB5Dh__hraeRNFU_TzYfeZXAR5LHFH6AeIdca24o2nXoo9fRl-ql43vvsnP76E8Svul3EN-APPGvuk_1fw9yD2QwAvYegpG7f-NOKJ8gIvHLhHDLNRjMI3a34BSlfbubvnAN10x1pxBfF06dm-xs99r6OAgPpojvSqRMkFm6HtA1j6r_HkeewHyhp6MzkYAZcWlquB8-M5KRzIQiYTOJ1OLVbRqObSUMkGaR_c40DdCguaIzWWA999-J90nnfZwvOaLNJw63xnUE4CoSHyg6IyAQrDMtMfnaPyYYuJc-zpkdpZUWgHJa1B56ioaedHWujaImU-AeiExQQ2EudFZowJ8CvWE2c4BzcXf6BhKvKGVjN2nljBkY5x-SZPedr21jhtOcU_uNhq_-_-q5gJAGtldOP4eM1CFJpKGLKf3xLvNdTSAlUcQuD3N00AD41RwJbtrIYxNc69uyaLXAXzA1MA9HB6m7U1Pc01uw8xotSc2RX9sjG4QT2-gbhzbTxtSEy1y0hwdXxsMrBKsHmP1sFEZeyBjsz0uyCIy2ozJfJoQfsfV1WtsmiZd-2-QkXAUJK-0eE2KYNjMvFaQISm9GW-TvSDJAr498HsJ1urGVTfVcGtrrA-6QGOInyVGlDVD1rbPggc-PJ9xfCybArOsElHyVv5CjaIoU_fGTAUE0qk1DnXbkzGholdVDqCMaBa2UshF_uwwcSXo5y4WFBeNhneK0e8U9ElgS102GC5sDExZqg2c-HqfCRV-oEa00nsgZtFHgpz72qphmtYW3eaZT1Q3klCyF-CY3Ti9XRzczrHxunNdynA_iCRw1psEfDVdx796paFFNuJr416OfUPk51NDPJRW8wG4KEMLYQO2zfuNtDI0yDp-SKdg0YYhhHb5fNSIQx58y19hWNwW5-lrG17lJKhlf8l6a_3_gtBtFqe0-_H2umAoETq502i1Q4VDjtLN4GCT1ettqPo4b4eX0XdMdhlpe4Yv0kFXcvGPMvjKUUWtr9vRuoOcWcej4isfkzO8okh-y3otpMVwFuSauNL_b8z1weXtzCzukTio7Biebu1ODHY5qhqY5K3tv3qIiGrZ2D1jW_yjyMu01NOpmW4d8e8iFVa4Srtg933z3RqUXVM09TtrNjBeOEXFC1vfm8ZjJ_hDseLbpnce3jPDEgO9zI8xYGpmjbA956iJQioagb6PBqrGVZh3zGLiBAZtaTOWh0xZyUuxTUynvCyHBO_941ji3O_Kb0hOnIHk7cucmW-1QgivAxeY9_4cVcaZE92LYCUQw3e_lDARO9JKZApPMJ-nJIwtpFrwm36C38JRHGXtkdB6d1KRNC-3YjqQZfw6XpKSTWqV0BnQ0yjo5U3jERkgvEzK-kGMmDl_LOLUbFFprQ9BIJkn-VG9C5zKV-QE7yI0anC0-x_u7Z5U1xpCzFJjrTIuKx2uJKzpxbnxKBRxtNfoNEu5PL7OxgrnKfZAQlc5AiqtPDal5FP7Mn8BdTvUGroRtqvj2__VJsa4gOmw9UjoJ-jZCectCOd3-kCrfSC9ACcwLmDs29963JE2gsp_JwxRGWK3YVxSgyavWrUOczRc364DOF3T9gM2wRAqAxSs1ZPwWBF2HpUfcmCi22njjaM-5FPt3ew1ENd84GtGnS-ZHrBiCb_9Jupjwo-eAWS65j1CD2EnEGTmVRpw1pYnuwSTzuDW0A4muzIlZHo39VGxn1tMtcv_wAG256CsxBxV145hgeRTQE5z8cBsxiMGHwz17aCL-5ojqk_cGMOUcEQ9scYgNx_NiUgNzrXwN7UWhPbyL1pbXfWrKpyK4dzBJ42gJklF4LpBJVDyYa1AhtwuhpxndSR7B6GgrD1qz_WGwVhofHWtzH4caSXgAmHNgRy0Qmi4dvXHHjpKGwaRVy0-eyZ-_c6vS0PIfSLtO_AeTROeaQOhOiN9H4zRYP7F7bnovphgzavGfISdA132IA_wIhYx9J4_2q2An5EYUWu35ftl99BfmfvKOfnxgfanu6zKkv0kRAeL1TIY0psAaEW-nAXXCkJYYKQ-zNDw80ft9M9AB9rcjk1PNgk4R0MpCxXnriHlDZis18xLYSGDF4zkrmOz-A0_5uG4m4gy-vhKKZxSy_vuN7Z3uv5nnUMQlVFVeHnUrAbKRnLYib7x9BqYrbf2E03UUTkqoBy9M7cdMEj9Lm7pmoAR6YkF0mphy87CkOx6DJShrjRZhUqzEdb4d2bN82z3oPHteUbo34rRxIohhRn1_n7FRJy3CBr5zxatT81bNj_9-seSve8fmcTeg2IQRJg8ddCBhsTTQjfwGYUSquYSqtx6kaZzvX9gPwq5yIWpjjQdTlFpB_maI_v_VdK0fKJBcmTy6_-IKLeadT9uGovsUvozOYsUZK717wKfW_Q0_Rz-pfJQloDFCuuLAvz96F_uDnIvidnWKrHUcUVrWwNWz5yMyGG9G-OCqUUJllw3hrZq_LWgsWJJDu7NO18mER8KCVNVXTmHxwytH1X4GtboRd7D4DwFGQBUq1u6h2R5Fk--iBILrM_3ABnd6ksdNEfbKjvRZ8C7cvrZ1Cn4C19QReNIrWvRaoCOyDZTQpOYnkef0W_kyokhmXO4iK6ZFOaUxD_mp80Rs5bmIMrtxRZgC77TIU-JzIDS1QEJVaahqaK039aQS_W54XvU21uYZDNEhtuLZwtP8GujdwxrBy76whM0XYAdszqbGDR33O9IT66BGYFtPx1an36srA5E31O8YQUlkDGnLz9cIHbN76dtOYwqS42bPpL7XBQqBKifmjxNchDpIdRnZCLNyjJppcNm2_S_P7Rky245o9kaTg5PNdyLwg0Q6iUA_mcRQPhDTG2qIqKGNLc0RiOYNPXhuR-y5u7johI8LQSWPWbadiF_zjAKT2-vYM95yau2Z1_LplXaUvfD0OYC9AEF-9FObi34G-geTExEVy8VaVAE_FazN83ZUP3AtiCLEIDU8z31uva8g72Qio6Q3kbLdEyHKyAWMSC1ojrOejA0vtNLh_9dW8bTF1uM9jbP1SgSHBAEzyDKpA0GCcptSOo4RdwaHxAI9OQ-2D8ye3dZ3ojqyALFQJ9XCvROfQzULmSa175200Ll3KzjcoYAwlrhFaKDZHRYxnnRTDSZ5b5RyQe3bP4dQlra_2Zw9WPJnz0hdW_dM1R6B4jHkhjnJ5yVsACbdSC5YReBWp-qiHI-WLbqXvYpDefSzW5I_IcwYXiZku3ZsMbx0Jxa5QlJKTYeDOKE7Za1Eczzw4MeIqeALc80KzHaOIRUwueG4sDwVtu7OIfC_lOTJ_l1Kxr0JxN42ygCiDkKkKEBvcgcmi7oa5xFYEovvuUwFmGkYff0H7NaDoGqQ5q0kywRxslk9US7u9-dOgoJJ46yU3Kf1sX4g4DOgV_C9ISCZWwt1YJu5329CLWMvcB4zmBbK5-v1mDOJQKDYhIMf05U3qfgie3HMjKtv4NyWZVsc7OH-5snwFEvXsSAiEyVBQ7Jwpjo45z7kdSdI8G82mXcaeJ30Gu_SFgOTGQ1EOUix4ohlc5fn03iUThuphFuJfAegCKOH7x4e5VAX3JauXckYXc3Te-HvCQpMGI45Ew5JojcbcD4JOiQ6Xv8DF54_jbnPEg4HoE29Ltyg5DjJAWAG-dexOrAHzWfqgTJIXf9PkOcsOCVvGP3wdwTd2S7-LInEnqXkDSgj9YmhIbZogujK-Zs0X46p2jvVQ3K1jTetiHmFF4xXkKjvVgt8k10xmX8HnVX5z0umipQqnPyIPyV4OXjoxwVnqy2EyD2FsMdPA5dUXRcHTsM3JRl01LWx-Qegtq3k9hH0frh66MiW7864Ls9svqt5SPg_Ygx-j1E30MMyFmnohcdFoH_GxcJIVAu9UM7J3MwKfkdU6Ly05o46r5e9aH6416qPPOOQ0zHekilxmdr09jJfXFq7lbD_emvo1nl6x156qaaWZz-5pWr_U_rm3FtOFby4YNbFyv44BbBkKEdLzaMGhPcaMR78roxtxM2eoPnBDW8GQgxG9FT2On9GJGUnLdo2JZqEqeoCvOKgS5WZS_Joxb4zNQwRQMzuk6QQ258YgMS4-nzHQKYylUpBBtvmVU1vG57DNv7GzCvaHlza4DtlrcLq0zOQavErYjPNKCJPtytF--J-VxdVyouRuK8-38i8iBjGbWdNv6NPTPDbcyEa3QncMtiTMz0bQ9kv4YqeGeL3ZtZZv03PYNz-Bz8fOZQI0fK64xwC3nlJscHFmSejcWFptGzSIObwsW_vUyMARIHI7C4unEIJ8aCTf5K7TfGSrE21Si3T77UVr81oiIhLsUaziR-rB7Hb2sJI-lbRr5fEARwjQk6iI5YNB5-dxIImIL33ElQ4aS3KXU9WIxvPVEdXGIlsLyvjnZH9FRV3I7mhu3IPg-8_VTSDQrG_Tqsjia1NXjD5G4aW2Z6r0OFE5ktFNaLbry-k8k7ROrSjrTdA9DQTU3CNuo_PBv3XqNquD4fkd4n445zqghauRcMdyYty-ZoYJmOPaPhMKTfE5NNxdKCe5q1XzVe42lFqf5JyD7nMJ98QDqCx9qS_3Jyj8P6ys8Tz0YYQc8I_5-cLYBPlvd7gsoEPI0dhnXp0U4rD9a7e49aqXeO_mbMhirlrs1XjbaXoyL-f7vor-zzRF_nrW_hdcDm0ZxmIXK45aPDwW1BvwuAPbknrpaGVzV9GFPLzqShieozm4gi0iWINjEBfMuYfrFupNaDd2DzrcqCv4obz_uaC39ppHRhtk1cFfEQ5hmrn-Fd01MZPTKDwZ5IpGfhgqr6W7B0TS-GmA0pXA9FH0XV-E5a2dGpb3r0xwIt7dmAwD0dFcz860evVdbSLkBhsydWow6emymVoM__HktebW_cleB6glQT9_6uwxwDKNh8thyoPLFYKCRo2Wwi0TYSuIScGYL2OTz_yzAi64VZX_OKKTyVakAIIs2Jajmk82vOFlBmktYYFvFaqmXi8WI8ghUJ5Kjo7jSWKSiwccmCBgcv_4T-QxV64gzqFKtdIB24sOFw2LpUfnzQaXCgkrKE0Oj6ycucQj6yhNobPL60Bh48QoolMK42fqn1ucOGn2MFWor4YSdMe94jmVKjSYrqrPsG0FHuNHARYNrGQ6sNsYJTwY5bIpBKshd5ReUMzSnj7aSaCzh-UoLWGlVf7EGGzNwZTW0Xe2jL1QLa8mZwh7ArAG-B_F0Vqa9IpYOr1F2gaYMdcMNJ-2Neabt85Hv_CD7f4mK8U_kMp2iNZwQMSTzcWjWeJ0-ka7yB4gbw8t5KlDXoy05IxRdpJl4bEFcZrOLHSZWsBvHp8BpvtQE8BblARKM3EhK3wXgGuJEVItlcRdKchbeIHm3-hSpAALcHczYhJTEaf_AyQGpkD_hLt2kmsJfHGQkKxvpfMft_Vg5i7kXpzPHk2IP6C4BrDAx6lt_t78cN5Sl01NPSpWxQlpmyjcokx9NrnPwp0JJPY-gS6ysxo2I32-DL40uzy0WtWkhIGAHNdGRRkoAeljbd4aeYJ3dMAs1SndjvwzXeq27KcDmoDMlNosmIcf3-MSXO7MFf0_ABd_Ri-deKZkZJPl-Vs691CCcALXwxc2zR69w1R4NEr-O8wIg0KfAZlBA2TGBPfHpxmd5wiB3B7CdB2Ygd1M2X49M1mqGrEDHPgNxjsLr7tTdtjJmO_T5IFTpY05l2ojOW2GVZ5ub6YAsTzxOZpj_llnn02VHbZ-57-yNk4SFqsDhfnnf55LtHDLc2ygmOQJZrwB4XbyvaJEh-41cVWovY7iXH7EH4Tz2c1uMv4et2b7w7WT4nOvoU4rbgVw3W96IIbDTMkcvHgZkMqMmZ_d0KMqaYsE1ppHycJ-95l5kb5s9a7_UQbCPEkt-BThW6sSpWXhjN1uHpgkOGPVIGtLNFJ14vYpmllkB0JuZYLxtMiebVJJFM4b3fJhLnMdP7VirjEv2jc8fQfDF7Zq-9F-CJaGv6YF30uCDRnEa6FB8lFEXkcFfz0k6OreOYruUNxSbm17VKTYtrSf4vLOlAnUyNzKAy0Di8zbxOpGvtHgu0IeMknPvNHfdpGawF9cM8lQp9vowYLdkQ_nEkmBzPowQs8zFcl_vI7jnwMQSJUjYD4agExJwrsDPljRP2o0tU8D6LLSrUn99pSedNSoOx4lVs3A-SdtQn0-4E_cPfeIESMG_BythvfFoFMBtbKUl0t4Tqce_J_h82hQ9xruAeCwjvZeJRUD7_WyxS_dDWTtBvUZcIy9CphJdUQMQRN-wavZdY9s5c98naWkpmRBCGkd0sAhJaXFA8oSm4IYVNU-iY4TGMXi3i0r2DqoCx2Tv9_GyHV6k3vofzg8XJD9xJTYuIOC9ZcsJLYq1ebT6QuUtMrH-X2ayk3hy7a1Iw4ouIYRpZlXy4LJ5Fzptw_KEuCZJ-SzQq5BGpWfnusMFbbknrDIsJMzuLgZFbZy8o7fLLRyE0GEtS_eOof3YlzHd3lzyP3lnaEau7BA0taw-q_-3KBgbA7w-CUqDGsym6kQq1ioljSitEqGOf6PiZf5LKvDKndDUcMIwhagVrudL9PBeaIorCB5uo8N4ZwYcjRaJWQLdPknh0t57wy3UaKlmNJbNDHEQLXDGZT8If-8IUq3jtKDXRMLROR1pH2AngGaRvlYvRgs5VfN5jXs7HvJN5rEwLyGPy2ewElTBr0piCVwLfXhjKoxvJqYm4mlKxO34HDVP0EoYEWw_QK8-xibSRHr_zApxvnkGkHXpYMZIp4hZT2me_XQ4Jh821D_X4W-Qtcrp_ECLvTjd-pL5T8ZiDUZcFn776Th-bgSzl_NTd8ImDCYA0D3ePzLp8MJWSJtc5hE_zV50kaB3Ca5C_M6HfxNdT8qDYQs8LohCKXDxpKaUTGFMjSYT5WHbXSBu9leEg60-cIaIbyvL0-_g8Gr86V1PbQVAOLK4ZV2jB3ev4wu94TSFmj1GVEZg5lWirrhAaTMWZHaZkRPrATmk6tcyP2WOmh6qdqSgxYCUHLwi4mUqVsgS3XZ9Z9LPxl3-ID_NKhtxf4njiv0wN8DL2wDiIA8UYiznOK7rhQTe1YHHyxEoOtNjvpJJUK3LNhwyLKRnniZsaQekEjL5F5aT9bfKDLwXeYs7xW0NsVo3siX-hLPSkDETvLYfih5aFiRQ6gVKGdDKb5kg-Y0tMkA79COpJgK3pD04VXtcDQlwNMm15s2QeyO3wuUbUFFQTATMhNjj9lHKZTOaB0-tk_7a39E3OHz9WyFnGamz0iZzrSbh5WjSPZw7b6Zuc5BB5MlSqSDSQ4i12SBPR-ieX0VDvMKTwDmsc5MHYTHqTbqCD8JotSr9wkrOIBuB5tLLWtyJHZhY0-gDrYk69UMvSdQ_X_XdNZNrb9OfSwyns_1tEUA_oq5AT0dDnZ1cubVSvxqxRUXV3KgTjlwonuWSa7aF1all7p9V7bBh7T3kMebG24qvcH5EmKJd2mexJkMVBld6V1K6JNZl1R8tSPDJOF9I-L7hPA6bVPrTkTuulUw3jEKFTtsuXjj3TN8tI5rffT2j-dey54kmA7MWmICcslztuQq3g9VP0G64ncEqejM23phybJbOWG93paB4i0jcky31fEm0k0223ONRLtGrE7DD6jjF1IW0oU3WMxjUloQIMJOWbylJOyI4Pyc8HAAvtfu0gw0HCtv3R04PNZZ0tOefabf-91qmAjWo3cyr2fDEsp7MVttaFQjnLnKULA3sAvmu26tsiW5MpAMQ5xGveqBaAPcj_c3ujE8kVj4MjNXxIhURlB6HLj5phJw6uulGAEHmMw7Pl7PcLbw== \ No newline at end of file diff --git a/backups/backup_v2docker_20250616_211330_encrypted.sql.gz.enc b/backups/backup_v2docker_20250616_211330_encrypted.sql.gz.enc new file mode 100644 index 0000000..744d470 --- /dev/null +++ b/backups/backup_v2docker_20250616_211330_encrypted.sql.gz.enc @@ -0,0 +1 @@ +gAAAAABoUGzasqXT7Upc4hUrQTruf_2ZAPYIDCdISy7YTEjzQjNp6RBW5SieY0hHfBZmgXYm15qhnf-LkI2_DGxHMh7Z5UAE7U4o50bxwL3Oe8muv-9lS9TSsIua4T4ULfFwfR_H0_-Qd0gO4qTOg8tuQoHT4nHKygejQwM9aMXFtPnS7nlxqJFJx_TmJvSQBTTR7Z3pCFsGQbvAI6HrnW-66s8a_2AM7gHodrgbf4j2Y3jTaDnjFSawnu0zvZP6nnn8t1OMnmKGw1z43eXdVraY6kA8-biaB4hXNJrctbFz23cGuZzA8paeF88n1QDuLMmgktUvZBMKN25JxWljZnMCtLB1AUa0lp_cmcJ8F1ui9z0sB0j3MBIXyGsJVxVS-1ssR3e6QrXR8KwYc_wE4AjcJ0R_W-5i1teZVJjotFF2REgiwZqtK8-F053-1R4y4q6Jc3zv-h4PGUMsWPRoKbvppzavNiGuig4-Y63YWLY5FwzR0LUOvxW4xMfMJARVEoq2YzRk5f4KWAHorlPicVGGe2UZrsouEq1kBOG79Trhb54lZqKqO4RgJQNZ2y0NEiMBk7qHelw5FQ6CWoGbtJ6tqt9gjMg5yFxlGZt0OJtveZEGfqROffnNiXx1XVuyPVCuIKh745cU8mhCiF-tDxiElDUMn59Zisi4COh1vPEOLwou9kkd30Thz5SOiQNOs-njtB9UYcGAUGilXTO_nDW74u27FTtHAN21GnD7RfGOKBS0eJ6Ge8GCGRDWO2Z8uq1jwC53MYO8e_0caysfKEtBvdvsLOdxCh80WbgpBWI2l5kD5nDJenP-TeOic4kq_kj4btAgYcMw3vf_OatFq-eVnhDGnnN9NoEti-_eTi6pOjOXJcTRXt6tI7d86DYfa3gif-hMgCVblPrIf4nTTbsLN_UcNhdiCIqJGlw_OlZrpquFIK5YKlyq2gqP7bIcJJWTM0usl7KZBNqMA5Xk-3WbEuKWlTJs3MkUzKjlk_6Ak7MmGuDBNPOmJ6J2HCWCeHEl2SeA8JQOAwawyeDYUnqDFosU2uN-wU0jxA9sBPnh0tik3tJikO3mQL8HA6_WrgW85vCHebZjdagIETzeo28Jm_zL2LTdImUK9khqDLGyk5lwH6crtcFlxDW4yAQHntr8Bqlu8UM2e8VypDhXZCd8jt-ANcgh1jmivpw9Wo-iSWvZUvX5tSh0LWtbi4HDz4OXvNspyUu4Zl7guzujuH2UQT4a5jwUsIB0nwW4BhPD40-pAU--hz572QIGUKF4lNGr7_-YTxtd80U2E9nrsXh2go_CF75GKQp52sNNaud2utYSoi_WauxvZMt1CxCoxcX5DPHnem6skk09ICU8c-9f409PyCB0pszr5WE4tfRas1sPR6MckRHgRnKx4ImNitdvi-UUApyRLJK-qxEn_ACkybnIzSzGU9hgesU5XbGF1Ce9NpAfReSQ8PF2We_-JjtON8f2Nm5SFY5wtxCITnIw6sTeTHOemKlJ5qwg3MsEbWUpWLqhahOfs27_Blqgy8tNsS9cq_tUvcwn-IWBdtjBgZeh1jSQQHXF-j-7WFstsbxFBw1JxklH_1Ghq-kYh3BhW7UZPhvfmYT1x6KZYo7qUNTFwH0c5ttJxQf1R2AUAF58XzsA4J0t12w2uH3z_TX8SEDh6f9jt4x0v0hMsyPNbCV9Uqtl-2o-rNW790djzZKdbyd3z-_NuZRh3DDQVoMEDmipgOBO91WWTkvAe22Pe7BcUh1eAkhmIj-YcqwQdLU6W2vnpihRYyJU7pufggJeFaPS4TAc-ReDxJHeLh5tQO0UpWsPB397-5L3zKx41vZ9Nm-JQwOX4N033Gm7HZx3r1UkzOPmDBbPyEm7QOaL_pHpFNCk4WS3Ftl9hKce3-MskiMaPmga7H7CG9zYH8uGTA6eJA6UI3yZVAiTg_0kTP90KZ-hDT57nPI0LNU8af2uZ60jn1RUtcZ8mjRccGI8TDxS9E6u5dOh2wtOGnNXGqPTd7X7BF6RaW-XiYA_xefU15ill-XINokXGcvev7VXOQGhbqckx4o2d-IIDhOwFI9UmyZDVet3VEXlCnInCM-FWCwmhzPWMNWeVut5WAwvm_nmFcj4DrptxfiR6-8hjEPHvJd5KZtHHi_B8akFFTFp59a61PGmJekKr5uPx0EGTEWYYp2U7duhCWkxdnyCITxlhUvOtE_oC7FhRfWS4bCmDc3YCnpmOnkNNPPI8L8ASujsoGeGSC848OYRH9hVJNmw6yRQWu5EeWySxQptRVmaJrC69GfSBcB4nARDz1dFcHjFSkrpx_DEaKCGpUSmJlFk1RKNGxNKNUWU1xodLE7LWz5VpAeiais2bLA05YZCbR-NAEEC-720aLpWVlilbo3T5vs7NRfqNlsv7ZiJHLl-D_mkjquZqg8g8jQOWVNS3eqtcnFZlr7PEHLuXf-54JEgVH3_pNmvO8I4Q0xas1s7I02mQDSRlxrOG0H4yVVsDWDkuTH1YotSiY9aNQtx6cwfrl2tVbLZAQwO0Hpt7z6vqQsEJZQnyELDKij3CUPM3c0XB2W2PWke8xM2q6XLoMhwRBJ7SHQ0NgDhuX_CfAERmLiHGpQbAFXPcbI1GiyrFDet_Sfcnz4DwR-7L-qkMgR3dGbNvbf6XfBCUml8hknn4LxQbp0QoVhr2cCgHFzrExFX5ZYEf9rprIDWhU-MW5Fqex3a0Vm4I_o0l-Uv6tm6I3-9KwE7tNYTvZcBodVk2QPdOZxCNFvdGEB7eO200s5wc676IhUvPsYmMWLqTCFBQp7ULKHtrPVbF_gCx9q8WaDzf6lJrgWgg_bqxUYogdvPzPyJUgWwgaNZu6sEDP3jNQtBxUEzKIagLK83FBcDRjuODURqFq-5kSlLmUftT0dSme9kiIHSod6TtvrNOehHE_TlvgI15qYOOtXdgiKd_d2Egqf8qR2O64m7KGKvUVmK6b9s_K1-zDIiESVaYmxx_WDBYm-5vVzxp1t6eoOVncO_vQHhkOUDNx0kSJWTtdia4HhargbFShBvvokBKnoTqRKSiWYS0dkC09Ji8Bb-GRxns0-wzulBwFZ5pTGJBC80si_0vhbJJ19-1t18L3s4JSXxZ0qeCi7N7PV-JrXBEcbH0q34mUYDLbewRDSfkFiKz-B-82qO3uqhNAgOI3BycqWDNRBea42EYKTDOVdjjK7NBGuI1F9Y87iNGMEwV73cV4VuuNz2fZDt0oCxd8MuIo6IOTd1tZzh5rkncNuf2qCIYzAK6t7FshEDiH2gf1u_gAnOGdElLcIyaPnffc07Eo2FDK4EDcLEqOE5VgZq9FmAUXUy-uetUUU4wkrwYcESfqdlG1IqNjqk5KnmAivaGT0cs5_kSZREc_td_38J0e71ypvN2SIRErkb4JhHMYdOmsua-luH3zm8HBQjZ06xoVCgdvQ_s_teocfMMlXPNLRdBBkBBNODBvuoQaPn4s_BmJNM6lfvkohFqtOReDlKe3HtH-5XCrWzEh_v6lWM5cbWgLX7xsxTsbmt_dV-6AGV-lq60MXxD_xy-nfXDoxwcFUtNnA12WTp9oumaitZvlUFtipPVovCwfvhdaPlNlExLYiEyWhAVMhhlWNr3C4riM0-WettVcfgWfsfPj9th2rIKXTtf19QiDHFqKUpsK2hSUaByRAVp4eVk_FkHmoi_vNqyYIK9QvgufdHmyTNsp_JeUVZzQgsrzqlhQ2ZzBq2zCxkX1OTDuTGAaKlLYbPFUAuLCB7vD3E8g7eNjf0vGjSKymiBkyM2zh2GLRcy6ubCc1E-O97F2Q7AQj9Gz_doCK4WggXZKvFsoWeZIcwnmL4vzm6zYIY7SGqwOODRYBiz5ZOIR2xt4a7G9KYx6yVFSrDoCE07WDimWnWkiFVaNM_2wRs_ajC_eL8zPnz5iiorFJCAW0khJbhhzMkJtVXjAoV2d3dXNuXGtvIQZPqrJ2LSuKfCtTetXRQn1X2s6EkDar5O8o49BjDA318eGSpn1IsdnajO7FFJXylNtBkGltBs0V2jKg7-UO9nblOC-ch0Ih_t77z1LJNbtD3r5-2NJzqUo9s89B0SoA8rNzr72J1P4QRkZ0MmNzhyeKcH4XhrMKqjBIiTOWCzFqHJUauzAMGD3Vntn5uR5vTJF0EV613fTj6uipiPyQuktxKCIQdhF0XZwdsZjv7lTqSL46VKJJ3pFTzU8_6a-DT5Se94Zujk5QcCnCn6zRbFnEaqYPbkopjTCkSj_QecTAg602MhOnxkeR3vV7Se3uNDsndlWYAIM88T0SjHPC-uWYwtB9cy2uMlNGlzIjmG2vh4YlvXfgvO-u2KHOtxS5Govyxgy7wjh1CDS6nRXWFkTxYvpfV6Z90pVilj5hpujU88DwMT-FEMf__gtCKVKPR8FIbl7V9KF7GeTTwFovu-EpqB_JMwrZ04YqJTsiv0NZkpa_bi5youZpWk7KXjfrvPVG9sRn2js-yNcfyZrO3AbC5wE4Pi2ZK4hJAiBF02Odn-JmQZpS1zf6oEyRz7j_5AVcbJ656CrMBvbRF-a2h-M5PxJNSLwDZmbEwSN7j0DArLEa-lJYF15agEfK7MUWIlwlb-zXTjM4WqCMoLFNWkP5dm3de1VsRbu4KiFROrRV2jFArUrk65hcxRSyCaqISQmBGt9lYYpZRbEvttFCwFTnLR4wx4JpFMSorDp84-taCL5PLcUCr5Dg2QgHpVkNRNjMN0GdoT21__HEIyIMF1gOcZ06qDqMXv0w_cKeKwDS62-eSyotLw9k0oGLs6beaDRs7olL2gasIywvuuyCtRqk8fXfHAHhKT4JO5ll1Gb4l0Q1na4CHjePALv7tNIDw3jFtNSYysz6yEMOL_eK5kuAQPlEUUrgYvabfVdMniyHJFVYiP1ST7V0TUzMpfzHqcVT3AXo8tPtZfLa9pTxi85TQiFumisQdTe3bNrW-LlJHhvndx7illzXyX03-HOZyOpqaXXxkg9LtmqhdnMTZ9fbGZMioPjpuVjKFCo23NKg96gvzQM-5dHkvqK5sFdYL0vZWj2-KdQop37aw8UbUeX5KbC_rjw2mtC5VXI6zvtQ3Elv_of8lhxk5afim6W77BLSiNwMcYPqX8qT6BGKlb2NiVUJhCRSaBrfWR64OGYJFYM7UNvwrmdDjWlTauyytqUQZPw9wZpqZ9LeEnDnAdVF7hsEFTQkaLq80YG5d8QjFci6Vdfi839Vr6kIXbiUjRPWTNm_1M-rW1V9qY7ylww8GSo6MqiJ-rGzJzo7gy7yTxsp_WPWC18I8iKAfr-VgLtmcyAu7EWeSoJADuBNqBB4O-MBViA49Bse_x-vYhEFqEyRl7JpwTRQGxLzxEKl2N8FGf3luWZan2EhEKqfhCJOxk0HHj_ke2dwT54eHYjgwfD5I7Nep98yKrUkWzbotnP7IVnfcu6tT7WQqf9pAhV4vUn1GdxmOXQ29dW1NUgZMZBG7S7nh6hcHQQV5OQZTNHWxbOxjuWN_7bRMtVpIIWi9tcPaY2j9d3-40O2pso6GiXK-1RmtPWqZY3SV6hhLAhK70YVvjX6vw7REr-jzp1xk3kH8pD9WfVa6i7mquI41YTF0EfnUb4EUqwTG-XGwW94D94Y3iloMJzijCGOuEMqUEdy0eFBxqqVn0njHOErQe_UjJq1AM19OAnif4UL8Co_nAa6nHzxRDUPA9xKTnN05TjOMJuC3cU7elSBAy0AY1czjMuSTLPlbRUwi1JSy5H7Rg5tgolIWPRYjb31Pwd6-CSMmw24EyxuCC1ylezbprJMEwhytLlTEZfw8v2sNbQb-E8ONXDVyDNFd7Y-I_ypUdodP672IEvmh4WoPWr_XSONC9wZv-MdJLleGEfQJ00GTrbGvDjnLVdOBMAxjQYAQxqD3cWPfq5VB8UbCqPWnOKv7ktVOeafQgTsOFHvS0MgZ6USqkXVKn_rxzwQZz33I1oxAhOXJ1np58zajXQp3QZiAeG8AoMItbuVb0LFvZpkLD4cx5dPol8e1T_jgBkeM820i31Y6a1q_9BqtppQ7myGOr4zk9BUs5BW3Mwt9eMfwz0rWNnzTm0XXV4nR1o0yzH6jElnrOh6uyIOKpVSO9ihAUpEzlCDpp5L-7P6pwn-dPJxnab3VXZnBlmravihjNe-buALduv9SJfpJCA0pFWnSbfAJJGU6h1lhloG7tFEX1vXB99oKkcceYln3k7SD1GffUF9A94Vz05fnDwTCiBtVCtD2pN3oWsIPUyrMIAhlUQ7WR-b7WI3fT8h82PzL6Kz0XHLppnWGU5J6Lmw65scVZBOx3-JCQ48sHU2TKQtTKnMhwSbh0p8U29wP9mwzMyvSk8HNWILC5-24EiOgYS45KofFsaCJvSfixVbY_QFpuxKVsceRs7Zna5QBWMqccq5LPTyge_B1rXR0Prny8RnLpCy6H1o7SJGCtq7fwxg19o1iucg0ydyYO5pPLBXlNFA9DKV6horUsiyUQFacr8MvVik4vTImXSovIeaREjgpK_JrlbEXhVoxotUzlwuqy5JmYRniLQGt7yYBgExwtcw7_gpRyaW-vqdW13sjGFi7ZU_1FWNT-YuEqcjSz7GKvaYF8-_jOeEZ2Oo_bGmJdm6BiFdei3IVBgqmRVXWpQVxzmNAWgb4BAOoFkLqwGJmUJ9AXmh3czQPj_fd7dbYtS-L_zow_g7GPvE9RluEUMSYfjpmkyv4ppIsR_ItvYe6tcpkykxEeNCA8r3xOgogR5DdMiD7JaGZ7h7LPa6vfeiHRiQpI5_yJLI8RKtzMWNTUMS4vikRB87piPOKX5KDYyjZeeT2YMI173TZ-Yq8wF6c0-JCe4huy5FqZBUL5QJG5SYajscyQFocD_WIrMEt8QrTQqP-bdtR0akUHd2ruhBWIv-ToHbiaoGWpGuEzhOWN1gK2S0rPYvNj5HmcqDGYWdVIOl6elzebsOD65AgFYTFPzMe-gKQdzCxiNueaHL-w99_UTMCWd-sWXrzzynANy3KP65Ob_CxCriEwDGfXln6vMtE6I49KMGtmcD-8C5jM_H_n5Xg-DTLR7EXig-Pnl3EQOpt6wZ1kprhAwszafgbjdU9OrezILmHXJu1fKZ1AX08C-TLGEaMbsZ3_WazNDP73k7j84J2DQZZemF0hwvuLOh4Hk0VuO61LXWBGY1js2BXbdlPsd34f9Ioa82-tz_FWZkYYPJKMskRwOlMl6Ni-NnZ5hK1SbvykEtfzH7BCoeyecmx3XcjFnvYv3ZlWxue2AzD-rRDgE3tKYHVvVIEhZwCA9lMcPUdEXpSEQwjim6EPMTYL7pwYoioSD41t5HqG_5WswJYDiibg7WZLJ27d0NITjtuRsL4UHWd849eStzRUJuRAbSZj5eBoiXJhBPHVSO9DbVP6Av-sjP-CyBI-YDlVB3rgJ2JQXm0d2r8GdIXdRbzh_mdO706FzwHYN_hXBTjFFS0Ud-a5FFc0X771qgxp1iOw0fEWND4CsvF9ElKVWiKHyrxnyRtpK4Xj207BO3Oa5rk-qVi3adQY4jyPCk32gSgaaDmlEW83JzXOcwZ0pVeNYFCaalzkokOfYdBOD3Q7wmLh_zUqSvlp8TN_fBcOwqIwrGTRmaCRudXAa07PHfe7YsX5AwAdActecKMHpDB7_TAlX8jfc41f1iNKE8ZjemdpErXe_ew1OP6grq9P0RbMwWQGEJi4Ji2R4oMjsjMEUwUFiMUr4jQmEZOcaUtcPf2ZNIt92F92EyXB7CK8ZwVW4sLmeMr0d5jsGB-PeOhu-AavPxv_lASxGE8Lz8l3hvTM1jtkB8p8Sa9GgIBoC7Bk_mTPy429jx9rSzj6Q2eK8twd9WdrBSYiBYl5-ipayQvWAmqZL2mWwP9-Crf23jdBH6U6RU47pN_bqRRYm3u-cxqaJVt9MbY3T3GZZ9W3czkYJyNBqznHO2k8q2MEoefaxALKUXItygQOxfJ2lWE66mhEu65fBMhk6P5cJ3ty3eXlgqpTLtMKL119rwd9lOuaaiT3nSTq4ngCh0IweR0sUr1R9vUAVGdif5lpT8rxbglII05XjTRP6_lHaZu9vB7A90Hv7SPbhNVxlsiWzCA1JOOCmXyW9WTSEBTZc7I7SpQG0ilxy3CRWy1U4zmfFUn4xs2A-5oUYdWMYRvaF6BzeYZzwbpc1xeXpgMMjJUJIBO8khA0E2PlyaPDujzyecP7iJuQHZADUi38qiK7f8kkiLdg9ShkUETjHwrRGqp4bLUyGknN9YA57tjVYVhQEdCX4ykU4okNFfquJCgy_yOsh57RircO4VedGkqHeD6bczTzOqFqnBhxiv16rQSZFSdpQ2R3TjeLp3M0x-R0qnXx9dZ_iWqW6D2nnsZaslWfZ2sV0NCpTflJK7kWxF46T4iGD8PXSlvxR6Cn7ukROUpRqcJOaQAytcClWSl91tNw8UcczPcYTVJsL9jUbpIcbUUGDFjvl8XdAsVOwpdSijCNmXBQBYXMLMgQsgblm9pAbfxZiH-lzlwSjRHfONEScWKnihXaKi4LBDp5Pd2Izym0-xlwj4tyHCmh5lL6gmI4lW37HziCR-HJqIek34O1rvM2RDmi7MgO84XoANcMJQDvS6vWrv_WYG8_MjK_HEExXSkqrDYeKiW2D8DLFoXXj4P5iKYWOHVAxoO1x26K3pH0ATvNwGzxTPcHpH0BJklzLjqMtwbimSO8KCoya1qWSoldmPVpcj8TW6pAjkxb37c2k2FQJcroTWdcBRwTMHX7jNVrSi94-zZRLiIUHL7SHeyVEc5531bq586qYOzkLnkzlv3ply_BJBod6-qvXDr_0u_PqeZRAUpRdoJRE9D6dJx3DPXtbVUD7qdLMyPVRpSfyC4eZ763rlMFUVVOV_jPSKM324qttpY04vTH1wJGm3-aH5rkz1BvXpmWOlMTIRVLQWSsNNwGeTMPy7PJEzGKir_7flWd_Ct6NzFyF1n_3gH2ggIDyMSNX91z0hT4A4PIGDfTnm2dm0w2vBdN5WD_75ZO6IEyKJMOP9YhYBWn8y-2LiVPZgNVWxPh9uiubM-O5mpBIMO9-QlTf-YX2UvxHcx-pYqYhGfbfJlDTrVvQbIvQzXVmE3HanLag2pPMbQw_9glDmeNlDlHdxvDqY2ZH6RcH5qSa8aucAJZhWmmThNSgkQocpiXPFMBJY6yqlL5vl5zaXqDkNgoDQtr-ojHuWVKg1dEG3_TAFPw3wYXTLCwQvKpMKnjz2bxFVOLfo6zgWTSpbJntXthBaN6cKgiMGDb95k7NtD1Q78cnrkvQ7thWhK652ny4KBV7S0Wzp5V55Og8xfy-ti3qTT9e1vzwa1zrP5VaCZ4q_3I3ud2DLyD1hOljEvP4p9FoT8XmGQthvsUTPzEVXKIBfJYEw5MblXNY5GP7EaMYqwPTyXkoNviObQY41GrDazpoTtuxI-Q_XkSjtNk8v35GootUC1uIz6VLoF4GjGPhgkpr3vrMCRXJGSBZlsovfDnRqsZBLUQ-Aai11p8BRvXV31A4K77B6i7QoAmNszipU0y69WrAf3P92xACr53aZWEgOFofhJKeG15rhsq8afSaDu7nfo7_pJuvKAFVD4YZlwj30uqv4JEoaj_gGvlAONtD6yagjODJdKAXvbsgNByF52wiVaFyuglyokS0d1i3JY5l5hFY-LwPOu_exg7EJ3ZF61mcjzxJUGqQp-Gcy5hB1RU0IoI94YAkKs2O1Bx27ejPbXTpLEuu-_XHw8FpIMih3D6tWfqijP5-aEUXJ4XX7TgtIG_Jc1yYfEXWP3EA1w5MsHVyCR_6Gymx9iIloVz63Ybt5rjThgV2kiL_MwkZEmbMiR50aN2EPnF-j9FEK-l-Da3mtDlyPsj-B2LnMjbGadbqMu-Rx6l2isUUIZ9GvzwBNY0yZRg0KdaA8S8M70WmN1TVUK8mTEiGq9eT4ZXaQqNh5W0FJqIaYXXccuhPvU7D21aLSC4au0ubelALFWYSrH3bex6g_XlCvw1PTyFqAZvAWL1rLHAUvDSLT6C_PhB7cCu-URO3Svx1wik4Ne9RiPm3Y7izgw1ByuEHAmlE8NF3nR5vKqBTsAuOX4QLmtlygmn1JTvfsTiV8fgYrOoOHvbJ8r_evNFKYeBxBFzKvZ9NkkFFM4DHr7aX-Wiz7Ha-k5k3-_ngzavwhnppFPfPlF2LYTiyxE1NqJJKxXvUR-q6oxuQYrrVm_FwCagWdwloYHyZqtCXgdmV4m2WjNCWqa7DyglTOoA-Xi-W4_zg470ckERIZEADT3GuppCF75hM17aoUYSZSM8nWsjzkC3AvkQAi35bRbTD-jZ6MvpsSLmIdjW0xH3aUTUZ6kskuGgwviT_7DR7qwPqHYWZF2mmCU8NpKZAoaZrHBLs8-U2ZZBiI-ghjOMvJ-rXHmAkFJCjBxHY92ZxGDas8NIm0bMeM1fKZyV_72PPRbFgzSZ9mFf7Yo_e1xz0DnMhCPy-ELN_bSqizMmsjZ6skk1UJZJIZM2oWIRMif9v8MTaKYkrVpCg90XEdUYdqp0_IF_-VKtrUkBy2BW8WZGUfQh1daVy79PTspxpWY9KBSMq6xEyjyh1EL00ktUmZbVvSQHzo3Wn2_7UHyKiXoQc2o1ULAezs_5l8dP0Jn7WFHQ4RT8STljZIvyhghixgHVMvJHOcRRWONiZoH-Z1ARRR5LVXNgrp9tG63ZRgJyhW9kp6OUXLYACA2Xzisu2ldm2UKEVuTlFj4TmpV92pA_8JUSWCVod-6CilkiF_buJ_mn1PHkmyfZSLiyYoOjv6-uy8nJg4gl6859Cd5dMzX-3BfJ5i4UdkLa-xlqZZxr-d5eqmo8XgakrVUEPFfoHOF_444sBc_mR-a85qol8OUvzl3eH0yvWrtjMN1GHjfOZAEZQbUoMt-tV_szbsz9-lfLoBPYNUnOTHVmc7l7lrPBE0ZyeXCBLHbVH9rKXdgPITUSxoPJV9pD3SL0bU63J6YvCyJLUJQUEslUYf5MfHqHTW76mWU11DJUgQG7mhRX5b5yGsju7TNOSxb31cfPCAr8MGOZ5UizIP6ngKM8y5cZ4n4NUDilQpDrHCbuUnuMGkO6VpXqFywf0Zk7B8AqjTuakVyKMhr3s_HW31tzHnIF7x1dCoWZaZusrmQbiktjUE1trr1JaHLRv_Fv92ZXaRIeuNGI8eU3364HuzcMWIDkqOEYQlSr2R4ZB0c-XAOHmYeAXjYycKmKRHdDAb2Owu3BgVLU4Ew-qPUcY0Yi-mCRRZjwnsIPC7nn4wwRZijovjMduRqR6LCz5f4EHE7jad-5l_mtG4p7V0f4js9TjFDXQ6-j1qYvAx3aX-qC8aFFOj8LSGq-bkGyYkszKce1tJcvORsRi-tSPPjREjoaqs3Dk_wqNanprmvLRS0XS1XkLTKhGggnKNgpq7llIIvBGg-wjRM7jM-98_80JrF62rTSvriRR99wWVb7XLyIgA3byD8UF8MXIjci3aXqFxOvN6bU7G_iwaGWZeluLSsRoY3QtxJc1n5k23SNHLN4PIthdjKvgIIeI7MkafCHYNY7zNxuA1FQGEl8VEM1NbB48EM5fZ140Me42AKot-LSorgfSm9NT4y6y34YGVRnDYfPRvecTwNNeHKWnpKNkHoGPoM_xmen4p2tmqmNytWjJAt2q8FR1_leiJoeXfmZ3BbRc27FudLr3MD13mjj6DWdeacK-eBAQvYce1SYrrwlpeteTYL4FVj6Dm5WaCMlMzg7wbiOqf09n01nTtOXqYcXu_2G0YvC0dNP60zV9xAq1OZAUABVxtuY2661dE4pqWXK74BKg0ZezRRRaMGzFR0Byeao_XMPh6s4lbjVhPxkAZRvjElC9z8amdmQ5RNhW1r_CFMQt-Wioa4DzeijPhNV8dDCq3XmPKIOrxgrrpfUCzqTZWzRtQd5kKW2kuhEY3nR5RP897BiJIj1J1Fnec049y1DBIb089GDJQs81JYKqqByAgdvcOX40JgpqZ26e9R9j1Ad6mUAM7x_Dw9hwPcVzsSwj8jIFVduCX-ATk4oX7w7NabCkZSnUdWeLoNgg-4N9dtYZKXeFhbEsLiMP5a7nqQIK7a2-Kxw-5tFRxjbucKGzcSdqGq2N7_RHmYrtyYWbu-J_zzFailvuk1Va6VxyL2Zb7-JJ1gNMW6jNnhlbgdkJfnanbVcrxcfcDlXcDXSjkjqoB_b-CO1ZgYVHtJ5WMZiybjODtzI8A4Lx9TGMHWmEfbGEAXb9wo1YjRjAaq_x3PSaZ8wvnh6v93ntnFa5sPecRz5kwApvLwx9f_iYSPai1eCEQ-DHg8nV8LfLXpUwdfpjDMcTyYAsvH3GlkNZTy7Bu2BQJ1yjFljHYjM-NcxbTVDTB4EPRJoHed9fqe7L2UAPC_PGKkB2OiWh5LVTdqMu4xN0rXPmWuab0V0qvPJBBKMsIvmXbsogPGpDRD3OZTuA8eK40rL3z_1NIvY5AiGzATA_90sCjjubWjx0k_bV-uOK0OOKNvnfM9xu31JbnN6crBDkK9iOvNc4mWetWkwfDtXH_Ensm0UvMvTXKL-_4nmAELnQhG4PCezS1Y9W45NiMnkt5gyJ6QWmKQvJnMpllqOsjJan4A87MudA8LEd1z-Zw8EjI9jYval5N3FaJBe6dgL5_OiQUeICBUa1-T_E49EpwceySCa-9Ubwa4h7QpDUjLN7F5qcY78UT9YZ680N4K3-kp3A5ZT8-9fZIcuhzcRVEBaFGQINBaKIG5Aj5rPzbI69fyCe0B6etPwtHJo3ZBvA4rUZJ_5O2TFuflnauM8RhGTg88qT0eGOBzdvaviRBcVH6xgKzHzPOSibdZ1Z1z_jMtV9exUivtpH1s2X33csO_lSeR_ak__QXM5kToV4SjiK_CbEEzmQa-tqEgEPDFim_R09v7TWwDZvip5bi70KSeANT1W5s8Tf-N-BP42ewtVJy5vbXkQdUPqp0vmIpGjVo0iTup3NFeNRqsoiknN-ltnypEjzIqJTbFSl2RuBEb-7HQu36gspcoCAZzWl5youzuRgePEa3-4UfzuNvfBrq_Ay1vgB915tKPGGUGpZjhys6X-hN7989KKaiV66qJWVmMoD2aXbj6fYlAtXNMlhXLNeBTfZK5EwsIFdcIRC5RXdSpLV38LgBSA7_ADPCfIhzc4tu_f999isnVUh2Kh97CBTDS6DfSoUxK8aHPe6hYtO9c254bKs7Gqt6yQ6GTyJTSqvvTUYspRfHqhYN3icIs6LkRbeTtXdduS8wCEgBRGMPkvNcFHesu8V8KQf6cJxxIfTtFv6hS3_DMKpZyK8Fkv4QDZ-OsxAdes90W3na_Np7_Qe-YLTpXnYJujpO-95GCM4XTEsVJdO3MzNH9oRzuoS7o2LZU_LM9Tqc-wU74ro2FPjgYuNIEQPdi329A9oty4lVu1vPyuT00yFMPepJqv7ik755pBmnCMjS-ncOMN2lPZFn1SoqDUORWmIEtg4MHtr2b_6qYgiEeOfQrmn77XcAijPnVsOk3PCF0PNNYJrbXKR29VtWnvcsU3CjqJqSjzYu87X-DMasmAPdjdOjYGABrgSeA47DTfBpSSoXLeRuccQXKqfLdXwEOMSPL7qZXmmB62pEaREBGNVkjy041CPoos6TDIptVf3WsyKUg571GF1C6zJeu6O0OLnfyEZp78IxXAXQPEvlppFbf9-sB1dDfuf_6z8GZPaFz7koF6_zDWriB3_PSoElIhE8shSpwBLGgrpRTG6ZzX56Ar-dKpw8vUxdH_riXvUs8h2E9zwo0ELVOcRbgTi-TUTd5JlMFzQRxU-NQx9shvmb56i--x4Jotv3i8htvNC_3IYzIi-Ui-ybiUTt-9HsC2Zwym7kVrN6i1cCAis7rOJnI5HmdZZ2qAvz4sUfqRHYDO8Zd0TSXRZzquI6aWWcI50gDRGQ472F-Z05weQVrIDV1l_Jnzvyh8FlxyppC5Pb_aBnueDh1VDvJitXs3rZAdyLiK5Ko8RlKyYCneOwTYMCRvPJFyhpOHVW-cSKSK7eDpBvScQKZLYB8k6njP6Inj45ZCeOl37vxHrFxvK3mfZ8YwgPFQrfO6E1C4fh6ayXg3KyXBYx4O0fzTH_UNIvNFvq49LSsfT-IKLuiMJ9hBztqPKKsuEoFE2jigAixcN8MFARZqB7AINmilkZVWqftzxCzAHc4UB7smexr_e3mqkkcBOnK-aCaxkyXNyJmXW64O4YSFO2cb1Qno3O4bNFA7BqNv-TuLR3YyqUDghK_hnJWRmuDA2rjtaCBmY1X1OA5DlZovya9oFnd_TkW2znQADUdRtRP42TQ63eORW1lNBk8W3juRFI9cU2v24bLEPq2G8CV5Dmlz2xnvFnuyy_GKBTfhymTN3z7-KzWqxxL06hlOAO-6JAeye4mswphbPGmfvvaCOXMyGUiaFVyVAinvXlexEWuoLxjlPePwSYDzZq57EqoTn49Hh_p6Nv-qv-cvB9UAjKZYjo0fKJ0rmoYEdN9VjSVDpcw562k-PI4wPd3p_Ay5kZLo0LjDIzAJWBnj2TAq4531_yFV2rP7sV1Ss6J3x-2YmjCpoagDD98yKDNN86osii3UKH0iiTfIpuG45pBxsKC0z4dBP1Uo0g0g9NvQO12TH8Aispi1FTajKbHfq9cQc0xYnBEekHzT-VGTMMH15P4PZ0KI0w956O5qjxVJpnprJGRDbV4oxhrhNPFVtNmN2ixKu6XITpWnQEsdXzdOk7r28F7BTcCdk7Ja8NM1rkmaSTYOn4U8mI9HrEPlCS2KMXgOUDrvqfSB2IYOxarHBR89CKs_qEy-WPAfc4Khb-Tnpc6xxN4zNjIKixcsAopFYBwU9TMqZ2Lk-7_p1Y1UuXRuUgqBRsrau14A_eYdt5xVmkCED0YzR89iYBU0NGDx1RArZf-iIBPUijelDM8zwmtt2v_Etvqo3DGg4bKWaQge0-uH5IjDcYqI5IwTW0VFIWijcq01etbIwRcArsX46XUg7uZMN5h3CmAfSEYGpR-hqOB2nIbvRySo6uth99m5-7Jau7dGHDWaxtDEk3ZvGnOPWI8_J9wOcYia0r8VmQPYlisNFhbNN2p1lW5EVHHmmhf9q3hmvt8RskpNCd5wCS5XmgDySXJXE-S6MVa1wgkjbV7E0N4HPWF9Q26MbfM-6puHtYz5cpQc8WgMCQ5M_Je_RoCD_uxc2T1_cZF8JpvAU9O5g-6YQZDTr7oZaXjXZbZSIBUlQsNAYRXzzfrqOArFh-4hnMsv9AA-p2ZvwkN9K_lTeQV_j5uQWAegv62-HXNU99urQdTf2_4SuiJsI9KDgZDncHC_bjNZlnOVnOcr-hMDbwNs0cKFLHX7ypN6sbeWeV6-9bkhpCazq_F-E-ulK0Gc3i9Al1OFJiU2pghupjoxloWBSf2PST2K_XdHdo0mrqNrD24Gfnt60srmvXZqwI_tebInpGyTBnqTVdn68VJ0fddqMGhBoe3y0bsml7n7xpI3FUcXoPOOEDszlyQPU3vpruvULZ2J_Gn9Wj9w_JIG0ny_OXtsdvOmana83K05skvitnXzJHmeksX2hHvxlkbuThdkauD1BREfyfmXMajglkhfJQS4nRK2HJh0ovxLL3qc43jx_foKEFagEj5Fib-aRNoKBukvYWSyNT5kJ-EX8mjg69U3gVVp2rbXb7hYVQipzjQ625agZZPOtMujy6Yw4gOF6ssnMwfezqgWQ7CeDVXaI7ghTwpGYT0_BktvFxevN5uXxNfaEUtYX0qz967C_TumTR9Onpwf0Xn_LgVIc_xBek7Kt2_03Tu_3mBomy3bqG8L0baKnjAy4fmulFrvVVkJ_dAoWJRcBT1C5M60adaorbRMHCsK9DJeJwRco9NqsALYnj2NLcY6qRKI-FVN_dySMFXiWVD8Nlrs4Qaidy9PAFfVOD0uWq-H0bJd6CXuV8ixVJn7egi9lasi45GMXwl52i__RLBCD9jrQJhpfOo11ZhmDWhoP1iQHH1f65f_zH3_vf_G0ZX8-r0DY93D7-LsGC1WlAvfa_Hlhw3PgysCTBb7viMJdypTWSsbuakxWSY4CFx6UwA4BJY--A1k5C0ZROnQ8VQG9xg1X8hJ5lJkHdK9xD0O6OuHC9OHfxYgGP_cN87WybDhbKvm8-Zf1ShyffKaVnTvx9krlNYoKbnnx-uaP6itsXnapA-5oBeTRRyxKb1QMZT5st-flbwNZ5L6F2NwqHZH2q5ykaz0lKoTcbXj5BhABDVv9nSV8dqU5DV5OeQn260XJ11PZ23gKFlXiLtOU66w2pd1ynwlnk_5BAbm63PiIlOkazQxGnQF4RFjumic6OAh8ywS7XINdIOZald4pQV38oACIjMZJe-wZ318V2v_FQbFio9R85J8F-2wxsBeKgnm3WbUKMN9n4F7HG6LCNm69qQm9g5chmIuVcnkNudhxV0Oe3Wei-uAZvdx7PXuFr3w8V5hXKFY5eB3uwFtxXBrTKypGKgQFP4FMK4bbz5z1-V11DovGPGH7rzCD6sbBQwTnldCzC7s-aW0HEuinV8zE0CcbB_tosyS87LFTnFVxilmRRK5LXp0Bbugdm1X1Hgf89JRCHPXvuNVDhld8v8u39TsWzlruHDCn4W7XxXwjkGo25GyNIF-JtIU6hn5pgIOKOXiPnY6FHviIjFzIVSdrL-A0Eedpo6p-YOvh4q9_iZY9BBpEKVt7sa5AkSTMeMxBdRB9pGl9eyuccmx8G7bOg32Txb6MYVjGh-wbmWTumljl8ZMdgIioLl_gN2hnLpP4GwFEQZZZYjn_A00fHPTHPS9wecg86xR_MaGtjegGUiUBXl6Vu0qaU_DaWN9qwhy_ltdNXw5NJxc5OHfIo_mrqNzg6vYbDxyRZGzbxgLKnJ_kSELoYAAeK7VBMRfe9QlBsOLtAKegI0MlN3HQ2SLJtBDTHjalL28GqCddyCkmP3Hwjt-qNAsUKYVNwq7KHxbXT06JairjfMdVE1kkjlCSWcKo0E8Ibnwj1Wm_s3S-sYsUIxoAFlv0LdJUyXgOTPPNqoonSMWOhGorMPgHZtUkQD_zAwHzjp8GIlSukTtsIAEQjNWKT9ky4Pc36dFQOosZ2SfcHjeDiZOOeqRRkVU50uUnGyY6o1GJiD7nrJqlURaVQbkFshVhNCgvpn3S-kpKHSb4DKRadEut3LHmFrwPhADKFqqJA28C4Og3h8H0RXGeMfE_YfeuCPO7E6E_Bpq3qzJrrlC4fsglTLgDGnwPWMoZpCTpJ9145wwj9lhNtx-W_FF3J3efSag8q2_XpGSj3vbk1fKm6hfMqfhU3yNtmBboM9AZ7Tyf_K-nSBZyVarvtL-fZ9jpE9hgBIrZVzJLR0jNAk0izoUH5I4g2LdoLHLbtm7a8y8od_A0ztKqJuGOilRFYb-5epwi1kQFZJTdno5KNJYlVx1Ym4NHwjY1QfBlIBPrp2k6iX5YmHUOQP2I0Roxvt1kyWYBaQ5fjqUTu-sCRzJW5rOZZ4_8NvOlyebVMT7giN1f6Qo-pjZJI2LDHGtGeRqmilW7df7976yD3kfK2rSttc9AjAyxgDturn_HmkXqEWEVBKo8mnjX5X-ok30qhjDdK2bZSkvCKLcPD6ZJnqmzIc8d_V5PNN3qh2aXXXImO4Ro82VzmcHwU3HGCsLgcblAQg5sbXnDt6zMjkq5Hd6qoIeJRFQrpc8Ww3mnZ5zFI1ODlcm7ZTYrzJKIGP_RNcx3KgLYisqwPHkBhIDNga_5ElnVDBufCaxKc9Rfzanev3qjipEI_s_4dLUPFCKqFbGbSSJ05ohwy7AcXIa7nsE8SjRn-3hpzYdwuRVRMN2WLnbOlmc6hb5-Jnm5vQEmqKUUGH61Dm1TlA1U8oQ_F6rn-L034gnIv4U-rk6gPIDiNB73EfNHP08SElrt7-VGRbHAlg8kf9YEMyulGCd0o0nYGkTxD7KI8-wRMy1m5SYdxm-yBsdAegnopZ3cSxDbGE-4rW1-ooJQ4VY2PnBJ0KfYWo1l2E7vZMt-VEw85fpZHh_QNxjNA55H1qsHF9A0sqMRelHFVxt1QnjYKUzzIDFGcdmiyCJkR-6cejFI1dXy0jkpNGP2tfaRo0i-hCpTQnuqIxjJCE3ieV8ZT2Sq9qm6qfQrYppgqF9cOgAnJwiGCCS0Qmvsw7JqXmyM2CpJmxvWALhIsVtY65JXOy6GiFK0UIlz7PBWgwfD2IZJn5NcpNR8lf6kxPtUHzCnxUJTO16PuDMqET99SMyd0Tv56EBEuByBz6CT2nI4W14HV6lgo2Vj3Uk1-U1EN0vrXv469Rhn6t6FDPD2NGbtGWZKXQtheVp_pvAMIj7G_RgjrlLYFIZXILXfmY2iFmG9Rx8VhjDNZiKYH_O0-6ao23HjuV_3YXsERYH2wDVJHaU7ysylbtqAVxKEnTPRhcW-7y9VgX8OQyo3nhAkn1fGN1qkFKhiKr9APIyxv0gyEsNLDCGhEFu7uK4JVcT0aVMFYXoRs-an1O-d6euxDAFriqjNtrbdwygBNySEoyyQI4XZemJ7blhbSTxldtNZOcxqwJlwsETKuc3BPHk7B16xajWf1axyBrNUNEMcQ8ce9LkbKAUeUYcZeelOVQz2OCvb7ZmrtZwQQY8oBJQdf-kBuvPXyBDoE7vZfiRq6q2AcN5iCxbnccFyRhSUj7oxXjIBRUw1Xi6DAIVOeCG-ZjUFq39rsLL9Wn3e5YKVsMjyVkY5Tvyy63HdywVD4j3pPR1GpDY_vH6qF4pmfZAlEfhruSd1Na8cIiljJtXY5t5ontn3sjrJD0Mipipe4CpniQgZJqQ37qzNTv3EQ2YbukEk_Fksz75vfLDC7B2TloBpKH6fRPV83yIcM7VAvdqS7IbYZ8R2kvT1jXJiNinW3s9kCXJV1K7FVq8nj5LmFYJclrZJtUp6eInXF3xfxJCx6tFeqJKIbE97KZ-tw0QcLyF0u-dOSUS6Qg28G4zUmDzi1PVeKGb--IKRP8C4Nu-6qdKq8bjimf-Zc8HbX1uV18hbWmwpamEAYCBS10pZ36kQP-PBC8F9CAUR3AQymTzgrDN8vKJcN_BdQKvMgTVWsDmTwAbwD7MUleuYB44ky2IKJW3wVYn1aQvVXoV86utOsiCSvb13_IfA7RT_qspPu77LD1Oi73tluDluqJzz1wS5-r8N5T6uYGU_YV1mvKDZUC2AsW_PBQZZIUV-ldLEOOed2oD9pEeHAyxLwl4rkv33g9omN7Pn121_pBfQJv2O2-1Gek3y0MDepIFPFaCsIQmNkaZkRaf6HakU6bKRfqpYdXX6LQVhiWTLqsEhLUHV1ct_dCxnsNdthE56RSHPFQ5BUJZarg_gv2izODhIg1y5a11YogSe1AZq7TXUlebQbFKDqmCYUQvwo5KS-BG_jYVHM-ZMysDXDLMXAis63wDtXPsFnmtFMypdIiVXG6A36mTDYKIH45m8SAKzN8VAWCfIB0_z0RzBBRFKekrS1bSmq2d7waBV_nLWRxNhudKOK_-AAy1CfM6lw68kxhXXfu9YyTX9jJnUlO-RRYNMn7ZSsYvtprUI7cHAOMxBFekLRk5bjjtUQhA8gWUn6xTSMxohjpF1Cg3KXYPeN5v6Wj6FS-dEcxDwqbbhhHv1oJMYdmiQVo5_grkwGzWkclrUV-y5Vq_yfMm9LOoFdo8pDYwoRzMNNvU4WcZtVKCv26Lk7EH1P2kf7Y7WbudIf9msx-n-QVzbiSNjprLM8bT1qhTXSN3cP4j4VCL3RnATzxkoA9psj2CGE2rGl2WkoYMj6eSx_nFvT2KhMrSnlJwV3HUx7IPzYy_WAKNw63IAKWJpV76xebksp09VYq-dv7QfDUNbzWMNOeFIiNmVKrkkLWE5UuNWxpx8ikWzZq2s1kmct5YOtpVBhETmIbHdsquZ971RPcbKkirnMaX9du-3iw9ZJqLbqWw7DSGjDvzAuGHaz8Lv4zUYJ1EjEnomwJgUcLzFwaU9iAtx_f7tUC_hEkRQzVy8Jv-tj7uIAjCdR-5Ey6WI6nr3iVLs1tV-nJcsEBTwDwj738VGVcKB5aUTZCfsbGDgj6QFcN0_tt2ux8veJfUAkE4Nj7Yc_4gVQa3e183hUU8soBe20KYIYgLget5BiOFBiP7hg8K3wIWnosuMqcDicABBzxSmOJtsI1kOIgDSBm9Ekgd0M6fk-ZADWsliR2FcQ9N5C1Id2_tNnH9TRlScfD-UXvRXDO19cJlq3CDt6K6Fg2YGLIc1TsEDokOL9kWF3n81DzfEKGZ1wip9meRVQ2Ec7xEuGKYoWld87ASiUceb7X-gaXU11NV_c15uJ9pNaLJizmO487XSGR5aBkIbFTS9xbE-aSRUUIAnC95zRE7c80K5b57Uys0_GoHa8VMiu--yRDX6xhkAQ_bO2vPFQHFjxBvduMnwl18f8ybgfY0YoYsjbG6xxAMcdX7R2J0wVM59FRaoeYpeVioVKoKpVgbRWCltx-HuAvhiKETTLIVpYo1MTzBEaqRYpmF428dP7Ca7yozlHDFl-52_ushldBYqvd5BB0JxaL8dYzoqHitDEGfdJrPKVUqgflgFzpF0psPeRcVMrm4ep_B3PqxIPGxhibDpMgnjlhwOjH4Fm6zWMAM39GrB_CtVevFlKODJmGMsN8RNICiTcSfSaHT6Q0W24B0l9y04O8TaaOG5DrsWnGdT6gMWxE5epL15HdoF2BFkzKJX0tOL72GaWtE7rjkgUo1R9e2zKx_lgcg2o4QPUp12E1PjB58JGUvb-7ABXXBWe9lVzNfg9tFGC5yQTVzLawtICkvBCUdCjfN-acrzXQXNV-oge7Bzze7cAHUIseix0A7j9rv2KQQXcFP54fYJLaMjFDpCm9ZRw0cXai2Ijpz84hxSs6qqZDQD4PPu8fCvCX6_j91BLZwK0FHKNqMPOYEN4fSOR_q4reWK-IVAsfnCABJxZW7Y09y6i5ujJpUz1PBW1RF9rOt2vybAPsfA34tNzhRnWOJP4o5jHZfnnqDTRfaKUT131mRzpDAlzUQmS0B9_QF_TMRNtbunQsKgmSWSlt6bwMujpWq0xiPfv0hp_JsTtK_quqveX_Cc6MjQn61nWTyHFu4jXHzoHsh78q0pFQPvCzNntQecDRB-9BC_ChCXkU_-rSGgRtcQ2EXBhbvrMKQZijVMKl1Tj5YDogGM0iqWHl1oCQqZEMy0I90zhQO14hUU-uPxaO6EfDCGnoetfobvtbbOjFGqhjiDk3BtvcjJYUireOhOUjf_Mn1Nmk_MfDly0FcB-xteaHh0jUS3b5DB0seVsRcUM2uTiUVUg4r0UY3qsqfv6chijxdmQ1XSZ-g70VyTG8KhVkz5WJl8Jf_yIsoOJUKzf4X9mVzPeQ0iclLQ3hSNf4Tya-_DHgmt8OudYK8eBL-lfYbITHaA9Vqz3rORj8dyeCJT3IOXT8UTqr1gcaulrtVJrT4jBY1GVenJEVRkd9Qwuw9b8C79T_Kn6exqSgXOx9mR8-nX5d1y6moRqkTdol_Ky4mtV0euUj2hWTFqsvPSoAsGT4lk2PD-75WDvg2X1mfNKI3HpEnI3usyiwSmdCT6OPRSmR_UlAb-3PlRljR8R8uCeh1f0eVlJPeOo5RqXtk04jW4yqKDlwjPnON1DBCR4Q22wXBagtHpFqtlPQ24FZA1xoegJvy-WjHknXx14atj8igpaEAg7CbGxRpq70YUp9Eat0hCNAHrJzdApNuAqNl8Ck9pt6E0t1XcMerTD6htQElbmn44IwwPf8DlQQnsowdm-949D8URus1j7xRVv7AWJocTgI5F0uka-i7qpGsEqZ3D-MlSwMAByFTHTQXFKQFn7PMAaCVLMcaf5dFY62kq2vAYLTlgaejLbBvfoDqzlcPf7B3Z3cARUwxiHsbFgAnC8Z1NuCoEbH7H6TNHMAUtnzDDApJA5v2Hmb25LQTsxnlsog8VvPRGstIenOSwdbdY7zl-vUkFg_CUKBo8E800rKady4GD_XkT-EOL8NyyXOCJS4C_GJ30RUE_gznTzz4Svypp1_AUtdWCgOew_PvwKOxWaQgnfXRAKsHuwP94e4g2K9yqOZCBhbwVLQnY_ykt6ooT4oGmhJztifKZPT1wWyow8mC1ftbLGwvCrvO2v5zSahI-ijYizd0nIZ74tBOqMYt-XDd2pc4_fsRn7jGY_NBlc1ZeI-2t7gmWNPwMFYOwrZdVNH068X_xjRg5FjIywEmdQYDWKpF4F3TvW3gV5-YKTJ3jktsEmXDtxD8ZvIx7366OOWcA1qE-by8BZIHCG1Hguov14ZJgm4mDOIhxxu5LYEPcTEPY_pd7HFdZnzcAN0gQB37PFvR6GlH-qIK5XK_pl-2bQxLXUipbVFF0dFNWD48gw8IX15MATW5GKpbIYaiVUAcfbsVNzAMnpRr4sHPH1xbjUvil885A6KEMkSjIyTn9Y4a9I0yhCz1gZGcLgbYpbSXnKrRPNgjhgECqczvxnVgWHGirYfOw4Z_N4_7NOy7uM7UHykEGFRIWj5vtscjIt4C-hZBH9aYh-ZJmQMITLtv60yP2WCTeyXfpdWo237_YCKL5i7l2Qrw3F4TDt_87c1ooKY5UdDHoJ6Lr8= \ No newline at end of file diff --git a/v2_adminpanel/__pycache__/app.cpython-312.pyc b/v2_adminpanel/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ae11b742268b509d840318ee411f1af105562aff GIT binary patch literal 172201 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
26_q?r-4Z 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 literal 0 HcmV?d00001 diff --git a/v2_adminpanel/__pycache__/config.cpython-312.pyc b/v2_adminpanel/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..09aafeb9eb28289c6e14dae101e90d09dafa51b8 GIT binary patch literal 2358 zcmah~+fUn896m8gAQuM%xj+geu$C3A5S4anyRMT=azZ@FWwxU(ewY<<3}-9Q_QV@q-}bAi#!rdyguZWdJ!!# zMD8OfuzL1wHcLW2)VoVMHlGtQ4C*@-({I&3G0VWI2)~6u0Tg`blm!YB4Ud=z))M9jz$X81(R zD2iBHT)lq0{{z`#0E(`i2S6GqNx(sfhPZMVz$|WlD;mbA-DS4=E|SVVit^ z&ulm|X~Qm~Tvd=#F^WX7=ZeM(`Fbj95y?n3l-l~b``t|@o=xZSBIKh2TM(j|?54Yr z%}0|gw*V7+=FP_aJdCqBF`i;&ffJ!w3j2@QV4R0y$4bLAi6d>Xvm_cjmWmaf3}|Q# z1^2RvG?%$3n_J7UX?SF?_I7BPc^5GwTZp|b^K2>XIoOH3qBb;4A1VjuJvoLcRmVN3 zXecJTG&2Nu*9o^O>Lzuf13SVUdYaIWO`AA>6|EF|Lj6GJ})QoJlo-2 zDx2p89JC0+CJB{}(AqG`s*RYb9ToMKqG_Y1?CDUUbIt;0gm?-j@(U#Fpu3;JYXSmq z(y?mIh?Qb_ay|4IYu;Z%f30Y!S}dWK8dY+^^w?b4NHrE?>oZftwx(!Ak~??3UaTpV zSV^rdm6xaM8*h3(uBxa}Q9jWi$!`7?>JQ0#zsqK`{Q+Dr!RSjcNZx+|&)4+66Zi)2 z@b{#<(hl|Q7hCRj@0l%cn+a^u?Sacp!2CWk@#M?rBNv+>+zyX6L8v`A(gcBaIAY4d zb4@VNj+||R;r$*!2k*?@f_Cpcc$$P9O>&V?{wQ2y0dt%e7C$qw|o=Fye;oU zn;F`s9=Ptin&GJyGu`ez|BCJ&ptgEn`GK#0d%8uBk=}s&1z?^5W``7Ho`XnxaBPQq z*hP1pf9=xCw$})L>+re^9tzXhtrz0%SVk zk8W!ZHtugU$F8(OR|%G&`oagT?8Kg2X)%{xFxQ?j*ILZ=U)G;7kmT+G4&T=7H{4fT z(-&#kqmL&akN-6J!({Wqjn?=lZ99FkbKlX`OKo{bd;UN>5Paq6@>66UEly79UzOKb A1^@s6 literal 0 HcmV?d00001 diff --git a/v2_adminpanel/app.py b/v2_adminpanel/app.py index 96c85f1..deba93a 100644 --- a/v2_adminpanel/app.py +++ b/v2_adminpanel/app.py @@ -1,50 +1,63 @@ import os -import psycopg2 -from psycopg2.extras import Json -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from functools import wraps -from dotenv import load_dotenv -import pandas as pd +import sys +import time +import json +import logging +import requests +import re +import random +import base64 +from io import BytesIO from datetime import datetime, timedelta from zoneinfo import ZoneInfo -import io -import subprocess -import gzip -from cryptography.fernet import Fernet from pathlib import Path -import time -from apscheduler.schedulers.background import BackgroundScheduler -import logging -import random -import hashlib -import requests -import secrets -import string -import re -import bcrypt -import pyotp -import qrcode -from io import BytesIO -import base64 -import json -from werkzeug.middleware.proxy_fix import ProxyFix -from openpyxl.utils import get_column_letter -load_dotenv() +# Add current directory to Python path to ensure modules can be imported +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash +from flask_session import Session +from werkzeug.middleware.proxy_fix import ProxyFix +from apscheduler.schedulers.background import BackgroundScheduler +import pandas as pd +from psycopg2.extras import Json + +# Import our new modules +import config +from db import get_connection, get_db_connection, get_db_cursor, execute_query +from auth.decorators import login_required +from auth.password import hash_password, verify_password +from auth.two_factor import ( + generate_totp_secret, generate_qr_code, verify_totp, + generate_backup_codes, hash_backup_code, verify_backup_code +) +from auth.rate_limiting import ( + check_ip_blocked, record_failed_attempt, + reset_login_attempts, get_login_attempts +) +from utils.network import get_client_ip +from utils.audit import log_audit +from utils.license import generate_license_key, validate_license_key +from utils.backup import create_backup, restore_backup, get_or_create_encryption_key +from utils.export import ( + create_excel_export, format_datetime_for_export, + prepare_license_export_data, prepare_customer_export_data, + prepare_session_export_data, prepare_audit_export_data +) +from models import get_user_by_username app = Flask(__name__) -app.config['SECRET_KEY'] = os.urandom(24) -app.config['SESSION_TYPE'] = 'filesystem' -app.config['JSON_AS_ASCII'] = False # JSON-Ausgabe mit UTF-8 -app.config['JSONIFY_MIMETYPE'] = 'application/json; charset=utf-8' -app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=5) # 5 Minuten Session-Timeout -app.config['SESSION_COOKIE_HTTPONLY'] = True -app.config['SESSION_COOKIE_SECURE'] = False # Wird auf True gesetzt wenn HTTPS (intern läuft HTTP) -app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' -app.config['SESSION_COOKIE_NAME'] = 'admin_session' -# WICHTIG: Session-Cookie soll auch nach 5 Minuten ablaufen -app.config['SESSION_REFRESH_EACH_REQUEST'] = False +# Load configuration from config module +app.config['SECRET_KEY'] = config.SECRET_KEY +app.config['SESSION_TYPE'] = config.SESSION_TYPE +app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII +app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE +app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME +app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY +app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE +app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE +app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME +app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST Session(app) # ProxyFix für korrekte IP-Adressen hinter Nginx @@ -52,22 +65,7 @@ app.wsgi_app = ProxyFix( app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 ) -# Backup-Konfiguration -BACKUP_DIR = Path("/app/backups") -BACKUP_DIR.mkdir(exist_ok=True) - -# Rate-Limiting Konfiguration -FAIL_MESSAGES = [ - "NOPE!", - "ACCESS DENIED, TRY HARDER", - "WRONG! 🚫", - "COMPUTER SAYS NO", - "YOU FAILED" -] - -MAX_LOGIN_ATTEMPTS = 5 -BLOCK_DURATION_HOURS = 24 -CAPTCHA_AFTER_ATTEMPTS = 2 +# Configuration is now loaded from config module # Scheduler für automatische Backups scheduler = BackgroundScheduler() @@ -77,385 +75,6 @@ scheduler.start() logging.basicConfig(level=logging.INFO) -# Login decorator -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if 'logged_in' not in session: - return redirect(url_for('login')) - - # Prüfe ob Session abgelaufen ist - if 'last_activity' in session: - last_activity = datetime.fromisoformat(session['last_activity']) - time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity - - # Debug-Logging - app.logger.info(f"Session check for {session.get('username', 'unknown')}: " - f"Last activity: {last_activity}, " - f"Time since: {time_since_activity.total_seconds()} seconds") - - if time_since_activity > timedelta(minutes=5): - # Session abgelaufen - Logout - username = session.get('username', 'unbekannt') - app.logger.info(f"Session timeout for user {username} - auto logout") - # Audit-Log für automatischen Logout (vor session.clear()!) - try: - log_audit('AUTO_LOGOUT', 'session', additional_info={'reason': 'Session timeout (5 minutes)', 'username': username}) - except: - pass - session.clear() - flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning') - return redirect(url_for('login')) - - # Aktivität NICHT automatisch aktualisieren - # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht) - return f(*args, **kwargs) - return decorated_function - -# DB-Verbindung mit UTF-8 Encoding -def get_connection(): - conn = psycopg2.connect( - host=os.getenv("POSTGRES_HOST", "postgres"), - port=os.getenv("POSTGRES_PORT", "5432"), - dbname=os.getenv("POSTGRES_DB"), - user=os.getenv("POSTGRES_USER"), - password=os.getenv("POSTGRES_PASSWORD"), - options='-c client_encoding=UTF8' - ) - conn.set_client_encoding('UTF8') - return conn - -# User Authentication Helper Functions -def hash_password(password): - """Hash a password using bcrypt""" - return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - -def verify_password(password, hashed): - """Verify a password against its hash""" - return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) - -def get_user_by_username(username): - """Get user from database by username""" - conn = get_connection() - cur = conn.cursor() - try: - cur.execute(""" - SELECT id, username, password_hash, email, totp_secret, totp_enabled, - backup_codes, last_password_change, failed_2fa_attempts - FROM users WHERE username = %s - """, (username,)) - user = cur.fetchone() - if user: - return { - 'id': user[0], - 'username': user[1], - 'password_hash': user[2], - 'email': user[3], - 'totp_secret': user[4], - 'totp_enabled': user[5], - 'backup_codes': user[6], - 'last_password_change': user[7], - 'failed_2fa_attempts': user[8] - } - return None - finally: - cur.close() - conn.close() - -def generate_totp_secret(): - """Generate a new TOTP secret""" - return pyotp.random_base32() - -def generate_qr_code(username, totp_secret): - """Generate QR code for TOTP setup""" - totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( - name=username, - issuer_name='V2 Admin Panel' - ) - - qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(totp_uri) - qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white") - buf = BytesIO() - img.save(buf, format='PNG') - buf.seek(0) - - return base64.b64encode(buf.getvalue()).decode() - -def verify_totp(totp_secret, token): - """Verify a TOTP token""" - totp = pyotp.TOTP(totp_secret) - return totp.verify(token, valid_window=1) - -def generate_backup_codes(count=8): - """Generate backup codes for 2FA recovery""" - codes = [] - for _ in range(count): - code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) - codes.append(code) - return codes - -def hash_backup_code(code): - """Hash a backup code for storage""" - return hashlib.sha256(code.encode()).hexdigest() - -def verify_backup_code(code, hashed_codes): - """Verify a backup code against stored hashes""" - code_hash = hashlib.sha256(code.encode()).hexdigest() - return code_hash in hashed_codes - -# Audit-Log-Funktion -def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): - """Protokolliert Änderungen im Audit-Log""" - conn = get_connection() - cur = conn.cursor() - - try: - username = session.get('username', 'system') - ip_address = get_client_ip() if request else None - user_agent = request.headers.get('User-Agent') if request else None - - # Debug logging - app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") - - # Konvertiere Dictionaries zu JSONB - old_json = Json(old_values) if old_values else None - new_json = Json(new_values) if new_values else None - - cur.execute(""" - INSERT INTO audit_log - (username, action, entity_type, entity_id, old_values, new_values, - ip_address, user_agent, additional_info) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - """, (username, action, entity_type, entity_id, old_json, new_json, - ip_address, user_agent, additional_info)) - - conn.commit() - except Exception as e: - print(f"Audit log error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - -# Verschlüsselungs-Funktionen -def get_or_create_encryption_key(): - """Holt oder erstellt einen Verschlüsselungsschlüssel""" - key_file = BACKUP_DIR / ".backup_key" - - # Versuche Key aus Umgebungsvariable zu lesen - env_key = os.getenv("BACKUP_ENCRYPTION_KEY") - if env_key: - try: - # Validiere den Key - Fernet(env_key.encode()) - return env_key.encode() - except: - pass - - # Wenn kein gültiger Key in ENV, prüfe Datei - if key_file.exists(): - return key_file.read_bytes() - - # Erstelle neuen Key - key = Fernet.generate_key() - key_file.write_bytes(key) - logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt") - return key - -# Backup-Funktionen -def create_backup(backup_type="manual", created_by=None): - """Erstellt ein verschlüsseltes Backup der Datenbank""" - start_time = time.time() - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S") - filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc" - filepath = BACKUP_DIR / filename - - conn = get_connection() - cur = conn.cursor() - - # Backup-Eintrag erstellen - cur.execute(""" - INSERT INTO backup_history - (filename, filepath, backup_type, status, created_by, is_encrypted) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (filename, str(filepath), backup_type, 'in_progress', - created_by or 'system', True)) - backup_id = cur.fetchone()[0] - conn.commit() - - try: - # PostgreSQL Dump erstellen - dump_command = [ - 'pg_dump', - '-h', os.getenv("POSTGRES_HOST", "postgres"), - '-p', os.getenv("POSTGRES_PORT", "5432"), - '-U', os.getenv("POSTGRES_USER"), - '-d', os.getenv("POSTGRES_DB"), - '--no-password', - '--verbose' - ] - - # PGPASSWORD setzen - env = os.environ.copy() - env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") - - # Dump ausführen - result = subprocess.run(dump_command, capture_output=True, text=True, env=env) - - if result.returncode != 0: - raise Exception(f"pg_dump failed: {result.stderr}") - - dump_data = result.stdout.encode('utf-8') - - # Komprimieren - compressed_data = gzip.compress(dump_data) - - # Verschlüsseln - key = get_or_create_encryption_key() - f = Fernet(key) - encrypted_data = f.encrypt(compressed_data) - - # Speichern - filepath.write_bytes(encrypted_data) - - # Statistiken sammeln - cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'") - tables_count = cur.fetchone()[0] - - cur.execute(""" - SELECT SUM(n_live_tup) - FROM pg_stat_user_tables - """) - records_count = cur.fetchone()[0] or 0 - - duration = time.time() - start_time - filesize = filepath.stat().st_size - - # Backup-Eintrag aktualisieren - cur.execute(""" - UPDATE backup_history - SET status = %s, filesize = %s, tables_count = %s, - records_count = %s, duration_seconds = %s - WHERE id = %s - """, ('success', filesize, tables_count, records_count, duration, backup_id)) - - conn.commit() - - # Audit-Log - log_audit('BACKUP', 'database', backup_id, - additional_info=f"Backup erstellt: {filename} ({filesize} bytes)") - - # E-Mail-Benachrichtigung (wenn konfiguriert) - send_backup_notification(True, filename, filesize, duration) - - logging.info(f"Backup erfolgreich erstellt: {filename}") - return True, filename - - except Exception as e: - # Fehler protokollieren - cur.execute(""" - UPDATE backup_history - SET status = %s, error_message = %s, duration_seconds = %s - WHERE id = %s - """, ('failed', str(e), time.time() - start_time, backup_id)) - conn.commit() - - logging.error(f"Backup fehlgeschlagen: {e}") - send_backup_notification(False, filename, error=str(e)) - - return False, str(e) - - finally: - cur.close() - conn.close() - -def restore_backup(backup_id, encryption_key=None): - """Stellt ein Backup wieder her""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Info abrufen - cur.execute(""" - SELECT filename, filepath, is_encrypted - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - raise Exception("Backup nicht gefunden") - - filename, filepath, is_encrypted = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - raise Exception("Backup-Datei nicht gefunden") - - # Datei lesen - encrypted_data = filepath.read_bytes() - - # Entschlüsseln - if is_encrypted: - key = encryption_key.encode() if encryption_key else get_or_create_encryption_key() - try: - f = Fernet(key) - compressed_data = f.decrypt(encrypted_data) - except: - raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?") - else: - compressed_data = encrypted_data - - # Dekomprimieren - dump_data = gzip.decompress(compressed_data) - sql_commands = dump_data.decode('utf-8') - - # Bestehende Verbindungen schließen - cur.close() - conn.close() - - # Datenbank wiederherstellen - restore_command = [ - 'psql', - '-h', os.getenv("POSTGRES_HOST", "postgres"), - '-p', os.getenv("POSTGRES_PORT", "5432"), - '-U', os.getenv("POSTGRES_USER"), - '-d', os.getenv("POSTGRES_DB"), - '--no-password' - ] - - env = os.environ.copy() - env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") - - result = subprocess.run(restore_command, input=sql_commands, - capture_output=True, text=True, env=env) - - if result.returncode != 0: - raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}") - - # Audit-Log (neue Verbindung) - log_audit('RESTORE', 'database', backup_id, - additional_info=f"Backup wiederhergestellt: {filename}") - - return True, "Backup erfolgreich wiederhergestellt" - - except Exception as e: - logging.error(f"Wiederherstellung fehlgeschlagen: {e}") - return False, str(e) - -def send_backup_notification(success, filename, filesize=None, duration=None, error=None): - """Sendet E-Mail-Benachrichtigung (wenn konfiguriert)""" - if not os.getenv("EMAIL_ENABLED", "false").lower() == "true": - return - - # E-Mail-Funktion vorbereitet aber deaktiviert - # TODO: Implementieren wenn E-Mail-Server konfiguriert ist - logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}") - # Scheduled Backup Job def scheduled_backup(): """Führt ein geplantes Backup aus""" @@ -466,165 +85,16 @@ def scheduled_backup(): scheduler.add_job( scheduled_backup, 'cron', - hour=3, - minute=0, + hour=config.SCHEDULER_CONFIG['backup_hour'], + minute=config.SCHEDULER_CONFIG['backup_minute'], id='daily_backup', replace_existing=True ) -# Rate-Limiting Funktionen -def get_client_ip(): - """Ermittelt die echte IP-Adresse des Clients""" - # Debug logging - app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}") - - # Try X-Real-IP first (set by nginx) - if request.headers.get('X-Real-IP'): - return request.headers.get('X-Real-IP') - # Then X-Forwarded-For - elif request.headers.get('X-Forwarded-For'): - # X-Forwarded-For can contain multiple IPs, take the first one - return request.headers.get('X-Forwarded-For').split(',')[0].strip() - # Fallback to remote_addr - else: - return request.remote_addr - -def check_ip_blocked(ip_address): - """Prüft ob eine IP-Adresse gesperrt ist""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT blocked_until FROM login_attempts - WHERE ip_address = %s AND blocked_until IS NOT NULL - """, (ip_address,)) - - result = cur.fetchone() - cur.close() - conn.close() - - if result and result[0]: - if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None): - return True, result[0] - return False, None - -def record_failed_attempt(ip_address, username): - """Zeichnet einen fehlgeschlagenen Login-Versuch auf""" - conn = get_connection() - cur = conn.cursor() - - # Random Fehlermeldung - error_message = random.choice(FAIL_MESSAGES) - - try: - # Prüfen ob IP bereits existiert - cur.execute(""" - SELECT attempt_count FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - - result = cur.fetchone() - - if result: - # Update bestehenden Eintrag - new_count = result[0] + 1 - blocked_until = None - - if new_count >= MAX_LOGIN_ATTEMPTS: - blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS) - # E-Mail-Benachrichtigung (wenn aktiviert) - if os.getenv("EMAIL_ENABLED", "false").lower() == "true": - send_security_alert_email(ip_address, username, new_count) - - cur.execute(""" - UPDATE login_attempts - SET attempt_count = %s, - last_attempt = CURRENT_TIMESTAMP, - blocked_until = %s, - last_username_tried = %s, - last_error_message = %s - WHERE ip_address = %s - """, (new_count, blocked_until, username, error_message, ip_address)) - else: - # Neuen Eintrag erstellen - cur.execute(""" - INSERT INTO login_attempts - (ip_address, attempt_count, last_username_tried, last_error_message) - VALUES (%s, 1, %s, %s) - """, (ip_address, username, error_message)) - - conn.commit() - - # Audit-Log - log_audit('LOGIN_FAILED', 'user', - additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}") - - except Exception as e: - print(f"Rate limiting error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - - return error_message - -def reset_login_attempts(ip_address): - """Setzt die Login-Versuche für eine IP zurück""" - conn = get_connection() - cur = conn.cursor() - - try: - cur.execute(""" - DELETE FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - conn.commit() - except Exception as e: - print(f"Reset attempts error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - -def get_login_attempts(ip_address): - """Gibt die Anzahl der Login-Versuche für eine IP zurück""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT attempt_count FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - - result = cur.fetchone() - cur.close() - conn.close() - - return result[0] if result else 0 - -def send_security_alert_email(ip_address, username, attempt_count): - """Sendet eine Sicherheitswarnung per E-Mail""" - subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche" - body = f""" - WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt! - - IP-Adresse: {ip_address} - Versuchter Benutzername: {username} - Anzahl Versuche: {attempt_count} - Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')} - - Die IP-Adresse wurde für 24 Stunden gesperrt. - - Dies ist eine automatische Nachricht vom v2-Docker Admin Panel. - """ - - # TODO: E-Mail-Versand implementieren wenn SMTP konfiguriert - logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") - print(f"E-Mail würde gesendet: {subject}") def verify_recaptcha(response): """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = os.getenv('RECAPTCHA_SECRET_KEY') + secret_key = config.RECAPTCHA_SECRET_KEY # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) if not secret_key: @@ -657,49 +127,6 @@ def verify_recaptcha(response): logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") return False -def generate_license_key(license_type='full'): - """ - Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ - - AF = Account Factory (Produktkennung) - F/T = F für Fullversion, T für Testversion - YYYY = Jahr - MM = Monat - XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen - """ - # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l) - chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' - - # Datum-Teil - now = datetime.now(ZoneInfo("Europe/Berlin")) - date_part = now.strftime('%Y%m') - type_char = 'F' if license_type == 'full' else 'T' - - # Zufällige Teile generieren (3 Blöcke à 4 Zeichen) - parts = [] - for _ in range(3): - part = ''.join(secrets.choice(chars) for _ in range(4)) - parts.append(part) - - # Key zusammensetzen - key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" - - return key - -def validate_license_key(key): - """ - Validiert das License Key Format - Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ - """ - if not key: - return False - - # Pattern für das neue Format - # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen - pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' - - # Großbuchstaben für Vergleich - return bool(re.match(pattern, key.upper())) @app.route("/login", methods=["GET", "POST"]) def login(): @@ -725,8 +152,8 @@ def login(): captcha_response = request.form.get("g-recaptcha-response") # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY') - if attempt_count >= CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: + recaptcha_site_key = config.RECAPTCHA_SITE_KEY + if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: if not captcha_response: # Timing-Attack Schutz elapsed = time.time() - start_time @@ -736,7 +163,7 @@ def login(): error="CAPTCHA ERFORDERLICH!", show_captcha=True, error_type="captcha", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), recaptcha_site_key=recaptcha_site_key) # CAPTCHA validieren @@ -749,7 +176,7 @@ def login(): error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", show_captcha=True, error_type="captcha", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), recaptcha_site_key=recaptcha_site_key) # Check user in database first, fallback to env vars @@ -764,13 +191,7 @@ def login(): needs_2fa = user['totp_enabled'] else: # Fallback to environment variables for backward compatibility - admin1_user = os.getenv("ADMIN1_USERNAME") - admin1_pass = os.getenv("ADMIN1_PASSWORD") - admin2_user = os.getenv("ADMIN2_USERNAME") - admin2_pass = os.getenv("ADMIN2_PASSWORD") - - if ((username == admin1_user and password == admin1_pass) or - (username == admin2_user and password == admin2_pass)): + if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: login_success = True # Timing-Attack Schutz - Mindestens 1 Sekunde warten @@ -806,20 +227,20 @@ def login(): is_now_blocked, _ = check_ip_blocked(ip_address) if is_now_blocked: log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") + additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") return render_template("login.html", error=error_message, - show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), + show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), error_type="failed", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), + recaptcha_site_key=config.RECAPTCHA_SITE_KEY) # GET Request return render_template("login.html", - show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) + show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=config.RECAPTCHA_SITE_KEY) @app.route("/logout") def logout(): diff --git a/v2_adminpanel/app.py.backup b/v2_adminpanel/app.py.backup new file mode 100644 index 0000000..96c85f1 --- /dev/null +++ b/v2_adminpanel/app.py.backup @@ -0,0 +1,5032 @@ +import os +import psycopg2 +from psycopg2.extras import Json +from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash +from flask_session import Session +from functools import wraps +from dotenv import load_dotenv +import pandas as pd +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +import io +import subprocess +import gzip +from cryptography.fernet import Fernet +from pathlib import Path +import time +from apscheduler.schedulers.background import BackgroundScheduler +import logging +import random +import hashlib +import requests +import secrets +import string +import re +import bcrypt +import pyotp +import qrcode +from io import BytesIO +import base64 +import json +from werkzeug.middleware.proxy_fix import ProxyFix +from openpyxl.utils import get_column_letter + +load_dotenv() + +app = Flask(__name__) +app.config['SECRET_KEY'] = os.urandom(24) +app.config['SESSION_TYPE'] = 'filesystem' +app.config['JSON_AS_ASCII'] = False # JSON-Ausgabe mit UTF-8 +app.config['JSONIFY_MIMETYPE'] = 'application/json; charset=utf-8' +app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=5) # 5 Minuten Session-Timeout +app.config['SESSION_COOKIE_HTTPONLY'] = True +app.config['SESSION_COOKIE_SECURE'] = False # Wird auf True gesetzt wenn HTTPS (intern läuft HTTP) +app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' +app.config['SESSION_COOKIE_NAME'] = 'admin_session' +# WICHTIG: Session-Cookie soll auch nach 5 Minuten ablaufen +app.config['SESSION_REFRESH_EACH_REQUEST'] = False +Session(app) + +# ProxyFix für korrekte IP-Adressen hinter Nginx +app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 +) + +# Backup-Konfiguration +BACKUP_DIR = Path("/app/backups") +BACKUP_DIR.mkdir(exist_ok=True) + +# Rate-Limiting Konfiguration +FAIL_MESSAGES = [ + "NOPE!", + "ACCESS DENIED, TRY HARDER", + "WRONG! 🚫", + "COMPUTER SAYS NO", + "YOU FAILED" +] + +MAX_LOGIN_ATTEMPTS = 5 +BLOCK_DURATION_HOURS = 24 +CAPTCHA_AFTER_ATTEMPTS = 2 + +# Scheduler für automatische Backups +scheduler = BackgroundScheduler() +scheduler.start() + +# Logging konfigurieren +logging.basicConfig(level=logging.INFO) + + +# Login decorator +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'logged_in' not in session: + return redirect(url_for('login')) + + # Prüfe ob Session abgelaufen ist + if 'last_activity' in session: + last_activity = datetime.fromisoformat(session['last_activity']) + time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity + + # Debug-Logging + app.logger.info(f"Session check for {session.get('username', 'unknown')}: " + f"Last activity: {last_activity}, " + f"Time since: {time_since_activity.total_seconds()} seconds") + + if time_since_activity > timedelta(minutes=5): + # Session abgelaufen - Logout + username = session.get('username', 'unbekannt') + app.logger.info(f"Session timeout for user {username} - auto logout") + # Audit-Log für automatischen Logout (vor session.clear()!) + try: + log_audit('AUTO_LOGOUT', 'session', additional_info={'reason': 'Session timeout (5 minutes)', 'username': username}) + except: + pass + session.clear() + flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning') + return redirect(url_for('login')) + + # Aktivität NICHT automatisch aktualisieren + # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht) + return f(*args, **kwargs) + return decorated_function + +# DB-Verbindung mit UTF-8 Encoding +def get_connection(): + conn = psycopg2.connect( + host=os.getenv("POSTGRES_HOST", "postgres"), + port=os.getenv("POSTGRES_PORT", "5432"), + dbname=os.getenv("POSTGRES_DB"), + user=os.getenv("POSTGRES_USER"), + password=os.getenv("POSTGRES_PASSWORD"), + options='-c client_encoding=UTF8' + ) + conn.set_client_encoding('UTF8') + return conn + +# User Authentication Helper Functions +def hash_password(password): + """Hash a password using bcrypt""" + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + +def verify_password(password, hashed): + """Verify a password against its hash""" + return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) + +def get_user_by_username(username): + """Get user from database by username""" + conn = get_connection() + cur = conn.cursor() + try: + cur.execute(""" + SELECT id, username, password_hash, email, totp_secret, totp_enabled, + backup_codes, last_password_change, failed_2fa_attempts + FROM users WHERE username = %s + """, (username,)) + user = cur.fetchone() + if user: + return { + 'id': user[0], + 'username': user[1], + 'password_hash': user[2], + 'email': user[3], + 'totp_secret': user[4], + 'totp_enabled': user[5], + 'backup_codes': user[6], + 'last_password_change': user[7], + 'failed_2fa_attempts': user[8] + } + return None + finally: + cur.close() + conn.close() + +def generate_totp_secret(): + """Generate a new TOTP secret""" + return pyotp.random_base32() + +def generate_qr_code(username, totp_secret): + """Generate QR code for TOTP setup""" + totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( + name=username, + issuer_name='V2 Admin Panel' + ) + + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(totp_uri) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + buf = BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + + return base64.b64encode(buf.getvalue()).decode() + +def verify_totp(totp_secret, token): + """Verify a TOTP token""" + totp = pyotp.TOTP(totp_secret) + return totp.verify(token, valid_window=1) + +def generate_backup_codes(count=8): + """Generate backup codes for 2FA recovery""" + codes = [] + for _ in range(count): + code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) + codes.append(code) + return codes + +def hash_backup_code(code): + """Hash a backup code for storage""" + return hashlib.sha256(code.encode()).hexdigest() + +def verify_backup_code(code, hashed_codes): + """Verify a backup code against stored hashes""" + code_hash = hashlib.sha256(code.encode()).hexdigest() + return code_hash in hashed_codes + +# Audit-Log-Funktion +def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): + """Protokolliert Änderungen im Audit-Log""" + conn = get_connection() + cur = conn.cursor() + + try: + username = session.get('username', 'system') + ip_address = get_client_ip() if request else None + user_agent = request.headers.get('User-Agent') if request else None + + # Debug logging + app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") + + # Konvertiere Dictionaries zu JSONB + old_json = Json(old_values) if old_values else None + new_json = Json(new_values) if new_values else None + + cur.execute(""" + INSERT INTO audit_log + (username, action, entity_type, entity_id, old_values, new_values, + ip_address, user_agent, additional_info) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """, (username, action, entity_type, entity_id, old_json, new_json, + ip_address, user_agent, additional_info)) + + conn.commit() + except Exception as e: + print(f"Audit log error: {e}") + conn.rollback() + finally: + cur.close() + conn.close() + +# Verschlüsselungs-Funktionen +def get_or_create_encryption_key(): + """Holt oder erstellt einen Verschlüsselungsschlüssel""" + key_file = BACKUP_DIR / ".backup_key" + + # Versuche Key aus Umgebungsvariable zu lesen + env_key = os.getenv("BACKUP_ENCRYPTION_KEY") + if env_key: + try: + # Validiere den Key + Fernet(env_key.encode()) + return env_key.encode() + except: + pass + + # Wenn kein gültiger Key in ENV, prüfe Datei + if key_file.exists(): + return key_file.read_bytes() + + # Erstelle neuen Key + key = Fernet.generate_key() + key_file.write_bytes(key) + logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt") + return key + +# Backup-Funktionen +def create_backup(backup_type="manual", created_by=None): + """Erstellt ein verschlüsseltes Backup der Datenbank""" + start_time = time.time() + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S") + filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc" + filepath = BACKUP_DIR / filename + + conn = get_connection() + cur = conn.cursor() + + # Backup-Eintrag erstellen + cur.execute(""" + INSERT INTO backup_history + (filename, filepath, backup_type, status, created_by, is_encrypted) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, (filename, str(filepath), backup_type, 'in_progress', + created_by or 'system', True)) + backup_id = cur.fetchone()[0] + conn.commit() + + try: + # PostgreSQL Dump erstellen + dump_command = [ + 'pg_dump', + '-h', os.getenv("POSTGRES_HOST", "postgres"), + '-p', os.getenv("POSTGRES_PORT", "5432"), + '-U', os.getenv("POSTGRES_USER"), + '-d', os.getenv("POSTGRES_DB"), + '--no-password', + '--verbose' + ] + + # PGPASSWORD setzen + env = os.environ.copy() + env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") + + # Dump ausführen + result = subprocess.run(dump_command, capture_output=True, text=True, env=env) + + if result.returncode != 0: + raise Exception(f"pg_dump failed: {result.stderr}") + + dump_data = result.stdout.encode('utf-8') + + # Komprimieren + compressed_data = gzip.compress(dump_data) + + # Verschlüsseln + key = get_or_create_encryption_key() + f = Fernet(key) + encrypted_data = f.encrypt(compressed_data) + + # Speichern + filepath.write_bytes(encrypted_data) + + # Statistiken sammeln + cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'") + tables_count = cur.fetchone()[0] + + cur.execute(""" + SELECT SUM(n_live_tup) + FROM pg_stat_user_tables + """) + records_count = cur.fetchone()[0] or 0 + + duration = time.time() - start_time + filesize = filepath.stat().st_size + + # Backup-Eintrag aktualisieren + cur.execute(""" + UPDATE backup_history + SET status = %s, filesize = %s, tables_count = %s, + records_count = %s, duration_seconds = %s + WHERE id = %s + """, ('success', filesize, tables_count, records_count, duration, backup_id)) + + conn.commit() + + # Audit-Log + log_audit('BACKUP', 'database', backup_id, + additional_info=f"Backup erstellt: {filename} ({filesize} bytes)") + + # E-Mail-Benachrichtigung (wenn konfiguriert) + send_backup_notification(True, filename, filesize, duration) + + logging.info(f"Backup erfolgreich erstellt: {filename}") + return True, filename + + except Exception as e: + # Fehler protokollieren + cur.execute(""" + UPDATE backup_history + SET status = %s, error_message = %s, duration_seconds = %s + WHERE id = %s + """, ('failed', str(e), time.time() - start_time, backup_id)) + conn.commit() + + logging.error(f"Backup fehlgeschlagen: {e}") + send_backup_notification(False, filename, error=str(e)) + + return False, str(e) + + finally: + cur.close() + conn.close() + +def restore_backup(backup_id, encryption_key=None): + """Stellt ein Backup wieder her""" + conn = get_connection() + cur = conn.cursor() + + try: + # Backup-Info abrufen + cur.execute(""" + SELECT filename, filepath, is_encrypted + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + if not backup_info: + raise Exception("Backup nicht gefunden") + + filename, filepath, is_encrypted = backup_info + filepath = Path(filepath) + + if not filepath.exists(): + raise Exception("Backup-Datei nicht gefunden") + + # Datei lesen + encrypted_data = filepath.read_bytes() + + # Entschlüsseln + if is_encrypted: + key = encryption_key.encode() if encryption_key else get_or_create_encryption_key() + try: + f = Fernet(key) + compressed_data = f.decrypt(encrypted_data) + except: + raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?") + else: + compressed_data = encrypted_data + + # Dekomprimieren + dump_data = gzip.decompress(compressed_data) + sql_commands = dump_data.decode('utf-8') + + # Bestehende Verbindungen schließen + cur.close() + conn.close() + + # Datenbank wiederherstellen + restore_command = [ + 'psql', + '-h', os.getenv("POSTGRES_HOST", "postgres"), + '-p', os.getenv("POSTGRES_PORT", "5432"), + '-U', os.getenv("POSTGRES_USER"), + '-d', os.getenv("POSTGRES_DB"), + '--no-password' + ] + + env = os.environ.copy() + env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") + + result = subprocess.run(restore_command, input=sql_commands, + capture_output=True, text=True, env=env) + + if result.returncode != 0: + raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}") + + # Audit-Log (neue Verbindung) + log_audit('RESTORE', 'database', backup_id, + additional_info=f"Backup wiederhergestellt: {filename}") + + return True, "Backup erfolgreich wiederhergestellt" + + except Exception as e: + logging.error(f"Wiederherstellung fehlgeschlagen: {e}") + return False, str(e) + +def send_backup_notification(success, filename, filesize=None, duration=None, error=None): + """Sendet E-Mail-Benachrichtigung (wenn konfiguriert)""" + if not os.getenv("EMAIL_ENABLED", "false").lower() == "true": + return + + # E-Mail-Funktion vorbereitet aber deaktiviert + # TODO: Implementieren wenn E-Mail-Server konfiguriert ist + logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}") + +# Scheduled Backup Job +def scheduled_backup(): + """Führt ein geplantes Backup aus""" + logging.info("Starte geplantes Backup...") + create_backup(backup_type="scheduled", created_by="scheduler") + +# Scheduler konfigurieren - täglich um 3:00 Uhr +scheduler.add_job( + scheduled_backup, + 'cron', + hour=3, + minute=0, + id='daily_backup', + replace_existing=True +) + +# Rate-Limiting Funktionen +def get_client_ip(): + """Ermittelt die echte IP-Adresse des Clients""" + # Debug logging + app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}") + + # Try X-Real-IP first (set by nginx) + if request.headers.get('X-Real-IP'): + return request.headers.get('X-Real-IP') + # Then X-Forwarded-For + elif request.headers.get('X-Forwarded-For'): + # X-Forwarded-For can contain multiple IPs, take the first one + return request.headers.get('X-Forwarded-For').split(',')[0].strip() + # Fallback to remote_addr + else: + return request.remote_addr + +def check_ip_blocked(ip_address): + """Prüft ob eine IP-Adresse gesperrt ist""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT blocked_until FROM login_attempts + WHERE ip_address = %s AND blocked_until IS NOT NULL + """, (ip_address,)) + + result = cur.fetchone() + cur.close() + conn.close() + + if result and result[0]: + if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None): + return True, result[0] + return False, None + +def record_failed_attempt(ip_address, username): + """Zeichnet einen fehlgeschlagenen Login-Versuch auf""" + conn = get_connection() + cur = conn.cursor() + + # Random Fehlermeldung + error_message = random.choice(FAIL_MESSAGES) + + try: + # Prüfen ob IP bereits existiert + cur.execute(""" + SELECT attempt_count FROM login_attempts + WHERE ip_address = %s + """, (ip_address,)) + + result = cur.fetchone() + + if result: + # Update bestehenden Eintrag + new_count = result[0] + 1 + blocked_until = None + + if new_count >= MAX_LOGIN_ATTEMPTS: + blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS) + # E-Mail-Benachrichtigung (wenn aktiviert) + if os.getenv("EMAIL_ENABLED", "false").lower() == "true": + send_security_alert_email(ip_address, username, new_count) + + cur.execute(""" + UPDATE login_attempts + SET attempt_count = %s, + last_attempt = CURRENT_TIMESTAMP, + blocked_until = %s, + last_username_tried = %s, + last_error_message = %s + WHERE ip_address = %s + """, (new_count, blocked_until, username, error_message, ip_address)) + else: + # Neuen Eintrag erstellen + cur.execute(""" + INSERT INTO login_attempts + (ip_address, attempt_count, last_username_tried, last_error_message) + VALUES (%s, 1, %s, %s) + """, (ip_address, username, error_message)) + + conn.commit() + + # Audit-Log + log_audit('LOGIN_FAILED', 'user', + additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}") + + except Exception as e: + print(f"Rate limiting error: {e}") + conn.rollback() + finally: + cur.close() + conn.close() + + return error_message + +def reset_login_attempts(ip_address): + """Setzt die Login-Versuche für eine IP zurück""" + conn = get_connection() + cur = conn.cursor() + + try: + cur.execute(""" + DELETE FROM login_attempts + WHERE ip_address = %s + """, (ip_address,)) + conn.commit() + except Exception as e: + print(f"Reset attempts error: {e}") + conn.rollback() + finally: + cur.close() + conn.close() + +def get_login_attempts(ip_address): + """Gibt die Anzahl der Login-Versuche für eine IP zurück""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT attempt_count FROM login_attempts + WHERE ip_address = %s + """, (ip_address,)) + + result = cur.fetchone() + cur.close() + conn.close() + + return result[0] if result else 0 + +def send_security_alert_email(ip_address, username, attempt_count): + """Sendet eine Sicherheitswarnung per E-Mail""" + subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche" + body = f""" + WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt! + + IP-Adresse: {ip_address} + Versuchter Benutzername: {username} + Anzahl Versuche: {attempt_count} + Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')} + + Die IP-Adresse wurde für 24 Stunden gesperrt. + + Dies ist eine automatische Nachricht vom v2-Docker Admin Panel. + """ + + # TODO: E-Mail-Versand implementieren wenn SMTP konfiguriert + logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") + print(f"E-Mail würde gesendet: {subject}") + +def verify_recaptcha(response): + """Verifiziert die reCAPTCHA v2 Response mit Google""" + secret_key = os.getenv('RECAPTCHA_SECRET_KEY') + + # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) + if not secret_key: + logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") + return True + + # Verifizierung bei Google + try: + verify_url = 'https://www.google.com/recaptcha/api/siteverify' + data = { + 'secret': secret_key, + 'response': response + } + + # Timeout für Request setzen + r = requests.post(verify_url, data=data, timeout=5) + result = r.json() + + # Log für Debugging + if not result.get('success'): + logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") + + return result.get('success', False) + + except requests.exceptions.RequestException as e: + logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") + # Bei Netzwerkfehlern CAPTCHA als bestanden werten + return True + except Exception as e: + logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") + return False + +def generate_license_key(license_type='full'): + """ + Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ + + AF = Account Factory (Produktkennung) + F/T = F für Fullversion, T für Testversion + YYYY = Jahr + MM = Monat + XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen + """ + # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l) + chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + + # Datum-Teil + now = datetime.now(ZoneInfo("Europe/Berlin")) + date_part = now.strftime('%Y%m') + type_char = 'F' if license_type == 'full' else 'T' + + # Zufällige Teile generieren (3 Blöcke à 4 Zeichen) + parts = [] + for _ in range(3): + part = ''.join(secrets.choice(chars) for _ in range(4)) + parts.append(part) + + # Key zusammensetzen + key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" + + return key + +def validate_license_key(key): + """ + Validiert das License Key Format + Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ + """ + if not key: + return False + + # Pattern für das neue Format + # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen + pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' + + # Großbuchstaben für Vergleich + return bool(re.match(pattern, key.upper())) + +@app.route("/login", methods=["GET", "POST"]) +def login(): + # Timing-Attack Schutz - Start Zeit merken + start_time = time.time() + + # IP-Adresse ermitteln + ip_address = get_client_ip() + + # Prüfen ob IP gesperrt ist + is_blocked, blocked_until = check_ip_blocked(ip_address) + if is_blocked: + time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 + error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." + return render_template("login.html", error=error_msg, error_type="blocked") + + # Anzahl bisheriger Versuche + attempt_count = get_login_attempts(ip_address) + + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + captcha_response = request.form.get("g-recaptcha-response") + + # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind + recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY') + if attempt_count >= CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: + if not captcha_response: + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA ERFORDERLICH!", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # CAPTCHA validieren + if not verify_recaptcha(captcha_response): + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # Check user in database first, fallback to env vars + user = get_user_by_username(username) + login_success = False + needs_2fa = False + + if user: + # Database user authentication + if verify_password(password, user['password_hash']): + login_success = True + needs_2fa = user['totp_enabled'] + else: + # Fallback to environment variables for backward compatibility + admin1_user = os.getenv("ADMIN1_USERNAME") + admin1_pass = os.getenv("ADMIN1_PASSWORD") + admin2_user = os.getenv("ADMIN2_USERNAME") + admin2_pass = os.getenv("ADMIN2_PASSWORD") + + if ((username == admin1_user and password == admin1_pass) or + (username == admin2_user and password == admin2_pass)): + login_success = True + + # Timing-Attack Schutz - Mindestens 1 Sekunde warten + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + + if login_success: + # Erfolgreicher Login + if needs_2fa: + # Store temporary session for 2FA verification + session['temp_username'] = username + session['temp_user_id'] = user['id'] + session['awaiting_2fa'] = True + return redirect(url_for('verify_2fa')) + else: + # Complete login without 2FA + session.permanent = True # Aktiviert das Timeout + session['logged_in'] = True + session['username'] = username + session['user_id'] = user['id'] if user else None + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + reset_login_attempts(ip_address) + log_audit('LOGIN_SUCCESS', 'user', + additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") + return redirect(url_for('dashboard')) + else: + # Fehlgeschlagener Login + error_message = record_failed_attempt(ip_address, username) + new_attempt_count = get_login_attempts(ip_address) + + # Prüfen ob jetzt gesperrt + is_now_blocked, _ = check_ip_blocked(ip_address) + if is_now_blocked: + log_audit('LOGIN_BLOCKED', 'security', + additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") + + return render_template("login.html", + error=error_message, + show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), + error_type="failed", + attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count), + recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) + + # GET Request + return render_template("login.html", + show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), + attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) + +@app.route("/logout") +def logout(): + username = session.get('username', 'unknown') + log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") + session.pop('logged_in', None) + session.pop('username', None) + session.pop('user_id', None) + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + return redirect(url_for('login')) + +@app.route("/verify-2fa", methods=["GET", "POST"]) +def verify_2fa(): + if not session.get('awaiting_2fa'): + return redirect(url_for('login')) + + if request.method == "POST": + token = request.form.get('token', '').replace(' ', '') + username = session.get('temp_username') + user_id = session.get('temp_user_id') + + if not username or not user_id: + flash('Session expired. Please login again.', 'error') + return redirect(url_for('login')) + + user = get_user_by_username(username) + if not user: + flash('User not found.', 'error') + return redirect(url_for('login')) + + # Check if it's a backup code + if len(token) == 8 and token.isupper(): + # Try backup code + backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] + if verify_backup_code(token, backup_codes): + # Remove used backup code + code_hash = hash_backup_code(token) + backup_codes.remove(code_hash) + + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", + (json.dumps(backup_codes), user_id)) + conn.commit() + cur.close() + conn.close() + + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + flash('Login successful using backup code. Please generate new backup codes.', 'warning') + log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") + return redirect(url_for('dashboard')) + else: + # Try TOTP token + if verify_totp(user['totp_secret'], token): + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") + return redirect(url_for('dashboard')) + + # Failed verification + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", + (datetime.now(), user_id)) + conn.commit() + cur.close() + conn.close() + + flash('Invalid authentication code. Please try again.', 'error') + log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") + + return render_template('verify_2fa.html') + +@app.route("/profile") +@login_required +def profile(): + user = get_user_by_username(session['username']) + if not user: + # For environment-based users, redirect with message + flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') + return redirect(url_for('dashboard')) + return render_template('profile.html', user=user) + +@app.route("/profile/change-password", methods=["POST"]) +@login_required +def change_password(): + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + user = get_user_by_username(session['username']) + + # Verify current password + if not verify_password(current_password, user['password_hash']): + flash('Current password is incorrect.', 'error') + return redirect(url_for('profile')) + + # Check new password + if new_password != confirm_password: + flash('New passwords do not match.', 'error') + return redirect(url_for('profile')) + + if len(new_password) < 8: + flash('Password must be at least 8 characters long.', 'error') + return redirect(url_for('profile')) + + # Update password + new_hash = hash_password(new_password) + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", + (new_hash, datetime.now(), user['id'])) + conn.commit() + cur.close() + conn.close() + + log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], + additional_info="Password changed successfully") + flash('Password changed successfully.', 'success') + return redirect(url_for('profile')) + +@app.route("/profile/setup-2fa") +@login_required +def setup_2fa(): + user = get_user_by_username(session['username']) + + if user['totp_enabled']: + flash('2FA is already enabled for your account.', 'info') + return redirect(url_for('profile')) + + # Generate new TOTP secret + totp_secret = generate_totp_secret() + session['temp_totp_secret'] = totp_secret + + # Generate QR code + qr_code = generate_qr_code(user['username'], totp_secret) + + return render_template('setup_2fa.html', + totp_secret=totp_secret, + qr_code=qr_code) + +@app.route("/profile/enable-2fa", methods=["POST"]) +@login_required +def enable_2fa(): + token = request.form.get('token', '').replace(' ', '') + totp_secret = session.get('temp_totp_secret') + + if not totp_secret: + flash('2FA setup session expired. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # Verify the token + if not verify_totp(totp_secret, token): + flash('Invalid authentication code. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # Generate backup codes + backup_codes = generate_backup_codes() + hashed_codes = [hash_backup_code(code) for code in backup_codes] + + # Enable 2FA + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + UPDATE users + SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s + WHERE username = %s + """, (totp_secret, json.dumps(hashed_codes), session['username'])) + conn.commit() + cur.close() + conn.close() + + session.pop('temp_totp_secret', None) + + log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") + + # Show backup codes + return render_template('backup_codes.html', backup_codes=backup_codes) + +@app.route("/profile/disable-2fa", methods=["POST"]) +@login_required +def disable_2fa(): + password = request.form.get('password') + user = get_user_by_username(session['username']) + + # Verify password + if not verify_password(password, user['password_hash']): + flash('Incorrect password.', 'error') + return redirect(url_for('profile')) + + # Disable 2FA + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + UPDATE users + SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL + WHERE username = %s + """, (session['username'],)) + conn.commit() + cur.close() + conn.close() + + log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") + flash('2FA has been disabled for your account.', 'success') + return redirect(url_for('profile')) + +@app.route("/heartbeat", methods=['POST']) +@login_required +def heartbeat(): + """Endpoint für Session Keep-Alive - aktualisiert last_activity""" + # Aktualisiere last_activity nur wenn explizit angefordert + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + # Force session save + session.modified = True + + return jsonify({ + 'status': 'ok', + 'last_activity': session['last_activity'], + 'username': session.get('username') + }) + +@app.route("/api/generate-license-key", methods=['POST']) +@login_required +def api_generate_key(): + """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" + try: + # Lizenztyp aus Request holen (default: full) + data = request.get_json() or {} + license_type = data.get('type', 'full') + + # Key generieren + key = generate_license_key(license_type) + + # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) + conn = get_connection() + cur = conn.cursor() + + # Wiederhole bis eindeutiger Key gefunden + attempts = 0 + while attempts < 10: # Max 10 Versuche + cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) + if not cur.fetchone(): + break # Key ist eindeutig + key = generate_license_key(license_type) + attempts += 1 + + cur.close() + conn.close() + + # Log für Audit + log_audit('GENERATE_KEY', 'license', + additional_info={'type': license_type, 'key': key}) + + return jsonify({ + 'success': True, + 'key': key, + 'type': license_type + }) + + except Exception as e: + logging.error(f"Fehler bei Key-Generierung: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Fehler bei der Key-Generierung' + }), 500 + +@app.route("/api/customers", methods=['GET']) +@login_required +def api_customers(): + """API Endpoint für die Kundensuche mit Select2""" + try: + # Suchparameter + search = request.args.get('q', '').strip() + page = request.args.get('page', 1, type=int) + per_page = 20 + customer_id = request.args.get('id', type=int) + + conn = get_connection() + cur = conn.cursor() + + # Einzelnen Kunden per ID abrufen + if customer_id: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.id = %s + GROUP BY c.id, c.name, c.email + """, (customer_id,)) + + customer = cur.fetchone() + results = [] + if customer: + results.append({ + 'id': customer[0], + 'text': f"{customer[1]} ({customer[2]})", + 'name': customer[1], + 'email': customer[2], + 'license_count': customer[3] + }) + + cur.close() + conn.close() + + return jsonify({ + 'results': results, + 'pagination': {'more': False} + }) + + # SQL Query mit optionaler Suche + elif search: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE LOWER(c.name) LIKE LOWER(%s) + OR LOWER(c.email) LIKE LOWER(%s) + GROUP BY c.id, c.name, c.email + ORDER BY c.name + LIMIT %s OFFSET %s + """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) + else: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + GROUP BY c.id, c.name, c.email + ORDER BY c.name + LIMIT %s OFFSET %s + """, (per_page, (page - 1) * per_page)) + + customers = cur.fetchall() + + # Format für Select2 + results = [] + for customer in customers: + results.append({ + 'id': customer[0], + 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", + 'name': customer[1], + 'email': customer[2], + 'license_count': customer[3] + }) + + # Gesamtanzahl für Pagination + if search: + cur.execute(""" + SELECT COUNT(*) FROM customers + WHERE LOWER(name) LIKE LOWER(%s) + OR LOWER(email) LIKE LOWER(%s) + """, (f'%{search}%', f'%{search}%')) + else: + cur.execute("SELECT COUNT(*) FROM customers") + + total_count = cur.fetchone()[0] + + cur.close() + conn.close() + + # Select2 Response Format + return jsonify({ + 'results': results, + 'pagination': { + 'more': (page * per_page) < total_count + } + }) + + except Exception as e: + logging.error(f"Fehler bei Kundensuche: {str(e)}") + return jsonify({ + 'results': [], + 'error': 'Fehler bei der Kundensuche' + }), 500 + +@app.route("/") +@login_required +def dashboard(): + conn = get_connection() + cur = conn.cursor() + + # Statistiken abrufen + # Gesamtanzahl Kunden (ohne Testdaten) + cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") + total_customers = cur.fetchone()[0] + + # Gesamtanzahl Lizenzen (ohne Testdaten) + cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") + total_licenses = cur.fetchone()[0] + + # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE + """) + active_licenses = cur.fetchone()[0] + + # Aktive Sessions + cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") + active_sessions_count = cur.fetchone()[0] + + # Abgelaufene Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until < CURRENT_DATE AND is_test = FALSE + """) + expired_licenses = cur.fetchone()[0] + + # Deaktivierte Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE is_active = FALSE AND is_test = FALSE + """) + inactive_licenses = cur.fetchone()[0] + + # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until >= CURRENT_DATE + AND valid_until < CURRENT_DATE + INTERVAL '30 days' + AND is_active = TRUE + AND is_test = FALSE + """) + expiring_soon = cur.fetchone()[0] + + # Testlizenzen vs Vollversionen (ohne Testdaten) + cur.execute(""" + SELECT license_type, COUNT(*) + FROM licenses + WHERE is_test = FALSE + GROUP BY license_type + """) + license_types = dict(cur.fetchall()) + + # Anzahl Testdaten + cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") + test_data_count = cur.fetchone()[0] + + # Anzahl Test-Kunden + cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") + test_customers_count = cur.fetchone()[0] + + # Anzahl Test-Ressourcen + cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") + test_resources_count = cur.fetchone()[0] + + # Letzte 5 erstellten Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT l.id, l.license_key, c.name, l.valid_until, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.is_test = FALSE + ORDER BY l.id DESC + LIMIT 5 + """) + recent_licenses = cur.fetchall() + + # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) + cur.execute(""" + SELECT l.id, l.license_key, c.name, l.valid_until, + l.valid_until - CURRENT_DATE as days_left + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.valid_until >= CURRENT_DATE + AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' + AND l.is_active = TRUE + AND l.is_test = FALSE + ORDER BY l.valid_until + LIMIT 10 + """) + expiring_licenses = cur.fetchall() + + # Letztes Backup + cur.execute(""" + SELECT created_at, filesize, duration_seconds, backup_type, status + FROM backup_history + ORDER BY created_at DESC + LIMIT 1 + """) + last_backup_info = cur.fetchone() + + # Sicherheitsstatistiken + # Gesperrte IPs + cur.execute(""" + SELECT COUNT(*) FROM login_attempts + WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP + """) + blocked_ips_count = cur.fetchone()[0] + + # Fehlversuche heute + cur.execute(""" + SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts + WHERE last_attempt::date = CURRENT_DATE + """) + failed_attempts_today = cur.fetchone()[0] + + # Letzte 5 Sicherheitsereignisse + cur.execute(""" + SELECT + la.ip_address, + la.attempt_count, + la.last_attempt, + la.blocked_until, + la.last_username_tried, + la.last_error_message + FROM login_attempts la + ORDER BY la.last_attempt DESC + LIMIT 5 + """) + recent_security_events = [] + for event in cur.fetchall(): + recent_security_events.append({ + 'ip_address': event[0], + 'attempt_count': event[1], + 'last_attempt': event[2].strftime('%d.%m %H:%M'), + 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, + 'username_tried': event[4], + 'error_message': event[5] + }) + + # Sicherheitslevel berechnen + if blocked_ips_count > 5 or failed_attempts_today > 50: + security_level = 'danger' + security_level_text = 'KRITISCH' + elif blocked_ips_count > 2 or failed_attempts_today > 20: + security_level = 'warning' + security_level_text = 'ERHÖHT' + else: + security_level = 'success' + security_level_text = 'NORMAL' + + # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + WHERE is_test = FALSE + GROUP BY resource_type + """) + + resource_stats = {} + resource_warning = None + + for row in cur.fetchall(): + available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) + resource_stats[row[0]] = { + 'available': row[1], + 'allocated': row[2], + 'quarantine': row[3], + 'total': row[4], + 'available_percent': available_percent, + 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' + } + + # Warnung bei niedrigem Bestand + if row[1] < 50: + if not resource_warning: + resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" + else: + resource_warning += f" | {row[0].upper()}: {row[1]}" + + cur.close() + conn.close() + + stats = { + 'total_customers': total_customers, + 'total_licenses': total_licenses, + 'active_licenses': active_licenses, + 'expired_licenses': expired_licenses, + 'inactive_licenses': inactive_licenses, + 'expiring_soon': expiring_soon, + 'full_licenses': license_types.get('full', 0), + 'test_licenses': license_types.get('test', 0), + 'test_data_count': test_data_count, + 'test_customers_count': test_customers_count, + 'test_resources_count': test_resources_count, + 'recent_licenses': recent_licenses, + 'expiring_licenses': expiring_licenses, + 'active_sessions': active_sessions_count, + 'last_backup': last_backup_info, + # Sicherheitsstatistiken + 'blocked_ips_count': blocked_ips_count, + 'failed_attempts_today': failed_attempts_today, + 'recent_security_events': recent_security_events, + 'security_level': security_level, + 'security_level_text': security_level_text, + 'resource_stats': resource_stats + } + + return render_template("dashboard.html", + stats=stats, + resource_stats=resource_stats, + resource_warning=resource_warning, + username=session.get('username')) + +@app.route("/create", methods=["GET", "POST"]) +@login_required +def create_license(): + if request.method == "POST": + customer_id = request.form.get("customer_id") + license_key = request.form["license_key"].upper() # Immer Großbuchstaben + license_type = request.form["license_type"] + valid_from = request.form["valid_from"] + is_test = request.form.get("is_test") == "on" # Checkbox value + + # Berechne valid_until basierend auf Laufzeit + duration = int(request.form.get("duration", 1)) + duration_type = request.form.get("duration_type", "years") + + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta + + start_date = datetime.strptime(valid_from, "%Y-%m-%d") + + if duration_type == "days": + end_date = start_date + timedelta(days=duration) + elif duration_type == "months": + end_date = start_date + relativedelta(months=duration) + else: # years + end_date = start_date + relativedelta(years=duration) + + # Ein Tag abziehen, da der Starttag mitgezählt wird + end_date = end_date - timedelta(days=1) + valid_until = end_date.strftime("%Y-%m-%d") + + # Validiere License Key Format + if not validate_license_key(license_key): + flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') + return redirect(url_for('create_license')) + + # Resource counts + domain_count = int(request.form.get("domain_count", 1)) + ipv4_count = int(request.form.get("ipv4_count", 1)) + phone_count = int(request.form.get("phone_count", 1)) + device_limit = int(request.form.get("device_limit", 3)) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfe ob neuer Kunde oder bestehender + if customer_id == "new": + # Neuer Kunde + name = request.form.get("customer_name") + email = request.form.get("email") + + if not name: + flash('Kundenname ist erforderlich!', 'error') + return redirect(url_for('create_license')) + + # Prüfe ob E-Mail bereits existiert + if email: + cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) + existing = cur.fetchone() + if existing: + flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') + return redirect(url_for('create_license')) + + # Kunde einfügen (erbt Test-Status von Lizenz) + cur.execute(""" + INSERT INTO customers (name, email, is_test, created_at) + VALUES (%s, %s, %s, NOW()) + RETURNING id + """, (name, email, is_test)) + customer_id = cur.fetchone()[0] + customer_info = {'name': name, 'email': email, 'is_test': is_test} + + # Audit-Log für neuen Kunden + log_audit('CREATE', 'customer', customer_id, + new_values={'name': name, 'email': email, 'is_test': is_test}) + else: + # Bestehender Kunde - hole Infos für Audit-Log + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + customer_data = cur.fetchone() + if not customer_data: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('create_license')) + customer_info = {'name': customer_data[0], 'email': customer_data[1]} + + # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren + if customer_data[2]: # is_test des Kunden + is_test = True + + # Lizenz hinzufügen + cur.execute(""" + INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, + domain_count, ipv4_count, phone_count, device_limit, is_test) + VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) + RETURNING id + """, (license_key, customer_id, license_type, valid_from, valid_until, + domain_count, ipv4_count, phone_count, device_limit, is_test)) + license_id = cur.fetchone()[0] + + # Ressourcen zuweisen + try: + # Prüfe Verfügbarkeit + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones + """, (is_test, is_test, is_test)) + available = cur.fetchone() + + if available[0] < domain_count: + raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") + if available[1] < ipv4_count: + raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") + if available[2] < phone_count: + raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") + + # Domains zuweisen + if domain_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, domain_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # IPv4s zuweisen + if ipv4_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, ipv4_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # Telefonnummern zuweisen + if phone_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, phone_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + except ValueError as e: + conn.rollback() + flash(str(e), 'error') + return redirect(url_for('create_license')) + + conn.commit() + + # Audit-Log + log_audit('CREATE', 'license', license_id, + new_values={ + 'license_key': license_key, + 'customer_name': customer_info['name'], + 'customer_email': customer_info['email'], + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'device_limit': device_limit, + 'is_test': is_test + }) + + flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') + + except Exception as e: + conn.rollback() + logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") + flash('Fehler beim Erstellen der Lizenz!', 'error') + finally: + cur.close() + conn.close() + + # Preserve show_test parameter if present + redirect_url = "/create" + if request.args.get('show_test') == 'true': + redirect_url += "?show_test=true" + return redirect(redirect_url) + + # Unterstützung für vorausgewählten Kunden + preselected_customer_id = request.args.get('customer_id', type=int) + return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) + +@app.route("/batch", methods=["GET", "POST"]) +@login_required +def batch_licenses(): + """Batch-Generierung mehrerer Lizenzen für einen Kunden""" + if request.method == "POST": + # Formulardaten + customer_id = request.form.get("customer_id") + license_type = request.form["license_type"] + quantity = int(request.form["quantity"]) + valid_from = request.form["valid_from"] + is_test = request.form.get("is_test") == "on" # Checkbox value + + # Berechne valid_until basierend auf Laufzeit + duration = int(request.form.get("duration", 1)) + duration_type = request.form.get("duration_type", "years") + + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta + + start_date = datetime.strptime(valid_from, "%Y-%m-%d") + + if duration_type == "days": + end_date = start_date + timedelta(days=duration) + elif duration_type == "months": + end_date = start_date + relativedelta(months=duration) + else: # years + end_date = start_date + relativedelta(years=duration) + + # Ein Tag abziehen, da der Starttag mitgezählt wird + end_date = end_date - timedelta(days=1) + valid_until = end_date.strftime("%Y-%m-%d") + + # Resource counts + domain_count = int(request.form.get("domain_count", 1)) + ipv4_count = int(request.form.get("ipv4_count", 1)) + phone_count = int(request.form.get("phone_count", 1)) + device_limit = int(request.form.get("device_limit", 3)) + + # Sicherheitslimit + if quantity < 1 or quantity > 100: + flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') + return redirect(url_for('batch_licenses')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfe ob neuer Kunde oder bestehender + if customer_id == "new": + # Neuer Kunde + name = request.form.get("customer_name") + email = request.form.get("email") + + if not name: + flash('Kundenname ist erforderlich!', 'error') + return redirect(url_for('batch_licenses')) + + # Prüfe ob E-Mail bereits existiert + if email: + cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) + existing = cur.fetchone() + if existing: + flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') + return redirect(url_for('batch_licenses')) + + # Kunde einfügen (erbt Test-Status von Lizenz) + cur.execute(""" + INSERT INTO customers (name, email, is_test, created_at) + VALUES (%s, %s, %s, NOW()) + RETURNING id + """, (name, email, is_test)) + customer_id = cur.fetchone()[0] + + # Audit-Log für neuen Kunden + log_audit('CREATE', 'customer', customer_id, + new_values={'name': name, 'email': email, 'is_test': is_test}) + else: + # Bestehender Kunde - hole Infos + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + customer_data = cur.fetchone() + if not customer_data: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('batch_licenses')) + name = customer_data[0] + email = customer_data[1] + + # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren + if customer_data[2]: # is_test des Kunden + is_test = True + + # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch + total_domains_needed = domain_count * quantity + total_ipv4s_needed = ipv4_count * quantity + total_phones_needed = phone_count * quantity + + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones + """, (is_test, is_test, is_test)) + available = cur.fetchone() + + if available[0] < total_domains_needed: + flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') + return redirect(url_for('batch_licenses')) + if available[1] < total_ipv4s_needed: + flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') + return redirect(url_for('batch_licenses')) + if available[2] < total_phones_needed: + flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') + return redirect(url_for('batch_licenses')) + + # Lizenzen generieren und speichern + generated_licenses = [] + for i in range(quantity): + # Eindeutigen Key generieren + attempts = 0 + while attempts < 10: + license_key = generate_license_key(license_type) + cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) + if not cur.fetchone(): + break + attempts += 1 + + # Lizenz einfügen + cur.execute(""" + INSERT INTO licenses (license_key, customer_id, license_type, is_test, + valid_from, valid_until, is_active, + domain_count, ipv4_count, phone_count, device_limit) + VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) + RETURNING id + """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, + domain_count, ipv4_count, phone_count, device_limit)) + license_id = cur.fetchone()[0] + + # Ressourcen für diese Lizenz zuweisen + # Domains + if domain_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, domain_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # IPv4s + if ipv4_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, ipv4_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # Telefonnummern + if phone_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, phone_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + generated_licenses.append({ + 'id': license_id, + 'key': license_key, + 'type': license_type + }) + + conn.commit() + + # Audit-Log + log_audit('CREATE_BATCH', 'license', + new_values={'customer': name, 'quantity': quantity, 'type': license_type}, + additional_info=f"Batch-Generierung von {quantity} Lizenzen") + + # Session für Export speichern + session['batch_export'] = { + 'customer': name, + 'email': email, + 'licenses': generated_licenses, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + } + + flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') + return render_template("batch_result.html", + customer=name, + email=email, + licenses=generated_licenses, + valid_from=valid_from, + valid_until=valid_until) + + except Exception as e: + conn.rollback() + logging.error(f"Fehler bei Batch-Generierung: {str(e)}") + flash('Fehler bei der Batch-Generierung!', 'error') + return redirect(url_for('batch_licenses')) + finally: + cur.close() + conn.close() + + # GET Request + return render_template("batch_form.html") + +@app.route("/batch/export") +@login_required +def export_batch(): + """Exportiert die zuletzt generierten Batch-Lizenzen""" + batch_data = session.get('batch_export') + if not batch_data: + flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') + return redirect(url_for('batch_licenses')) + + # CSV generieren + output = io.StringIO() + output.write('\ufeff') # UTF-8 BOM für Excel + + # Header + output.write(f"Kunde: {batch_data['customer']}\n") + output.write(f"E-Mail: {batch_data['email']}\n") + output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") + output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") + output.write("\n") + output.write("Nr;Lizenzschlüssel;Typ\n") + + # Lizenzen + for i, license in enumerate(batch_data['licenses'], 1): + typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" + output.write(f"{i};{license['key']};{typ_text}\n") + + output.seek(0) + + # Audit-Log + log_audit('EXPORT', 'batch_licenses', + additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" + ) + +@app.route("/licenses") +@login_required +def licenses(): + # Redirect zur kombinierten Ansicht + return redirect("/customers-licenses") + +@app.route("/license/edit/", methods=["GET", "POST"]) +@login_required +def edit_license(license_id): + conn = get_connection() + cur = conn.cursor() + + if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute(""" + SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit + FROM licenses WHERE id = %s + """, (license_id,)) + old_license = cur.fetchone() + + # Update license + license_key = request.form["license_key"] + license_type = request.form["license_type"] + valid_from = request.form["valid_from"] + valid_until = request.form["valid_until"] + is_active = request.form.get("is_active") == "on" + is_test = request.form.get("is_test") == "on" + device_limit = int(request.form.get("device_limit", 3)) + + cur.execute(""" + UPDATE licenses + SET license_key = %s, license_type = %s, valid_from = %s, + valid_until = %s, is_active = %s, is_test = %s, device_limit = %s + WHERE id = %s + """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'license_key': old_license[0], + 'license_type': old_license[1], + 'valid_from': str(old_license[2]), + 'valid_until': str(old_license[3]), + 'is_active': old_license[4], + 'is_test': old_license[5], + 'device_limit': old_license[6] + }, + new_values={ + 'license_key': license_key, + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'is_active': is_active, + 'is_test': is_test, + 'device_limit': device_limit + }) + + cur.close() + conn.close() + + # Redirect zurück zu customers-licenses mit beibehaltenen Parametern + redirect_url = "/customers-licenses" + + # Behalte show_test Parameter bei (aus Form oder GET-Parameter) + show_test = request.form.get('show_test') or request.args.get('show_test') + if show_test == 'true': + redirect_url += "?show_test=true" + + # Behalte customer_id bei wenn vorhanden + if request.referrer and 'customer_id=' in request.referrer: + import re + match = re.search(r'customer_id=(\d+)', request.referrer) + if match: + connector = "&" if "?" in redirect_url else "?" + redirect_url += f"{connector}customer_id={match.group(1)}" + + return redirect(redirect_url) + + # Get license data + cur.execute(""" + SELECT l.id, l.license_key, c.name, c.email, l.license_type, + l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + + license = cur.fetchone() + cur.close() + conn.close() + + if not license: + return redirect("/licenses") + + return render_template("edit_license.html", license=license, username=session.get('username')) + +@app.route("/license/delete/", methods=["POST"]) +@login_required +def delete_license(license_id): + conn = get_connection() + cur = conn.cursor() + + # Lizenzdetails für Audit-Log abrufen + cur.execute(""" + SELECT l.license_key, c.name, l.license_type + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + license_info = cur.fetchone() + + cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) + + conn.commit() + + # Audit-Log + if license_info: + log_audit('DELETE', 'license', license_id, + old_values={ + 'license_key': license_info[0], + 'customer_name': license_info[1], + 'license_type': license_info[2] + }) + + cur.close() + conn.close() + + return redirect("/licenses") + +@app.route("/customers") +@login_required +def customers(): + # Redirect zur kombinierten Ansicht + return redirect("/customers-licenses") + +@app.route("/customer/edit/", methods=["GET", "POST"]) +@login_required +def edit_customer(customer_id): + conn = get_connection() + cur = conn.cursor() + + if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + old_customer = cur.fetchone() + + # Update customer + name = request.form["name"] + email = request.form["email"] + is_test = request.form.get("is_test") == "on" + + cur.execute(""" + UPDATE customers + SET name = %s, email = %s, is_test = %s + WHERE id = %s + """, (name, email, is_test, customer_id)) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'customer', customer_id, + old_values={ + 'name': old_customer[0], + 'email': old_customer[1], + 'is_test': old_customer[2] + }, + new_values={ + 'name': name, + 'email': email, + 'is_test': is_test + }) + + cur.close() + conn.close() + + # Redirect zurück zu customers-licenses mit beibehaltenen Parametern + redirect_url = "/customers-licenses" + + # Behalte show_test Parameter bei (aus Form oder GET-Parameter) + show_test = request.form.get('show_test') or request.args.get('show_test') + if show_test == 'true': + redirect_url += "?show_test=true" + + # Behalte customer_id bei (immer der aktuelle Kunde) + connector = "&" if "?" in redirect_url else "?" + redirect_url += f"{connector}customer_id={customer_id}" + + return redirect(redirect_url) + + # Get customer data with licenses + cur.execute(""" + SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s + """, (customer_id,)) + + customer = cur.fetchone() + if not customer: + cur.close() + conn.close() + return "Kunde nicht gefunden", 404 + + + # Get customer's licenses + cur.execute(""" + SELECT id, license_key, license_type, valid_from, valid_until, is_active + FROM licenses + WHERE customer_id = %s + ORDER BY valid_until DESC + """, (customer_id,)) + + licenses = cur.fetchall() + + cur.close() + conn.close() + + if not customer: + return redirect("/customers-licenses") + + return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) + +@app.route("/customer/create", methods=["GET", "POST"]) +@login_required +def create_customer(): + """Erstellt einen neuen Kunden ohne Lizenz""" + if request.method == "POST": + name = request.form.get('name') + email = request.form.get('email') + is_test = request.form.get('is_test') == 'on' + + if not name or not email: + flash("Name und E-Mail sind Pflichtfelder!", "error") + return render_template("create_customer.html", username=session.get('username')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfen ob E-Mail bereits existiert + cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) + existing = cur.fetchone() + if existing: + flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") + return render_template("create_customer.html", username=session.get('username')) + + # Kunde erstellen + cur.execute(""" + INSERT INTO customers (name, email, created_at, is_test) + VALUES (%s, %s, %s, %s) RETURNING id + """, (name, email, datetime.now(), is_test)) + + customer_id = cur.fetchone()[0] + conn.commit() + + # Audit-Log + log_audit('CREATE', 'customer', customer_id, + new_values={ + 'name': name, + 'email': email, + 'is_test': is_test + }) + + flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") + return redirect(f"/customer/edit/{customer_id}") + + except Exception as e: + conn.rollback() + flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") + return render_template("create_customer.html", username=session.get('username')) + finally: + cur.close() + conn.close() + + # GET Request - Formular anzeigen + return render_template("create_customer.html", username=session.get('username')) + +@app.route("/customer/delete/", methods=["POST"]) +@login_required +def delete_customer(customer_id): + conn = get_connection() + cur = conn.cursor() + + # Prüfen ob Kunde Lizenzen hat + cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) + license_count = cur.fetchone()[0] + + if license_count > 0: + # Kunde hat Lizenzen - nicht löschen + cur.close() + conn.close() + return redirect("/customers") + + # Kundendetails für Audit-Log abrufen + cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) + customer_info = cur.fetchone() + + # Kunde löschen wenn keine Lizenzen vorhanden + cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) + + conn.commit() + + # Audit-Log + if customer_info: + log_audit('DELETE', 'customer', customer_id, + old_values={ + 'name': customer_info[0], + 'email': customer_info[1] + }) + + cur.close() + conn.close() + + return redirect("/customers") + +@app.route("/customers-licenses") +@login_required +def customers_licenses(): + """Kombinierte Ansicht für Kunden und deren Lizenzen""" + conn = get_connection() + cur = conn.cursor() + + # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + query = """ + SELECT + c.id, + c.name, + c.email, + c.created_at, + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + """ + + if not show_test: + query += " WHERE c.is_test = FALSE" + + query += """ + GROUP BY c.id, c.name, c.email, c.created_at + ORDER BY c.name + """ + + cur.execute(query) + customers = cur.fetchall() + + # Hole ausgewählten Kunden nur wenn explizit in URL angegeben + selected_customer_id = request.args.get('customer_id', type=int) + licenses = [] + selected_customer = None + + if customers and selected_customer_id: + # Hole Daten des ausgewählten Kunden + for customer in customers: + if customer[0] == selected_customer_id: + selected_customer = customer + break + + # Hole Lizenzen des ausgewählten Kunden + if selected_customer: + cur.execute(""" + SELECT + l.id, + l.license_key, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status, + l.domain_count, + l.ipv4_count, + l.phone_count, + l.device_limit, + (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, + -- Actual resource counts + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count + FROM licenses l + WHERE l.customer_id = %s + ORDER BY l.created_at DESC, l.id DESC + """, (selected_customer_id,)) + licenses = cur.fetchall() + + cur.close() + conn.close() + + return render_template("customers_licenses.html", + customers=customers, + selected_customer=selected_customer, + selected_customer_id=selected_customer_id, + licenses=licenses, + show_test=show_test) + +@app.route("/api/customer//licenses") +@login_required +def api_customer_licenses(customer_id): + """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" + conn = get_connection() + cur = conn.cursor() + + # Hole Lizenzen des Kunden + cur.execute(""" + SELECT + l.id, + l.license_key, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status, + l.domain_count, + l.ipv4_count, + l.phone_count, + l.device_limit, + (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, + -- Actual resource counts + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count + FROM licenses l + WHERE l.customer_id = %s + ORDER BY l.created_at DESC, l.id DESC + """, (customer_id,)) + + licenses = [] + for row in cur.fetchall(): + license_id = row[0] + + # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz + cur.execute(""" + SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at + FROM resource_pools rp + JOIN license_resources lr ON rp.id = lr.resource_id + WHERE lr.license_id = %s AND lr.is_active = true + ORDER BY rp.resource_type, rp.resource_value + """, (license_id,)) + + resources = { + 'domains': [], + 'ipv4s': [], + 'phones': [] + } + + for res_row in cur.fetchall(): + resource_info = { + 'id': res_row[0], + 'value': res_row[2], + 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' + } + + if res_row[1] == 'domain': + resources['domains'].append(resource_info) + elif res_row[1] == 'ipv4': + resources['ipv4s'].append(resource_info) + elif res_row[1] == 'phone': + resources['phones'].append(resource_info) + + licenses.append({ + 'id': row[0], + 'license_key': row[1], + 'license_type': row[2], + 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', + 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', + 'is_active': row[5], + 'status': row[6], + 'domain_count': row[7], # limit + 'ipv4_count': row[8], # limit + 'phone_count': row[9], # limit + 'device_limit': row[10], + 'active_devices': row[11], + 'actual_domain_count': row[12], # actual count + 'actual_ipv4_count': row[13], # actual count + 'actual_phone_count': row[14], # actual count + 'resources': resources + }) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'licenses': licenses, + 'count': len(licenses) + }) + +@app.route("/api/customer//quick-stats") +@login_required +def api_customer_quick_stats(customer_id): + """API-Endpoint für Schnellstatistiken eines Kunden""" + conn = get_connection() + cur = conn.cursor() + + # Hole Kundenstatistiken + cur.execute(""" + SELECT + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon + FROM licenses l + WHERE l.customer_id = %s + """, (customer_id,)) + + stats = cur.fetchone() + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'stats': { + 'total': stats[0], + 'active': stats[1], + 'expired': stats[2], + 'expiring_soon': stats[3] + } + }) + +@app.route("/api/license//quick-edit", methods=['POST']) +@login_required +def api_license_quick_edit(license_id): + """API-Endpoint für schnelle Lizenz-Bearbeitung""" + conn = get_connection() + cur = conn.cursor() + + try: + data = request.get_json() + + # Hole alte Werte für Audit-Log + cur.execute(""" + SELECT is_active, valid_until, license_type + FROM licenses WHERE id = %s + """, (license_id,)) + old_values = cur.fetchone() + + if not old_values: + return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 + + # Update-Felder vorbereiten + updates = [] + params = [] + new_values = {} + + if 'is_active' in data: + updates.append("is_active = %s") + params.append(data['is_active']) + new_values['is_active'] = data['is_active'] + + if 'valid_until' in data: + updates.append("valid_until = %s") + params.append(data['valid_until']) + new_values['valid_until'] = data['valid_until'] + + if 'license_type' in data: + updates.append("license_type = %s") + params.append(data['license_type']) + new_values['license_type'] = data['license_type'] + + if updates: + params.append(license_id) + cur.execute(f""" + UPDATE licenses + SET {', '.join(updates)} + WHERE id = %s + """, params) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'is_active': old_values[0], + 'valid_until': old_values[1].isoformat() if old_values[1] else None, + 'license_type': old_values[2] + }, + new_values=new_values) + + cur.close() + conn.close() + + return jsonify({'success': True}) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/api/license//resources") +@login_required +def api_license_resources(license_id): + """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" + conn = get_connection() + cur = conn.cursor() + + try: + # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz + cur.execute(""" + SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at + FROM resource_pools rp + JOIN license_resources lr ON rp.id = lr.resource_id + WHERE lr.license_id = %s AND lr.is_active = true + ORDER BY rp.resource_type, rp.resource_value + """, (license_id,)) + + resources = { + 'domains': [], + 'ipv4s': [], + 'phones': [] + } + + for row in cur.fetchall(): + resource_info = { + 'id': row[0], + 'value': row[2], + 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' + } + + if row[1] == 'domain': + resources['domains'].append(resource_info) + elif row[1] == 'ipv4': + resources['ipv4s'].append(resource_info) + elif row[1] == 'phone': + resources['phones'].append(resource_info) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'resources': resources + }) + + except Exception as e: + cur.close() + conn.close() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/sessions") +@login_required +def sessions(): + conn = get_connection() + cur = conn.cursor() + + # Sortierparameter + active_sort = request.args.get('active_sort', 'last_heartbeat') + active_order = request.args.get('active_order', 'desc') + ended_sort = request.args.get('ended_sort', 'ended_at') + ended_order = request.args.get('ended_order', 'desc') + + # Whitelist für erlaubte Sortierfelder - Aktive Sessions + active_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'last_heartbeat': 's.last_heartbeat', + 'inactive': 'minutes_inactive' + } + + # Whitelist für erlaubte Sortierfelder - Beendete Sessions + ended_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'ended_at': 's.ended_at', + 'duration': 'duration_minutes' + } + + # Validierung + if active_sort not in active_sort_fields: + active_sort = 'last_heartbeat' + if ended_sort not in ended_sort_fields: + ended_sort = 'ended_at' + if active_order not in ['asc', 'desc']: + active_order = 'desc' + if ended_order not in ['asc', 'desc']: + ended_order = 'desc' + + # Aktive Sessions abrufen + cur.execute(f""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.user_agent, s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = TRUE + ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} + """) + active_sessions = cur.fetchall() + + # Inaktive Sessions der letzten 24 Stunden + cur.execute(f""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = FALSE + AND s.ended_at > NOW() - INTERVAL '24 hours' + ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} + LIMIT 50 + """) + recent_sessions = cur.fetchall() + + cur.close() + conn.close() + + return render_template("sessions.html", + active_sessions=active_sessions, + recent_sessions=recent_sessions, + active_sort=active_sort, + active_order=active_order, + ended_sort=ended_sort, + ended_order=ended_order, + username=session.get('username')) + +@app.route("/session/end/", methods=["POST"]) +@login_required +def end_session(session_id): + conn = get_connection() + cur = conn.cursor() + + # Session beenden + cur.execute(""" + UPDATE sessions + SET is_active = FALSE, ended_at = NOW() + WHERE id = %s AND is_active = TRUE + """, (session_id,)) + + conn.commit() + cur.close() + conn.close() + + return redirect("/sessions") + +@app.route("/export/licenses") +@login_required +def export_licenses(): + conn = get_connection() + cur = conn.cursor() + + # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) + include_test = request.args.get('include_test', 'false').lower() == 'true' + customer_id = request.args.get('customer_id', type=int) + + query = """ + SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, + l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, + CASE + WHEN l.is_active = FALSE THEN 'Deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' + ELSE 'Aktiv' + END as status + FROM licenses l + JOIN customers c ON l.customer_id = c.id + """ + + # Build WHERE clause + where_conditions = [] + params = [] + + if not include_test: + where_conditions.append("l.is_test = FALSE") + + if customer_id: + where_conditions.append("l.customer_id = %s") + params.append(customer_id) + + if where_conditions: + query += " WHERE " + " AND ".join(where_conditions) + + query += " ORDER BY l.id" + + cur.execute(query, params) + + # Spaltennamen + columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', + 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] + + # Daten in DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + # Datumsformatierung + df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') + df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') + + # Typ und Aktiv Status anpassen + df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) + df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) + df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) + + cur.close() + conn.close() + + # Export Format + export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'license', + additional_info=f"Export aller Lizenzen als {export_format.upper()}") + filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Lizenzen', index=False) + + # Formatierung + worksheet = writer.sheets['Lizenzen'] + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/audit") +@login_required +def export_audit(): + conn = get_connection() + cur = conn.cursor() + + # Holen der Filter-Parameter + filter_user = request.args.get('user', '') + filter_action = request.args.get('action', '') + filter_entity = request.args.get('entity', '') + export_format = request.args.get('format', 'excel') + + # SQL Query mit Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + params = [] + + if filter_user: + query += " AND username ILIKE %s" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + query += " ORDER BY timestamp DESC" + + cur.execute(query, params) + audit_logs = cur.fetchall() + cur.close() + conn.close() + + # Daten für Export vorbereiten + data = [] + for log in audit_logs: + action_text = { + 'CREATE': 'Erstellt', + 'UPDATE': 'Bearbeitet', + 'DELETE': 'Gelöscht', + 'LOGIN': 'Anmeldung', + 'LOGOUT': 'Abmeldung', + 'AUTO_LOGOUT': 'Auto-Logout', + 'EXPORT': 'Export', + 'GENERATE_KEY': 'Key generiert', + 'CREATE_BATCH': 'Batch erstellt', + 'BACKUP': 'Backup erstellt', + 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', + 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', + 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', + 'LOGIN_BLOCKED': 'Login-Blockiert', + 'RESTORE': 'Wiederhergestellt', + 'PASSWORD_CHANGE': 'Passwort geändert', + '2FA_ENABLED': '2FA aktiviert', + '2FA_DISABLED': '2FA deaktiviert' + }.get(log[3], log[3]) + + data.append({ + 'ID': log[0], + 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), + 'Benutzer': log[2], + 'Aktion': action_text, + 'Entität': log[4], + 'Entität-ID': log[5] or '', + 'IP-Adresse': log[8] or '', + 'Zusatzinfo': log[10] or '' + }) + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'audit_log_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'audit_log', + additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Audit Log') + + # Spaltenbreiten anpassen + worksheet = writer.sheets['Audit Log'] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/customers") +@login_required +def export_customers(): + conn = get_connection() + cur = conn.cursor() + + # Check if test data should be included + include_test = request.args.get('include_test', 'false').lower() == 'true' + + # Build query based on test data filter + if include_test: + # Include all customers + query = """ + SELECT c.id, c.name, c.email, c.created_at, c.is_test, + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + GROUP BY c.id, c.name, c.email, c.created_at, c.is_test + ORDER BY c.id + """ + else: + # Exclude test customers and test licenses + query = """ + SELECT c.id, c.name, c.email, c.created_at, c.is_test, + COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.is_test = FALSE + GROUP BY c.id, c.name, c.email, c.created_at, c.is_test + ORDER BY c.id + """ + + cur.execute(query) + + # Spaltennamen + columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', + 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] + + # Daten in DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + # Datumsformatierung + df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') + + # Testdaten formatting + df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) + + cur.close() + conn.close() + + # Export Format + export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'customer', + additional_info=f"Export aller Kunden als {export_format.upper()}") + filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Kunden', index=False) + + # Formatierung + worksheet = writer.sheets['Kunden'] + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/sessions") +@login_required +def export_sessions(): + conn = get_connection() + cur = conn.cursor() + + # Holen des Session-Typs (active oder ended) + session_type = request.args.get('type', 'active') + export_format = request.args.get('format', 'excel') + + # Daten je nach Typ abrufen + if session_type == 'active': + # Aktive Lizenz-Sessions + cur.execute(""" + SELECT s.id, l.license_key, c.name as customer_name, s.session_id, + s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, + s.ip_address, s.user_agent + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = true + ORDER BY s.last_heartbeat DESC + """) + sessions = cur.fetchall() + + # Daten für Export vorbereiten + data = [] + for sess in sessions: + duration = sess[6] + hours = duration // 3600 + minutes = (duration % 3600) // 60 + seconds = duration % 60 + + data.append({ + 'Session-ID': sess[0], + 'Lizenzschlüssel': sess[1], + 'Kunde': sess[2], + 'Session-ID (Tech)': sess[3], + 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), + 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), + 'Dauer': f"{hours}h {minutes}m {seconds}s", + 'IP-Adresse': sess[7], + 'Browser': sess[8] + }) + + sheet_name = 'Aktive Sessions' + filename_prefix = 'aktive_sessions' + else: + # Beendete Lizenz-Sessions + cur.execute(""" + SELECT s.id, l.license_key, c.name as customer_name, s.session_id, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, + s.ip_address, s.user_agent + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = false AND s.ended_at IS NOT NULL + ORDER BY s.ended_at DESC + LIMIT 1000 + """) + sessions = cur.fetchall() + + # Daten für Export vorbereiten + data = [] + for sess in sessions: + duration = sess[6] if sess[6] else 0 + hours = duration // 3600 + minutes = (duration % 3600) // 60 + seconds = duration % 60 + + data.append({ + 'Session-ID': sess[0], + 'Lizenzschlüssel': sess[1], + 'Kunde': sess[2], + 'Session-ID (Tech)': sess[3], + 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), + 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', + 'Dauer': f"{hours}h {minutes}m {seconds}s", + 'IP-Adresse': sess[7], + 'Browser': sess[8] + }) + + sheet_name = 'Beendete Sessions' + filename_prefix = 'beendete_sessions' + + cur.close() + conn.close() + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'{filename_prefix}_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'sessions', + additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name=sheet_name) + + # Spaltenbreiten anpassen + worksheet = writer.sheets[sheet_name] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/resources") +@login_required +def export_resources(): + conn = get_connection() + cur = conn.cursor() + + # Holen der Filter-Parameter + filter_type = request.args.get('type', '') + filter_status = request.args.get('status', '') + search_query = request.args.get('search', '') + show_test = request.args.get('show_test', 'false').lower() == 'true' + export_format = request.args.get('format', 'excel') + + # SQL Query mit Filtern + query = """ + SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, + r.created_at, r.status_changed_at, + l.license_key, c.name as customer_name, c.email as customer_email, + l.license_type + FROM resource_pools r + LEFT JOIN licenses l ON r.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE 1=1 + """ + params = [] + + # Filter für Testdaten + if not show_test: + query += " AND (r.is_test = false OR r.is_test IS NULL)" + + # Filter für Ressourcentyp + if filter_type: + query += " AND r.resource_type = %s" + params.append(filter_type) + + # Filter für Status + if filter_status: + query += " AND r.status = %s" + params.append(filter_status) + + # Suchfilter + if search_query: + query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" + params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) + + query += " ORDER BY r.id DESC" + + cur.execute(query, params) + resources = cur.fetchall() + cur.close() + conn.close() + + # Daten für Export vorbereiten + data = [] + for res in resources: + status_text = { + 'available': 'Verfügbar', + 'allocated': 'Zugewiesen', + 'quarantine': 'Quarantäne' + }.get(res[3], res[3]) + + type_text = { + 'domain': 'Domain', + 'ipv4': 'IPv4', + 'phone': 'Telefon' + }.get(res[1], res[1]) + + data.append({ + 'ID': res[0], + 'Typ': type_text, + 'Ressource': res[2], + 'Status': status_text, + 'Lizenzschlüssel': res[7] or '', + 'Kunde': res[8] or '', + 'Kunden-Email': res[9] or '', + 'Lizenztyp': res[10] or '', + 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', + 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' + }) + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'resources_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'resources', + additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Resources') + + # Spaltenbreiten anpassen + worksheet = writer.sheets['Resources'] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/audit") +@login_required +def audit_log(): + conn = get_connection() + cur = conn.cursor() + + # Parameter + filter_user = request.args.get('user', '').strip() + filter_action = request.args.get('action', '').strip() + filter_entity = request.args.get('entity', '').strip() + page = request.args.get('page', 1, type=int) + sort = request.args.get('sort', 'timestamp') + order = request.args.get('order', 'desc') + per_page = 50 + + # Whitelist für erlaubte Sortierfelder + allowed_sort_fields = { + 'timestamp': 'timestamp', + 'username': 'username', + 'action': 'action', + 'entity': 'entity_type', + 'ip': 'ip_address' + } + + # Validierung + if sort not in allowed_sort_fields: + sort = 'timestamp' + if order not in ['asc', 'desc']: + order = 'desc' + + sort_field = allowed_sort_fields[sort] + + # SQL Query mit optionalen Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + + params = [] + + # Filter + if filter_user: + query += " AND LOWER(username) LIKE LOWER(%s)" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + # Gesamtanzahl für Pagination + count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" + cur.execute(count_query, params) + total = cur.fetchone()[0] + + # Pagination + offset = (page - 1) * per_page + query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + logs = cur.fetchall() + + # JSON-Werte parsen + parsed_logs = [] + for log in logs: + parsed_log = list(log) + # old_values und new_values sind bereits Dictionaries (JSONB) + # Keine Konvertierung nötig + parsed_logs.append(parsed_log) + + # Pagination Info + total_pages = (total + per_page - 1) // per_page + + cur.close() + conn.close() + + return render_template("audit_log.html", + logs=parsed_logs, + filter_user=filter_user, + filter_action=filter_action, + filter_entity=filter_entity, + page=page, + total_pages=total_pages, + total=total, + sort=sort, + order=order, + username=session.get('username')) + +@app.route("/backups") +@login_required +def backups(): + """Zeigt die Backup-Historie an""" + conn = get_connection() + cur = conn.cursor() + + # Letztes erfolgreiches Backup für Dashboard + cur.execute(""" + SELECT created_at, filesize, duration_seconds + FROM backup_history + WHERE status = 'success' + ORDER BY created_at DESC + LIMIT 1 + """) + last_backup = cur.fetchone() + + # Alle Backups abrufen + cur.execute(""" + SELECT id, filename, filesize, backup_type, status, error_message, + created_at, created_by, tables_count, records_count, + duration_seconds, is_encrypted + FROM backup_history + ORDER BY created_at DESC + """) + backups = cur.fetchall() + + cur.close() + conn.close() + + return render_template("backups.html", + backups=backups, + last_backup=last_backup, + username=session.get('username')) + +@app.route("/backup/create", methods=["POST"]) +@login_required +def create_backup_route(): + """Erstellt ein manuelles Backup""" + username = session.get('username') + success, result = create_backup(backup_type="manual", created_by=username) + + if success: + return jsonify({ + 'success': True, + 'message': f'Backup erfolgreich erstellt: {result}' + }) + else: + return jsonify({ + 'success': False, + 'message': f'Backup fehlgeschlagen: {result}' + }), 500 + +@app.route("/backup/restore/", methods=["POST"]) +@login_required +def restore_backup_route(backup_id): + """Stellt ein Backup wieder her""" + encryption_key = request.form.get('encryption_key') + + success, message = restore_backup(backup_id, encryption_key) + + if success: + return jsonify({ + 'success': True, + 'message': message + }) + else: + return jsonify({ + 'success': False, + 'message': message + }), 500 + +@app.route("/backup/download/") +@login_required +def download_backup(backup_id): + """Lädt eine Backup-Datei herunter""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT filename, filepath + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + cur.close() + conn.close() + + if not backup_info: + return "Backup nicht gefunden", 404 + + filename, filepath = backup_info + filepath = Path(filepath) + + if not filepath.exists(): + return "Backup-Datei nicht gefunden", 404 + + # Audit-Log + log_audit('DOWNLOAD', 'backup', backup_id, + additional_info=f"Backup heruntergeladen: {filename}") + + return send_file(filepath, as_attachment=True, download_name=filename) + +@app.route("/backup/delete/", methods=["DELETE"]) +@login_required +def delete_backup(backup_id): + """Löscht ein Backup""" + conn = get_connection() + cur = conn.cursor() + + try: + # Backup-Informationen abrufen + cur.execute(""" + SELECT filename, filepath + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + if not backup_info: + return jsonify({ + 'success': False, + 'message': 'Backup nicht gefunden' + }), 404 + + filename, filepath = backup_info + filepath = Path(filepath) + + # Datei löschen, wenn sie existiert + if filepath.exists(): + filepath.unlink() + + # Aus Datenbank löschen + cur.execute(""" + DELETE FROM backup_history + WHERE id = %s + """, (backup_id,)) + + conn.commit() + + # Audit-Log + log_audit('DELETE', 'backup', backup_id, + additional_info=f"Backup gelöscht: {filename}") + + return jsonify({ + 'success': True, + 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' + }) + + except Exception as e: + conn.rollback() + return jsonify({ + 'success': False, + 'message': f'Fehler beim Löschen des Backups: {str(e)}' + }), 500 + finally: + cur.close() + conn.close() + +@app.route("/security/blocked-ips") +@login_required +def blocked_ips(): + """Zeigt alle gesperrten IPs an""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT + ip_address, + attempt_count, + first_attempt, + last_attempt, + blocked_until, + last_username_tried, + last_error_message + FROM login_attempts + WHERE blocked_until IS NOT NULL + ORDER BY blocked_until DESC + """) + + blocked_ips_list = [] + for ip in cur.fetchall(): + blocked_ips_list.append({ + 'ip_address': ip[0], + 'attempt_count': ip[1], + 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), + 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), + 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), + 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), + 'last_username': ip[5], + 'last_error': ip[6] + }) + + cur.close() + conn.close() + + return render_template("blocked_ips.html", + blocked_ips=blocked_ips_list, + username=session.get('username')) + +@app.route("/security/unblock-ip", methods=["POST"]) +@login_required +def unblock_ip(): + """Entsperrt eine IP-Adresse""" + ip_address = request.form.get('ip_address') + + if ip_address: + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + UPDATE login_attempts + SET blocked_until = NULL + WHERE ip_address = %s + """, (ip_address,)) + + conn.commit() + cur.close() + conn.close() + + # Audit-Log + log_audit('UNBLOCK_IP', 'security', + additional_info=f"IP {ip_address} manuell entsperrt") + + return redirect(url_for('blocked_ips')) + +@app.route("/security/clear-attempts", methods=["POST"]) +@login_required +def clear_attempts(): + """Löscht alle Login-Versuche für eine IP""" + ip_address = request.form.get('ip_address') + + if ip_address: + reset_login_attempts(ip_address) + + # Audit-Log + log_audit('CLEAR_ATTEMPTS', 'security', + additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") + + return redirect(url_for('blocked_ips')) + +# API Endpoints for License Management +@app.route("/api/license//toggle", methods=["POST"]) +@login_required +def toggle_license_api(license_id): + """Toggle license active status via API""" + try: + data = request.get_json() + is_active = data.get('is_active', False) + + conn = get_connection() + cur = conn.cursor() + + # Update license status + cur.execute(""" + UPDATE licenses + SET is_active = %s + WHERE id = %s + """, (is_active, license_id)) + + conn.commit() + + # Log the action + log_audit('UPDATE', 'license', license_id, + new_values={'is_active': is_active}, + additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/licenses/bulk-activate", methods=["POST"]) +@login_required +def bulk_activate_licenses(): + """Activate multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Update all selected licenses (nur Live-Daten) + cur.execute(""" + UPDATE licenses + SET is_active = TRUE + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_UPDATE', 'licenses', None, + new_values={'is_active': True, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen aktiviert") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) +@login_required +def bulk_deactivate_licenses(): + """Deactivate multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Update all selected licenses (nur Live-Daten) + cur.execute(""" + UPDATE licenses + SET is_active = FALSE + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_UPDATE', 'licenses', None, + new_values={'is_active': False, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen deaktiviert") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/license//devices") +@login_required +def get_license_devices(license_id): + """Hole alle registrierten Geräte einer Lizenz""" + try: + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Lizenz existiert und hole device_limit + cur.execute(""" + SELECT device_limit FROM licenses WHERE id = %s + """, (license_id,)) + license_data = cur.fetchone() + + if not license_data: + return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + + device_limit = license_data[0] + + # Hole alle Geräte für diese Lizenz + cur.execute(""" + SELECT id, hardware_id, device_name, operating_system, + first_seen, last_seen, is_active, ip_address + FROM device_registrations + WHERE license_id = %s + ORDER BY is_active DESC, last_seen DESC + """, (license_id,)) + + devices = [] + for row in cur.fetchall(): + devices.append({ + 'id': row[0], + 'hardware_id': row[1], + 'device_name': row[2] or 'Unbekanntes Gerät', + 'operating_system': row[3] or 'Unbekannt', + 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', + 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', + 'is_active': row[6], + 'ip_address': row[7] or '-' + }) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'devices': devices, + 'device_limit': device_limit, + 'active_count': sum(1 for d in devices if d['is_active']) + }) + + except Exception as e: + logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 + +@app.route("/api/license//register-device", methods=["POST"]) +def register_device(license_id): + """Registriere ein neues Gerät für eine Lizenz""" + try: + data = request.get_json() + hardware_id = data.get('hardware_id') + device_name = data.get('device_name', '') + operating_system = data.get('operating_system', '') + + if not hardware_id: + return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Lizenz existiert und aktiv ist + cur.execute(""" + SELECT device_limit, is_active, valid_until + FROM licenses + WHERE id = %s + """, (license_id,)) + license_data = cur.fetchone() + + if not license_data: + return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + + device_limit, is_active, valid_until = license_data + + # Prüfe ob Lizenz aktiv und gültig ist + if not is_active: + return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 + + if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): + return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 + + # Prüfe ob Gerät bereits registriert ist + cur.execute(""" + SELECT id, is_active FROM device_registrations + WHERE license_id = %s AND hardware_id = %s + """, (license_id, hardware_id)) + existing_device = cur.fetchone() + + if existing_device: + device_id, is_device_active = existing_device + if is_device_active: + # Gerät ist bereits aktiv, update last_seen + cur.execute(""" + UPDATE device_registrations + SET last_seen = CURRENT_TIMESTAMP, + ip_address = %s, + user_agent = %s + WHERE id = %s + """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + conn.commit() + return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) + else: + # Gerät war deaktiviert, prüfe ob wir es reaktivieren können + cur.execute(""" + SELECT COUNT(*) FROM device_registrations + WHERE license_id = %s AND is_active = TRUE + """, (license_id,)) + active_count = cur.fetchone()[0] + + if active_count >= device_limit: + return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + + # Reaktiviere das Gerät + cur.execute(""" + UPDATE device_registrations + SET is_active = TRUE, + last_seen = CURRENT_TIMESTAMP, + deactivated_at = NULL, + deactivated_by = NULL, + ip_address = %s, + user_agent = %s + WHERE id = %s + """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + conn.commit() + return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) + + # Neues Gerät - prüfe Gerätelimit + cur.execute(""" + SELECT COUNT(*) FROM device_registrations + WHERE license_id = %s AND is_active = TRUE + """, (license_id,)) + active_count = cur.fetchone()[0] + + if active_count >= device_limit: + return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + + # Registriere neues Gerät + cur.execute(""" + INSERT INTO device_registrations + (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, (license_id, hardware_id, device_name, operating_system, + get_client_ip(), request.headers.get('User-Agent', ''))) + device_id = cur.fetchone()[0] + + conn.commit() + + # Audit Log + log_audit('DEVICE_REGISTER', 'device', device_id, + new_values={'license_id': license_id, 'hardware_id': hardware_id}) + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) + + except Exception as e: + logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 + +@app.route("/api/license//deactivate-device/", methods=["POST"]) +@login_required +def deactivate_device(license_id, device_id): + """Deaktiviere ein registriertes Gerät""" + try: + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob das Gerät zu dieser Lizenz gehört + cur.execute(""" + SELECT id FROM device_registrations + WHERE id = %s AND license_id = %s AND is_active = TRUE + """, (device_id, license_id)) + + if not cur.fetchone(): + return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 + + # Deaktiviere das Gerät + cur.execute(""" + UPDATE device_registrations + SET is_active = FALSE, + deactivated_at = CURRENT_TIMESTAMP, + deactivated_by = %s + WHERE id = %s + """, (session['username'], device_id)) + + conn.commit() + + # Audit Log + log_audit('DEVICE_DEACTIVATE', 'device', device_id, + old_values={'is_active': True}, + new_values={'is_active': False}) + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) + + except Exception as e: + logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 + +@app.route("/api/licenses/bulk-delete", methods=["POST"]) +@login_required +def bulk_delete_licenses(): + """Delete multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Get license info for audit log (nur Live-Daten) + cur.execute(""" + SELECT license_key + FROM licenses + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + license_keys = [row[0] for row in cur.fetchall()] + + # Delete all selected licenses (nur Live-Daten) + cur.execute(""" + DELETE FROM licenses + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_DELETE', 'licenses', None, + old_values={'license_keys': license_keys, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen gelöscht") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +# ===================== RESOURCE POOL MANAGEMENT ===================== + +@app.route('/resources') +@login_required +def resources(): + """Resource Pool Hauptübersicht""" + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + # Statistiken abrufen + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + WHERE is_test = %s + GROUP BY resource_type + """, (show_test,)) + + stats = {} + for row in cur.fetchall(): + stats[row[0]] = { + 'available': row[1], + 'allocated': row[2], + 'quarantine': row[3], + 'total': row[4], + 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) + } + + # Letzte Aktivitäten (gefiltert nach Test/Live) + cur.execute(""" + SELECT + rh.action, + rh.action_by, + rh.action_at, + rp.resource_type, + rp.resource_value, + rh.details + FROM resource_history rh + JOIN resource_pools rp ON rh.resource_id = rp.id + WHERE rp.is_test = %s + ORDER BY rh.action_at DESC + LIMIT 10 + """, (show_test,)) + recent_activities = cur.fetchall() + + # Ressourcen-Liste mit Pagination + page = request.args.get('page', 1, type=int) + per_page = 50 + offset = (page - 1) * per_page + + resource_type = request.args.get('type', '') + status_filter = request.args.get('status', '') + search = request.args.get('search', '') + + # Sortierung + sort_by = request.args.get('sort', 'id') + sort_order = request.args.get('order', 'desc') + + # Base Query + query = """ + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + rp.allocated_to_license, + l.license_key, + c.name as customer_name, + rp.status_changed_at, + rp.quarantine_reason, + rp.quarantine_until, + c.id as customer_id + FROM resource_pools rp + LEFT JOIN licenses l ON rp.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE rp.is_test = %s + """ + params = [show_test] + + if resource_type: + query += " AND rp.resource_type = %s" + params.append(resource_type) + + if status_filter: + query += " AND rp.status = %s" + params.append(status_filter) + + if search: + query += " AND rp.resource_value ILIKE %s" + params.append(f'%{search}%') + + # Count total + count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" + cur.execute(count_query, params) + total = cur.fetchone()[0] + total_pages = (total + per_page - 1) // per_page + + # Get paginated results with dynamic sorting + sort_column_map = { + 'id': 'rp.id', + 'type': 'rp.resource_type', + 'resource': 'rp.resource_value', + 'status': 'rp.status', + 'assigned': 'c.name', + 'changed': 'rp.status_changed_at' + } + + sort_column = sort_column_map.get(sort_by, 'rp.id') + sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' + + query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + resources = cur.fetchall() + + cur.close() + conn.close() + + return render_template('resources.html', + stats=stats, + resources=resources, + recent_activities=recent_activities, + page=page, + total_pages=total_pages, + total=total, + resource_type=resource_type, + status_filter=status_filter, + search=search, + show_test=show_test, + sort_by=sort_by, + sort_order=sort_order, + datetime=datetime, + timedelta=timedelta) + +@app.route('/resources/add', methods=['GET', 'POST']) +@login_required +def add_resources(): + """Ressourcen zum Pool hinzufügen""" + # Hole show_test Parameter für die Anzeige + show_test = request.args.get('show_test', 'false').lower() == 'true' + + if request.method == 'POST': + resource_type = request.form.get('resource_type') + resources_text = request.form.get('resources_text', '') + is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten + + # Parse resources (one per line) + resources = [r.strip() for r in resources_text.split('\n') if r.strip()] + + if not resources: + flash('Keine Ressourcen angegeben', 'error') + return redirect(url_for('add_resources', show_test=show_test)) + + conn = get_connection() + cur = conn.cursor() + + added = 0 + duplicates = 0 + + for resource_value in resources: + try: + cur.execute(""" + INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) + VALUES (%s, %s, %s, %s) + ON CONFLICT (resource_type, resource_value) DO NOTHING + """, (resource_type, resource_value, session['username'], is_test)) + + if cur.rowcount > 0: + added += 1 + # Get the inserted ID + cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", + (resource_type, resource_value)) + resource_id = cur.fetchone()[0] + + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address) + VALUES (%s, 'created', %s, %s) + """, (resource_id, session['username'], get_client_ip())) + else: + duplicates += 1 + + except Exception as e: + app.logger.error(f"Error adding resource {resource_value}: {e}") + + conn.commit() + cur.close() + conn.close() + + log_audit('CREATE', 'resource_pool', None, + new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, + additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") + + flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') + return redirect(url_for('resources', show_test=show_test)) + + return render_template('add_resources.html', show_test=show_test) + +@app.route('/resources/quarantine/', methods=['POST']) +@login_required +def quarantine_resource(resource_id): + """Ressource in Quarantäne setzen""" + reason = request.form.get('reason', 'review') + until_date = request.form.get('until_date') + notes = request.form.get('notes', '') + + conn = get_connection() + cur = conn.cursor() + + # Get current resource info + cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) + resource = cur.fetchone() + + if not resource: + flash('Ressource nicht gefunden', 'error') + return redirect(url_for('resources')) + + old_status = resource[2] + + # Update resource + cur.execute(""" + UPDATE resource_pools + SET status = 'quarantine', + quarantine_reason = %s, + quarantine_until = %s, + notes = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) + + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) + VALUES (%s, 'quarantined', %s, %s, %s) + """, (resource_id, session['username'], get_client_ip(), + Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) + + conn.commit() + cur.close() + conn.close() + + log_audit('UPDATE', 'resource', resource_id, + old_values={'status': old_status}, + new_values={'status': 'quarantine', 'reason': reason}, + additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") + + flash('Ressource in Quarantäne gesetzt', 'success') + + # Redirect mit allen aktuellen Filtern + return redirect(url_for('resources', + show_test=request.args.get('show_test', request.form.get('show_test', 'false')), + type=request.args.get('type', request.form.get('type', '')), + status=request.args.get('status', request.form.get('status', '')), + search=request.args.get('search', request.form.get('search', '')))) + +@app.route('/resources/release', methods=['POST']) +@login_required +def release_resources(): + """Ressourcen aus Quarantäne freigeben""" + resource_ids = request.form.getlist('resource_ids') + + if not resource_ids: + flash('Keine Ressourcen ausgewählt', 'error') + return redirect(url_for('resources')) + + conn = get_connection() + cur = conn.cursor() + + released = 0 + for resource_id in resource_ids: + cur.execute(""" + UPDATE resource_pools + SET status = 'available', + quarantine_reason = NULL, + quarantine_until = NULL, + allocated_to_license = NULL, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s AND status = 'quarantine' + """, (session['username'], resource_id)) + + if cur.rowcount > 0: + released += 1 + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address) + VALUES (%s, 'released', %s, %s) + """, (resource_id, session['username'], get_client_ip())) + + conn.commit() + cur.close() + conn.close() + + log_audit('UPDATE', 'resource_pool', None, + new_values={'released': released}, + additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") + + flash(f'{released} Ressourcen freigegeben', 'success') + + # Redirect mit allen aktuellen Filtern + return redirect(url_for('resources', + show_test=request.args.get('show_test', request.form.get('show_test', 'false')), + type=request.args.get('type', request.form.get('type', '')), + status=request.args.get('status', request.form.get('status', '')), + search=request.args.get('search', request.form.get('search', '')))) + +@app.route('/api/resources/allocate', methods=['POST']) +@login_required +def allocate_resources_api(): + """API für Ressourcen-Zuweisung bei Lizenzerstellung""" + data = request.json + license_id = data.get('license_id') + domain_count = data.get('domain_count', 1) + ipv4_count = data.get('ipv4_count', 1) + phone_count = data.get('phone_count', 1) + + conn = get_connection() + cur = conn.cursor() + + try: + allocated = {'domains': [], 'ipv4s': [], 'phones': []} + + # Allocate domains + if domain_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' + LIMIT %s FOR UPDATE + """, (domain_count,)) + domains = cur.fetchall() + + if len(domains) < domain_count: + raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") + + for domain_id, domain_value in domains: + # Update resource status + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], domain_id)) + + # Create assignment + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, domain_id, session['username'])) + + # Log history + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (domain_id, license_id, session['username'], get_client_ip())) + + allocated['domains'].append(domain_value) + + # Allocate IPv4s (similar logic) + if ipv4_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' + LIMIT %s FOR UPDATE + """, (ipv4_count,)) + ipv4s = cur.fetchall() + + if len(ipv4s) < ipv4_count: + raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") + + for ipv4_id, ipv4_value in ipv4s: + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], ipv4_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, ipv4_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (ipv4_id, license_id, session['username'], get_client_ip())) + + allocated['ipv4s'].append(ipv4_value) + + # Allocate phones (similar logic) + if phone_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' + LIMIT %s FOR UPDATE + """, (phone_count,)) + phones = cur.fetchall() + + if len(phones) < phone_count: + raise ValueError(f"Nicht genügend Telefonnummern verfügbar") + + for phone_id, phone_value in phones: + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], phone_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, phone_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (phone_id, license_id, session['username'], get_client_ip())) + + allocated['phones'].append(phone_value) + + # Update license resource counts + cur.execute(""" + UPDATE licenses + SET domain_count = %s, + ipv4_count = %s, + phone_count = %s + WHERE id = %s + """, (domain_count, ipv4_count, phone_count, license_id)) + + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'allocated': allocated + }) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({ + 'success': False, + 'error': str(e) + }), 400 + +@app.route('/api/resources/check-availability', methods=['GET']) +@login_required +def check_resource_availability(): + """Prüft verfügbare Ressourcen""" + resource_type = request.args.get('type', '') + count = request.args.get('count', 10, type=int) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + conn = get_connection() + cur = conn.cursor() + + if resource_type: + # Spezifische Ressourcen für einen Typ + cur.execute(""" + SELECT id, resource_value + FROM resource_pools + WHERE status = 'available' + AND resource_type = %s + AND is_test = %s + ORDER BY resource_value + LIMIT %s + """, (resource_type, show_test, count)) + + resources = [] + for row in cur.fetchall(): + resources.append({ + 'id': row[0], + 'value': row[1] + }) + + cur.close() + conn.close() + + return jsonify({ + 'available': resources, + 'type': resource_type, + 'count': len(resources) + }) + else: + # Zusammenfassung aller Typen + cur.execute(""" + SELECT + resource_type, + COUNT(*) as available + FROM resource_pools + WHERE status = 'available' + AND is_test = %s + GROUP BY resource_type + """, (show_test,)) + + availability = {} + for row in cur.fetchall(): + availability[row[0]] = row[1] + + cur.close() + conn.close() + + return jsonify(availability) + +@app.route('/api/global-search', methods=['GET']) +@login_required +def global_search(): + """Global search API endpoint for searching customers and licenses""" + query = request.args.get('q', '').strip() + + if not query or len(query) < 2: + return jsonify({'customers': [], 'licenses': []}) + + conn = get_connection() + cur = conn.cursor() + + # Search pattern with wildcards + search_pattern = f'%{query}%' + + # Search customers + cur.execute(""" + SELECT id, name, email, company_name + FROM customers + WHERE (LOWER(name) LIKE LOWER(%s) + OR LOWER(email) LIKE LOWER(%s) + OR LOWER(company_name) LIKE LOWER(%s)) + AND is_test = FALSE + ORDER BY name + LIMIT 5 + """, (search_pattern, search_pattern, search_pattern)) + + customers = [] + for row in cur.fetchall(): + customers.append({ + 'id': row[0], + 'name': row[1], + 'email': row[2], + 'company_name': row[3] + }) + + # Search licenses + cur.execute(""" + SELECT l.id, l.license_key, c.name as customer_name + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE LOWER(l.license_key) LIKE LOWER(%s) + AND l.is_test = FALSE + ORDER BY l.created_at DESC + LIMIT 5 + """, (search_pattern,)) + + licenses = [] + for row in cur.fetchall(): + licenses.append({ + 'id': row[0], + 'license_key': row[1], + 'customer_name': row[2] + }) + + cur.close() + conn.close() + + return jsonify({ + 'customers': customers, + 'licenses': licenses + }) + +@app.route('/resources/history/') +@login_required +def resource_history(resource_id): + """Zeigt die komplette Historie einer Ressource""" + conn = get_connection() + cur = conn.cursor() + + # Get complete resource info using named columns + cur.execute(""" + SELECT id, resource_type, resource_value, status, allocated_to_license, + status_changed_at, status_changed_by, quarantine_reason, + quarantine_until, created_at, notes + FROM resource_pools + WHERE id = %s + """, (resource_id,)) + row = cur.fetchone() + + if not row: + flash('Ressource nicht gefunden', 'error') + return redirect(url_for('resources')) + + # Create resource object with named attributes + resource = { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'allocated_to_license': row[4], + 'status_changed_at': row[5], + 'status_changed_by': row[6], + 'quarantine_reason': row[7], + 'quarantine_until': row[8], + 'created_at': row[9], + 'notes': row[10] + } + + # Get license info if allocated + license_info = None + if resource['allocated_to_license']: + cur.execute("SELECT license_key FROM licenses WHERE id = %s", + (resource['allocated_to_license'],)) + lic = cur.fetchone() + if lic: + license_info = {'license_key': lic[0]} + + # Get history with named columns + cur.execute(""" + SELECT + rh.action, + rh.action_by, + rh.action_at, + rh.details, + rh.license_id, + rh.ip_address + FROM resource_history rh + WHERE rh.resource_id = %s + ORDER BY rh.action_at DESC + """, (resource_id,)) + + history = [] + for row in cur.fetchall(): + history.append({ + 'action': row[0], + 'action_by': row[1], + 'action_at': row[2], + 'details': row[3], + 'license_id': row[4], + 'ip_address': row[5] + }) + + cur.close() + conn.close() + + # Convert to object-like for template + class ResourceObj: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + + resource_obj = ResourceObj(resource) + history_objs = [ResourceObj(h) for h in history] + + return render_template('resource_history.html', + resource=resource_obj, + license_info=license_info, + history=history_objs) + +@app.route('/resources/metrics') +@login_required +def resources_metrics(): + """Dashboard für Resource Metrics und Reports""" + conn = get_connection() + cur = conn.cursor() + + # Overall stats with fallback values + cur.execute(""" + SELECT + COUNT(DISTINCT resource_id) as total_resources, + COALESCE(AVG(performance_score), 0) as avg_performance, + COALESCE(SUM(cost), 0) as total_cost, + COALESCE(SUM(revenue), 0) as total_revenue, + COALESCE(SUM(issues_count), 0) as total_issues + FROM resource_metrics + WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' + """) + row = cur.fetchone() + + # Calculate ROI + roi = 0 + if row[2] > 0: # if total_cost > 0 + roi = row[3] / row[2] # revenue / cost + + stats = { + 'total_resources': row[0] or 0, + 'avg_performance': row[1] or 0, + 'total_cost': row[2] or 0, + 'total_revenue': row[3] or 0, + 'total_issues': row[4] or 0, + 'roi': roi + } + + # Performance by type + cur.execute(""" + SELECT + rp.resource_type, + COALESCE(AVG(rm.performance_score), 0) as avg_score, + COUNT(DISTINCT rp.id) as resource_count + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY rp.resource_type + ORDER BY rp.resource_type + """) + performance_by_type = cur.fetchall() + + # Utilization data + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) as total, + ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent + FROM resource_pools + GROUP BY resource_type + """) + utilization_rows = cur.fetchall() + utilization_data = [ + { + 'type': row[0].upper(), + 'allocated': row[1], + 'total': row[2], + 'allocated_percent': row[3] + } + for row in utilization_rows + ] + + # Top performing resources + cur.execute(""" + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + COALESCE(AVG(rm.performance_score), 0) as avg_score, + COALESCE(SUM(rm.revenue), 0) as total_revenue, + COALESCE(SUM(rm.cost), 1) as total_cost, + CASE + WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 + ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) + END as roi + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + WHERE rp.status != 'quarantine' + GROUP BY rp.id, rp.resource_type, rp.resource_value + HAVING AVG(rm.performance_score) IS NOT NULL + ORDER BY avg_score DESC + LIMIT 10 + """) + top_rows = cur.fetchall() + top_performers = [ + { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'avg_score': row[3], + 'roi': row[6] + } + for row in top_rows + ] + + # Resources with issues + cur.execute(""" + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + COALESCE(SUM(rm.issues_count), 0) as total_issues + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + WHERE rm.issues_count > 0 OR rp.status = 'quarantine' + GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status + HAVING SUM(rm.issues_count) > 0 + ORDER BY total_issues DESC + LIMIT 10 + """) + problem_rows = cur.fetchall() + problem_resources = [ + { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'total_issues': row[4] + } + for row in problem_rows + ] + + # Daily metrics for trend chart (last 30 days) + cur.execute(""" + SELECT + metric_date, + COALESCE(AVG(performance_score), 0) as avg_performance, + COALESCE(SUM(issues_count), 0) as total_issues + FROM resource_metrics + WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY metric_date + ORDER BY metric_date + """) + daily_rows = cur.fetchall() + daily_metrics = [ + { + 'date': row[0].strftime('%d.%m'), + 'performance': float(row[1]), + 'issues': int(row[2]) + } + for row in daily_rows + ] + + cur.close() + conn.close() + + return render_template('resource_metrics.html', + stats=stats, + performance_by_type=performance_by_type, + utilization_data=utilization_data, + top_performers=top_performers, + problem_resources=problem_resources, + daily_metrics=daily_metrics) + +@app.route('/resources/report', methods=['GET']) +@login_required +def resources_report(): + """Generiert Ressourcen-Reports oder zeigt Report-Formular""" + # Prüfe ob Download angefordert wurde + if request.args.get('download') == 'true': + report_type = request.args.get('type', 'usage') + format_type = request.args.get('format', 'excel') + date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) + date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) + + conn = get_connection() + cur = conn.cursor() + + if report_type == 'usage': + # Auslastungsreport + query = """ + SELECT + rp.resource_type, + rp.resource_value, + rp.status, + COUNT(DISTINCT rh.license_id) as unique_licenses, + COUNT(rh.id) as total_allocations, + MIN(rh.action_at) as first_used, + MAX(rh.action_at) as last_used + FROM resource_pools rp + LEFT JOIN resource_history rh ON rp.id = rh.resource_id + AND rh.action = 'allocated' + AND rh.action_at BETWEEN %s AND %s + GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status + ORDER BY rp.resource_type, total_allocations DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] + + elif report_type == 'performance': + # Performance-Report + query = """ + SELECT + rp.resource_type, + rp.resource_value, + AVG(rm.performance_score) as avg_performance, + SUM(rm.usage_count) as total_usage, + SUM(rm.revenue) as total_revenue, + SUM(rm.cost) as total_cost, + SUM(rm.revenue - rm.cost) as profit, + SUM(rm.issues_count) as total_issues + FROM resource_pools rp + JOIN resource_metrics rm ON rp.id = rm.resource_id + WHERE rm.metric_date BETWEEN %s AND %s + GROUP BY rp.id, rp.resource_type, rp.resource_value + ORDER BY profit DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] + + elif report_type == 'compliance': + # Compliance-Report + query = """ + SELECT + rh.action_at, + rh.action, + rh.action_by, + rp.resource_type, + rp.resource_value, + l.license_key, + c.name as customer_name, + rh.ip_address + FROM resource_history rh + JOIN resource_pools rp ON rh.resource_id = rp.id + LEFT JOIN licenses l ON rh.license_id = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE rh.action_at BETWEEN %s AND %s + ORDER BY rh.action_at DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] + + else: # inventory report + # Inventar-Report + query = """ + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + GROUP BY resource_type + ORDER BY resource_type + """ + cur.execute(query) + columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] + + # Convert to DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + cur.close() + conn.close() + + # Generate file + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f"resource_report_{report_type}_{timestamp}" + + if format_type == 'excel': + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Report', index=False) + + # Auto-adjust columns width + worksheet = writer.sheets['Report'] + for column in worksheet.columns: + max_length = 0 + column = [cell for cell in column] + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = (max_length + 2) + worksheet.column_dimensions[column[0].column_letter].width = adjusted_width + + output.seek(0) + + log_audit('EXPORT', 'resource_report', None, + new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, + additional_info=f"Resource Report {report_type} exportiert") + + return send_file(output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx') + + else: # CSV + output = io.StringIO() + df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') + output.seek(0) + + log_audit('EXPORT', 'resource_report', None, + new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, + additional_info=f"Resource Report {report_type} exportiert") + + return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv') + + # Wenn kein Download, zeige Report-Formular + return render_template('resource_report.html', + datetime=datetime, + timedelta=timedelta, + username=session.get('username')) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/v2_adminpanel/app.py.old b/v2_adminpanel/app.py.old new file mode 100644 index 0000000..3849500 --- /dev/null +++ b/v2_adminpanel/app.py.old @@ -0,0 +1,5021 @@ +import os +import time +import json +import logging +import requests +from io import BytesIO +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from pathlib import Path + +from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash +from flask_session import Session +from werkzeug.middleware.proxy_fix import ProxyFix +from apscheduler.schedulers.background import BackgroundScheduler +import pandas as pd +from psycopg2.extras import Json + +# Import our new modules +import config +from db import get_connection, get_db_connection, get_db_cursor, execute_query +from auth.decorators import login_required +from auth.password import hash_password, verify_password +from auth.two_factor import ( + generate_totp_secret, generate_qr_code, verify_totp, + generate_backup_codes, hash_backup_code, verify_backup_code +) +from auth.rate_limiting import ( + get_client_ip, check_ip_blocked, record_failed_attempt, + reset_login_attempts, get_login_attempts +) +from utils.audit import log_audit +from utils.license import generate_license_key, validate_license_key +from utils.backup import create_backup, restore_backup, get_or_create_encryption_key +from utils.export import ( + create_excel_export, format_datetime_for_export, + prepare_license_export_data, prepare_customer_export_data, + prepare_session_export_data, prepare_audit_export_data +) + +app = Flask(__name__) +# Load configuration from config module +app.config['SECRET_KEY'] = config.SECRET_KEY +app.config['SESSION_TYPE'] = config.SESSION_TYPE +app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII +app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE +app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME +app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY +app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE +app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE +app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME +app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST +Session(app) + +# ProxyFix für korrekte IP-Adressen hinter Nginx +app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 +) + +# Configuration is now loaded from config module + +# Scheduler für automatische Backups +scheduler = BackgroundScheduler() +scheduler.start() + +# Logging konfigurieren +logging.basicConfig(level=logging.INFO) + + +# Login decorator +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'logged_in' not in session: + return redirect(url_for('login')) + + # Prüfe ob Session abgelaufen ist + if 'last_activity' in session: + last_activity = datetime.fromisoformat(session['last_activity']) + time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity + + # Debug-Logging + app.logger.info(f"Session check for {session.get('username', 'unknown')}: " + f"Last activity: {last_activity}, " + f"Time since: {time_since_activity.total_seconds()} seconds") + + if time_since_activity > timedelta(minutes=5): + # Session abgelaufen - Logout + username = session.get('username', 'unbekannt') + app.logger.info(f"Session timeout for user {username} - auto logout") + # Audit-Log für automatischen Logout (vor session.clear()!) + try: + log_audit('AUTO_LOGOUT', 'session', additional_info={'reason': 'Session timeout (5 minutes)', 'username': username}) + except: + pass + session.clear() + flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning') + return redirect(url_for('login')) + + # Aktivität NICHT automatisch aktualisieren + # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht) + return f(*args, **kwargs) + return decorated_function + +# DB-Verbindung mit UTF-8 Encoding +def get_connection(): + conn = psycopg2.connect( + host=os.getenv("POSTGRES_HOST", "postgres"), + port=os.getenv("POSTGRES_PORT", "5432"), + dbname=os.getenv("POSTGRES_DB"), + user=os.getenv("POSTGRES_USER"), + password=os.getenv("POSTGRES_PASSWORD"), + options='-c client_encoding=UTF8' + ) + conn.set_client_encoding('UTF8') + return conn + +# User Authentication Helper Functions +def hash_password(password): + """Hash a password using bcrypt""" + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + +def verify_password(password, hashed): + """Verify a password against its hash""" + return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) + +def get_user_by_username(username): + """Get user from database by username""" + conn = get_connection() + cur = conn.cursor() + try: + cur.execute(""" + SELECT id, username, password_hash, email, totp_secret, totp_enabled, + backup_codes, last_password_change, failed_2fa_attempts + FROM users WHERE username = %s + """, (username,)) + user = cur.fetchone() + if user: + return { + 'id': user[0], + 'username': user[1], + 'password_hash': user[2], + 'email': user[3], + 'totp_secret': user[4], + 'totp_enabled': user[5], + 'backup_codes': user[6], + 'last_password_change': user[7], + 'failed_2fa_attempts': user[8] + } + return None + finally: + cur.close() + conn.close() + +def generate_totp_secret(): + """Generate a new TOTP secret""" + return pyotp.random_base32() + +def generate_qr_code(username, totp_secret): + """Generate QR code for TOTP setup""" + totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( + name=username, + issuer_name='V2 Admin Panel' + ) + + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(totp_uri) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + buf = BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + + return base64.b64encode(buf.getvalue()).decode() + +def verify_totp(totp_secret, token): + """Verify a TOTP token""" + totp = pyotp.TOTP(totp_secret) + return totp.verify(token, valid_window=1) + +def generate_backup_codes(count=8): + """Generate backup codes for 2FA recovery""" + codes = [] + for _ in range(count): + code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) + codes.append(code) + return codes + +def hash_backup_code(code): + """Hash a backup code for storage""" + return hashlib.sha256(code.encode()).hexdigest() + +def verify_backup_code(code, hashed_codes): + """Verify a backup code against stored hashes""" + code_hash = hashlib.sha256(code.encode()).hexdigest() + return code_hash in hashed_codes + +# Audit-Log-Funktion +def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): + """Protokolliert Änderungen im Audit-Log""" + conn = get_connection() + cur = conn.cursor() + + try: + username = session.get('username', 'system') + ip_address = get_client_ip() if request else None + user_agent = request.headers.get('User-Agent') if request else None + + # Debug logging + app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") + + # Konvertiere Dictionaries zu JSONB + old_json = Json(old_values) if old_values else None + new_json = Json(new_values) if new_values else None + + cur.execute(""" + INSERT INTO audit_log + (username, action, entity_type, entity_id, old_values, new_values, + ip_address, user_agent, additional_info) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """, (username, action, entity_type, entity_id, old_json, new_json, + ip_address, user_agent, additional_info)) + + conn.commit() + except Exception as e: + print(f"Audit log error: {e}") + conn.rollback() + finally: + cur.close() + conn.close() + +# Verschlüsselungs-Funktionen +def get_or_create_encryption_key(): + """Holt oder erstellt einen Verschlüsselungsschlüssel""" + key_file = BACKUP_DIR / ".backup_key" + + # Versuche Key aus Umgebungsvariable zu lesen + env_key = os.getenv("BACKUP_ENCRYPTION_KEY") + if env_key: + try: + # Validiere den Key + Fernet(env_key.encode()) + return env_key.encode() + except: + pass + + # Wenn kein gültiger Key in ENV, prüfe Datei + if key_file.exists(): + return key_file.read_bytes() + + # Erstelle neuen Key + key = Fernet.generate_key() + key_file.write_bytes(key) + logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt") + return key + +# Backup-Funktionen +def create_backup(backup_type="manual", created_by=None): + """Erstellt ein verschlüsseltes Backup der Datenbank""" + start_time = time.time() + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S") + filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc" + filepath = BACKUP_DIR / filename + + conn = get_connection() + cur = conn.cursor() + + # Backup-Eintrag erstellen + cur.execute(""" + INSERT INTO backup_history + (filename, filepath, backup_type, status, created_by, is_encrypted) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, (filename, str(filepath), backup_type, 'in_progress', + created_by or 'system', True)) + backup_id = cur.fetchone()[0] + conn.commit() + + try: + # PostgreSQL Dump erstellen + dump_command = [ + 'pg_dump', + '-h', os.getenv("POSTGRES_HOST", "postgres"), + '-p', os.getenv("POSTGRES_PORT", "5432"), + '-U', os.getenv("POSTGRES_USER"), + '-d', os.getenv("POSTGRES_DB"), + '--no-password', + '--verbose' + ] + + # PGPASSWORD setzen + env = os.environ.copy() + env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") + + # Dump ausführen + result = subprocess.run(dump_command, capture_output=True, text=True, env=env) + + if result.returncode != 0: + raise Exception(f"pg_dump failed: {result.stderr}") + + dump_data = result.stdout.encode('utf-8') + + # Komprimieren + compressed_data = gzip.compress(dump_data) + + # Verschlüsseln + key = get_or_create_encryption_key() + f = Fernet(key) + encrypted_data = f.encrypt(compressed_data) + + # Speichern + filepath.write_bytes(encrypted_data) + + # Statistiken sammeln + cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'") + tables_count = cur.fetchone()[0] + + cur.execute(""" + SELECT SUM(n_live_tup) + FROM pg_stat_user_tables + """) + records_count = cur.fetchone()[0] or 0 + + duration = time.time() - start_time + filesize = filepath.stat().st_size + + # Backup-Eintrag aktualisieren + cur.execute(""" + UPDATE backup_history + SET status = %s, filesize = %s, tables_count = %s, + records_count = %s, duration_seconds = %s + WHERE id = %s + """, ('success', filesize, tables_count, records_count, duration, backup_id)) + + conn.commit() + + # Audit-Log + log_audit('BACKUP', 'database', backup_id, + additional_info=f"Backup erstellt: {filename} ({filesize} bytes)") + + # E-Mail-Benachrichtigung (wenn konfiguriert) + send_backup_notification(True, filename, filesize, duration) + + logging.info(f"Backup erfolgreich erstellt: {filename}") + return True, filename + + except Exception as e: + # Fehler protokollieren + cur.execute(""" + UPDATE backup_history + SET status = %s, error_message = %s, duration_seconds = %s + WHERE id = %s + """, ('failed', str(e), time.time() - start_time, backup_id)) + conn.commit() + + logging.error(f"Backup fehlgeschlagen: {e}") + send_backup_notification(False, filename, error=str(e)) + + return False, str(e) + + finally: + cur.close() + conn.close() + +def restore_backup(backup_id, encryption_key=None): + """Stellt ein Backup wieder her""" + conn = get_connection() + cur = conn.cursor() + + try: + # Backup-Info abrufen + cur.execute(""" + SELECT filename, filepath, is_encrypted + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + if not backup_info: + raise Exception("Backup nicht gefunden") + + filename, filepath, is_encrypted = backup_info + filepath = Path(filepath) + + if not filepath.exists(): + raise Exception("Backup-Datei nicht gefunden") + + # Datei lesen + encrypted_data = filepath.read_bytes() + + # Entschlüsseln + if is_encrypted: + key = encryption_key.encode() if encryption_key else get_or_create_encryption_key() + try: + f = Fernet(key) + compressed_data = f.decrypt(encrypted_data) + except: + raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?") + else: + compressed_data = encrypted_data + + # Dekomprimieren + dump_data = gzip.decompress(compressed_data) + sql_commands = dump_data.decode('utf-8') + + # Bestehende Verbindungen schließen + cur.close() + conn.close() + + # Datenbank wiederherstellen + restore_command = [ + 'psql', + '-h', os.getenv("POSTGRES_HOST", "postgres"), + '-p', os.getenv("POSTGRES_PORT", "5432"), + '-U', os.getenv("POSTGRES_USER"), + '-d', os.getenv("POSTGRES_DB"), + '--no-password' + ] + + env = os.environ.copy() + env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") + + result = subprocess.run(restore_command, input=sql_commands, + capture_output=True, text=True, env=env) + + if result.returncode != 0: + raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}") + + # Audit-Log (neue Verbindung) + log_audit('RESTORE', 'database', backup_id, + additional_info=f"Backup wiederhergestellt: {filename}") + + return True, "Backup erfolgreich wiederhergestellt" + + except Exception as e: + logging.error(f"Wiederherstellung fehlgeschlagen: {e}") + return False, str(e) + +def send_backup_notification(success, filename, filesize=None, duration=None, error=None): + """Sendet E-Mail-Benachrichtigung (wenn konfiguriert)""" + if not os.getenv("EMAIL_ENABLED", "false").lower() == "true": + return + + # E-Mail-Funktion vorbereitet aber deaktiviert + # TODO: Implementieren wenn E-Mail-Server konfiguriert ist + logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}") + +# Scheduled Backup Job +def scheduled_backup(): + """Führt ein geplantes Backup aus""" + logging.info("Starte geplantes Backup...") + create_backup(backup_type="scheduled", created_by="scheduler") + +# Scheduler konfigurieren - täglich um 3:00 Uhr +scheduler.add_job( + scheduled_backup, + 'cron', + hour=3, + minute=0, + id='daily_backup', + replace_existing=True +) + +# Rate-Limiting Funktionen +def get_client_ip(): + """Ermittelt die echte IP-Adresse des Clients""" + # Debug logging + app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}") + + # Try X-Real-IP first (set by nginx) + if request.headers.get('X-Real-IP'): + return request.headers.get('X-Real-IP') + # Then X-Forwarded-For + elif request.headers.get('X-Forwarded-For'): + # X-Forwarded-For can contain multiple IPs, take the first one + return request.headers.get('X-Forwarded-For').split(',')[0].strip() + # Fallback to remote_addr + else: + return request.remote_addr + +def check_ip_blocked(ip_address): + """Prüft ob eine IP-Adresse gesperrt ist""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT blocked_until FROM login_attempts + WHERE ip_address = %s AND blocked_until IS NOT NULL + """, (ip_address,)) + + result = cur.fetchone() + cur.close() + conn.close() + + if result and result[0]: + if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None): + return True, result[0] + return False, None + +def record_failed_attempt(ip_address, username): + """Zeichnet einen fehlgeschlagenen Login-Versuch auf""" + conn = get_connection() + cur = conn.cursor() + + # Random Fehlermeldung + error_message = random.choice(FAIL_MESSAGES) + + try: + # Prüfen ob IP bereits existiert + cur.execute(""" + SELECT attempt_count FROM login_attempts + WHERE ip_address = %s + """, (ip_address,)) + + result = cur.fetchone() + + if result: + # Update bestehenden Eintrag + new_count = result[0] + 1 + blocked_until = None + + if new_count >= MAX_LOGIN_ATTEMPTS: + blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS) + # E-Mail-Benachrichtigung (wenn aktiviert) + if os.getenv("EMAIL_ENABLED", "false").lower() == "true": + send_security_alert_email(ip_address, username, new_count) + + cur.execute(""" + UPDATE login_attempts + SET attempt_count = %s, + last_attempt = CURRENT_TIMESTAMP, + blocked_until = %s, + last_username_tried = %s, + last_error_message = %s + WHERE ip_address = %s + """, (new_count, blocked_until, username, error_message, ip_address)) + else: + # Neuen Eintrag erstellen + cur.execute(""" + INSERT INTO login_attempts + (ip_address, attempt_count, last_username_tried, last_error_message) + VALUES (%s, 1, %s, %s) + """, (ip_address, username, error_message)) + + conn.commit() + + # Audit-Log + log_audit('LOGIN_FAILED', 'user', + additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}") + + except Exception as e: + print(f"Rate limiting error: {e}") + conn.rollback() + finally: + cur.close() + conn.close() + + return error_message + +def reset_login_attempts(ip_address): + """Setzt die Login-Versuche für eine IP zurück""" + conn = get_connection() + cur = conn.cursor() + + try: + cur.execute(""" + DELETE FROM login_attempts + WHERE ip_address = %s + """, (ip_address,)) + conn.commit() + except Exception as e: + print(f"Reset attempts error: {e}") + conn.rollback() + finally: + cur.close() + conn.close() + +def get_login_attempts(ip_address): + """Gibt die Anzahl der Login-Versuche für eine IP zurück""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT attempt_count FROM login_attempts + WHERE ip_address = %s + """, (ip_address,)) + + result = cur.fetchone() + cur.close() + conn.close() + + return result[0] if result else 0 + +def send_security_alert_email(ip_address, username, attempt_count): + """Sendet eine Sicherheitswarnung per E-Mail""" + subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche" + body = f""" + WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt! + + IP-Adresse: {ip_address} + Versuchter Benutzername: {username} + Anzahl Versuche: {attempt_count} + Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')} + + Die IP-Adresse wurde für 24 Stunden gesperrt. + + Dies ist eine automatische Nachricht vom v2-Docker Admin Panel. + """ + + # TODO: E-Mail-Versand implementieren wenn SMTP konfiguriert + logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") + print(f"E-Mail würde gesendet: {subject}") + +def verify_recaptcha(response): + """Verifiziert die reCAPTCHA v2 Response mit Google""" + secret_key = os.getenv('RECAPTCHA_SECRET_KEY') + + # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) + if not secret_key: + logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") + return True + + # Verifizierung bei Google + try: + verify_url = 'https://www.google.com/recaptcha/api/siteverify' + data = { + 'secret': secret_key, + 'response': response + } + + # Timeout für Request setzen + r = requests.post(verify_url, data=data, timeout=5) + result = r.json() + + # Log für Debugging + if not result.get('success'): + logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") + + return result.get('success', False) + + except requests.exceptions.RequestException as e: + logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") + # Bei Netzwerkfehlern CAPTCHA als bestanden werten + return True + except Exception as e: + logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") + return False + +def generate_license_key(license_type='full'): + """ + Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ + + AF = Account Factory (Produktkennung) + F/T = F für Fullversion, T für Testversion + YYYY = Jahr + MM = Monat + XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen + """ + # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l) + chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + + # Datum-Teil + now = datetime.now(ZoneInfo("Europe/Berlin")) + date_part = now.strftime('%Y%m') + type_char = 'F' if license_type == 'full' else 'T' + + # Zufällige Teile generieren (3 Blöcke à 4 Zeichen) + parts = [] + for _ in range(3): + part = ''.join(secrets.choice(chars) for _ in range(4)) + parts.append(part) + + # Key zusammensetzen + key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" + + return key + +def validate_license_key(key): + """ + Validiert das License Key Format + Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ + """ + if not key: + return False + + # Pattern für das neue Format + # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen + pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' + + # Großbuchstaben für Vergleich + return bool(re.match(pattern, key.upper())) + +@app.route("/login", methods=["GET", "POST"]) +def login(): + # Timing-Attack Schutz - Start Zeit merken + start_time = time.time() + + # IP-Adresse ermitteln + ip_address = get_client_ip() + + # Prüfen ob IP gesperrt ist + is_blocked, blocked_until = check_ip_blocked(ip_address) + if is_blocked: + time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 + error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." + return render_template("login.html", error=error_msg, error_type="blocked") + + # Anzahl bisheriger Versuche + attempt_count = get_login_attempts(ip_address) + + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + captcha_response = request.form.get("g-recaptcha-response") + + # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind + recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY') + if attempt_count >= CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: + if not captcha_response: + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA ERFORDERLICH!", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # CAPTCHA validieren + if not verify_recaptcha(captcha_response): + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # Check user in database first, fallback to env vars + user = get_user_by_username(username) + login_success = False + needs_2fa = False + + if user: + # Database user authentication + if verify_password(password, user['password_hash']): + login_success = True + needs_2fa = user['totp_enabled'] + else: + # Fallback to environment variables for backward compatibility + admin1_user = os.getenv("ADMIN1_USERNAME") + admin1_pass = os.getenv("ADMIN1_PASSWORD") + admin2_user = os.getenv("ADMIN2_USERNAME") + admin2_pass = os.getenv("ADMIN2_PASSWORD") + + if ((username == admin1_user and password == admin1_pass) or + (username == admin2_user and password == admin2_pass)): + login_success = True + + # Timing-Attack Schutz - Mindestens 1 Sekunde warten + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + + if login_success: + # Erfolgreicher Login + if needs_2fa: + # Store temporary session for 2FA verification + session['temp_username'] = username + session['temp_user_id'] = user['id'] + session['awaiting_2fa'] = True + return redirect(url_for('verify_2fa')) + else: + # Complete login without 2FA + session.permanent = True # Aktiviert das Timeout + session['logged_in'] = True + session['username'] = username + session['user_id'] = user['id'] if user else None + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + reset_login_attempts(ip_address) + log_audit('LOGIN_SUCCESS', 'user', + additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") + return redirect(url_for('dashboard')) + else: + # Fehlgeschlagener Login + error_message = record_failed_attempt(ip_address, username) + new_attempt_count = get_login_attempts(ip_address) + + # Prüfen ob jetzt gesperrt + is_now_blocked, _ = check_ip_blocked(ip_address) + if is_now_blocked: + log_audit('LOGIN_BLOCKED', 'security', + additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") + + return render_template("login.html", + error=error_message, + show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), + error_type="failed", + attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count), + recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) + + # GET Request + return render_template("login.html", + show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), + attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) + +@app.route("/logout") +def logout(): + username = session.get('username', 'unknown') + log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") + session.pop('logged_in', None) + session.pop('username', None) + session.pop('user_id', None) + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + return redirect(url_for('login')) + +@app.route("/verify-2fa", methods=["GET", "POST"]) +def verify_2fa(): + if not session.get('awaiting_2fa'): + return redirect(url_for('login')) + + if request.method == "POST": + token = request.form.get('token', '').replace(' ', '') + username = session.get('temp_username') + user_id = session.get('temp_user_id') + + if not username or not user_id: + flash('Session expired. Please login again.', 'error') + return redirect(url_for('login')) + + user = get_user_by_username(username) + if not user: + flash('User not found.', 'error') + return redirect(url_for('login')) + + # Check if it's a backup code + if len(token) == 8 and token.isupper(): + # Try backup code + backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] + if verify_backup_code(token, backup_codes): + # Remove used backup code + code_hash = hash_backup_code(token) + backup_codes.remove(code_hash) + + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", + (json.dumps(backup_codes), user_id)) + conn.commit() + cur.close() + conn.close() + + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + flash('Login successful using backup code. Please generate new backup codes.', 'warning') + log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") + return redirect(url_for('dashboard')) + else: + # Try TOTP token + if verify_totp(user['totp_secret'], token): + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") + return redirect(url_for('dashboard')) + + # Failed verification + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", + (datetime.now(), user_id)) + conn.commit() + cur.close() + conn.close() + + flash('Invalid authentication code. Please try again.', 'error') + log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") + + return render_template('verify_2fa.html') + +@app.route("/profile") +@login_required +def profile(): + user = get_user_by_username(session['username']) + if not user: + # For environment-based users, redirect with message + flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') + return redirect(url_for('dashboard')) + return render_template('profile.html', user=user) + +@app.route("/profile/change-password", methods=["POST"]) +@login_required +def change_password(): + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + user = get_user_by_username(session['username']) + + # Verify current password + if not verify_password(current_password, user['password_hash']): + flash('Current password is incorrect.', 'error') + return redirect(url_for('profile')) + + # Check new password + if new_password != confirm_password: + flash('New passwords do not match.', 'error') + return redirect(url_for('profile')) + + if len(new_password) < 8: + flash('Password must be at least 8 characters long.', 'error') + return redirect(url_for('profile')) + + # Update password + new_hash = hash_password(new_password) + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", + (new_hash, datetime.now(), user['id'])) + conn.commit() + cur.close() + conn.close() + + log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], + additional_info="Password changed successfully") + flash('Password changed successfully.', 'success') + return redirect(url_for('profile')) + +@app.route("/profile/setup-2fa") +@login_required +def setup_2fa(): + user = get_user_by_username(session['username']) + + if user['totp_enabled']: + flash('2FA is already enabled for your account.', 'info') + return redirect(url_for('profile')) + + # Generate new TOTP secret + totp_secret = generate_totp_secret() + session['temp_totp_secret'] = totp_secret + + # Generate QR code + qr_code = generate_qr_code(user['username'], totp_secret) + + return render_template('setup_2fa.html', + totp_secret=totp_secret, + qr_code=qr_code) + +@app.route("/profile/enable-2fa", methods=["POST"]) +@login_required +def enable_2fa(): + token = request.form.get('token', '').replace(' ', '') + totp_secret = session.get('temp_totp_secret') + + if not totp_secret: + flash('2FA setup session expired. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # Verify the token + if not verify_totp(totp_secret, token): + flash('Invalid authentication code. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # Generate backup codes + backup_codes = generate_backup_codes() + hashed_codes = [hash_backup_code(code) for code in backup_codes] + + # Enable 2FA + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + UPDATE users + SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s + WHERE username = %s + """, (totp_secret, json.dumps(hashed_codes), session['username'])) + conn.commit() + cur.close() + conn.close() + + session.pop('temp_totp_secret', None) + + log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") + + # Show backup codes + return render_template('backup_codes.html', backup_codes=backup_codes) + +@app.route("/profile/disable-2fa", methods=["POST"]) +@login_required +def disable_2fa(): + password = request.form.get('password') + user = get_user_by_username(session['username']) + + # Verify password + if not verify_password(password, user['password_hash']): + flash('Incorrect password.', 'error') + return redirect(url_for('profile')) + + # Disable 2FA + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + UPDATE users + SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL + WHERE username = %s + """, (session['username'],)) + conn.commit() + cur.close() + conn.close() + + log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") + flash('2FA has been disabled for your account.', 'success') + return redirect(url_for('profile')) + +@app.route("/heartbeat", methods=['POST']) +@login_required +def heartbeat(): + """Endpoint für Session Keep-Alive - aktualisiert last_activity""" + # Aktualisiere last_activity nur wenn explizit angefordert + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + # Force session save + session.modified = True + + return jsonify({ + 'status': 'ok', + 'last_activity': session['last_activity'], + 'username': session.get('username') + }) + +@app.route("/api/generate-license-key", methods=['POST']) +@login_required +def api_generate_key(): + """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" + try: + # Lizenztyp aus Request holen (default: full) + data = request.get_json() or {} + license_type = data.get('type', 'full') + + # Key generieren + key = generate_license_key(license_type) + + # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) + conn = get_connection() + cur = conn.cursor() + + # Wiederhole bis eindeutiger Key gefunden + attempts = 0 + while attempts < 10: # Max 10 Versuche + cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) + if not cur.fetchone(): + break # Key ist eindeutig + key = generate_license_key(license_type) + attempts += 1 + + cur.close() + conn.close() + + # Log für Audit + log_audit('GENERATE_KEY', 'license', + additional_info={'type': license_type, 'key': key}) + + return jsonify({ + 'success': True, + 'key': key, + 'type': license_type + }) + + except Exception as e: + logging.error(f"Fehler bei Key-Generierung: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Fehler bei der Key-Generierung' + }), 500 + +@app.route("/api/customers", methods=['GET']) +@login_required +def api_customers(): + """API Endpoint für die Kundensuche mit Select2""" + try: + # Suchparameter + search = request.args.get('q', '').strip() + page = request.args.get('page', 1, type=int) + per_page = 20 + customer_id = request.args.get('id', type=int) + + conn = get_connection() + cur = conn.cursor() + + # Einzelnen Kunden per ID abrufen + if customer_id: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.id = %s + GROUP BY c.id, c.name, c.email + """, (customer_id,)) + + customer = cur.fetchone() + results = [] + if customer: + results.append({ + 'id': customer[0], + 'text': f"{customer[1]} ({customer[2]})", + 'name': customer[1], + 'email': customer[2], + 'license_count': customer[3] + }) + + cur.close() + conn.close() + + return jsonify({ + 'results': results, + 'pagination': {'more': False} + }) + + # SQL Query mit optionaler Suche + elif search: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE LOWER(c.name) LIKE LOWER(%s) + OR LOWER(c.email) LIKE LOWER(%s) + GROUP BY c.id, c.name, c.email + ORDER BY c.name + LIMIT %s OFFSET %s + """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) + else: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + GROUP BY c.id, c.name, c.email + ORDER BY c.name + LIMIT %s OFFSET %s + """, (per_page, (page - 1) * per_page)) + + customers = cur.fetchall() + + # Format für Select2 + results = [] + for customer in customers: + results.append({ + 'id': customer[0], + 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", + 'name': customer[1], + 'email': customer[2], + 'license_count': customer[3] + }) + + # Gesamtanzahl für Pagination + if search: + cur.execute(""" + SELECT COUNT(*) FROM customers + WHERE LOWER(name) LIKE LOWER(%s) + OR LOWER(email) LIKE LOWER(%s) + """, (f'%{search}%', f'%{search}%')) + else: + cur.execute("SELECT COUNT(*) FROM customers") + + total_count = cur.fetchone()[0] + + cur.close() + conn.close() + + # Select2 Response Format + return jsonify({ + 'results': results, + 'pagination': { + 'more': (page * per_page) < total_count + } + }) + + except Exception as e: + logging.error(f"Fehler bei Kundensuche: {str(e)}") + return jsonify({ + 'results': [], + 'error': 'Fehler bei der Kundensuche' + }), 500 + +@app.route("/") +@login_required +def dashboard(): + conn = get_connection() + cur = conn.cursor() + + # Statistiken abrufen + # Gesamtanzahl Kunden (ohne Testdaten) + cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") + total_customers = cur.fetchone()[0] + + # Gesamtanzahl Lizenzen (ohne Testdaten) + cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") + total_licenses = cur.fetchone()[0] + + # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE + """) + active_licenses = cur.fetchone()[0] + + # Aktive Sessions + cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") + active_sessions_count = cur.fetchone()[0] + + # Abgelaufene Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until < CURRENT_DATE AND is_test = FALSE + """) + expired_licenses = cur.fetchone()[0] + + # Deaktivierte Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE is_active = FALSE AND is_test = FALSE + """) + inactive_licenses = cur.fetchone()[0] + + # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until >= CURRENT_DATE + AND valid_until < CURRENT_DATE + INTERVAL '30 days' + AND is_active = TRUE + AND is_test = FALSE + """) + expiring_soon = cur.fetchone()[0] + + # Testlizenzen vs Vollversionen (ohne Testdaten) + cur.execute(""" + SELECT license_type, COUNT(*) + FROM licenses + WHERE is_test = FALSE + GROUP BY license_type + """) + license_types = dict(cur.fetchall()) + + # Anzahl Testdaten + cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") + test_data_count = cur.fetchone()[0] + + # Anzahl Test-Kunden + cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") + test_customers_count = cur.fetchone()[0] + + # Anzahl Test-Ressourcen + cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") + test_resources_count = cur.fetchone()[0] + + # Letzte 5 erstellten Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT l.id, l.license_key, c.name, l.valid_until, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.is_test = FALSE + ORDER BY l.id DESC + LIMIT 5 + """) + recent_licenses = cur.fetchall() + + # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) + cur.execute(""" + SELECT l.id, l.license_key, c.name, l.valid_until, + l.valid_until - CURRENT_DATE as days_left + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.valid_until >= CURRENT_DATE + AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' + AND l.is_active = TRUE + AND l.is_test = FALSE + ORDER BY l.valid_until + LIMIT 10 + """) + expiring_licenses = cur.fetchall() + + # Letztes Backup + cur.execute(""" + SELECT created_at, filesize, duration_seconds, backup_type, status + FROM backup_history + ORDER BY created_at DESC + LIMIT 1 + """) + last_backup_info = cur.fetchone() + + # Sicherheitsstatistiken + # Gesperrte IPs + cur.execute(""" + SELECT COUNT(*) FROM login_attempts + WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP + """) + blocked_ips_count = cur.fetchone()[0] + + # Fehlversuche heute + cur.execute(""" + SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts + WHERE last_attempt::date = CURRENT_DATE + """) + failed_attempts_today = cur.fetchone()[0] + + # Letzte 5 Sicherheitsereignisse + cur.execute(""" + SELECT + la.ip_address, + la.attempt_count, + la.last_attempt, + la.blocked_until, + la.last_username_tried, + la.last_error_message + FROM login_attempts la + ORDER BY la.last_attempt DESC + LIMIT 5 + """) + recent_security_events = [] + for event in cur.fetchall(): + recent_security_events.append({ + 'ip_address': event[0], + 'attempt_count': event[1], + 'last_attempt': event[2].strftime('%d.%m %H:%M'), + 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, + 'username_tried': event[4], + 'error_message': event[5] + }) + + # Sicherheitslevel berechnen + if blocked_ips_count > 5 or failed_attempts_today > 50: + security_level = 'danger' + security_level_text = 'KRITISCH' + elif blocked_ips_count > 2 or failed_attempts_today > 20: + security_level = 'warning' + security_level_text = 'ERHÖHT' + else: + security_level = 'success' + security_level_text = 'NORMAL' + + # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + WHERE is_test = FALSE + GROUP BY resource_type + """) + + resource_stats = {} + resource_warning = None + + for row in cur.fetchall(): + available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) + resource_stats[row[0]] = { + 'available': row[1], + 'allocated': row[2], + 'quarantine': row[3], + 'total': row[4], + 'available_percent': available_percent, + 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' + } + + # Warnung bei niedrigem Bestand + if row[1] < 50: + if not resource_warning: + resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" + else: + resource_warning += f" | {row[0].upper()}: {row[1]}" + + cur.close() + conn.close() + + stats = { + 'total_customers': total_customers, + 'total_licenses': total_licenses, + 'active_licenses': active_licenses, + 'expired_licenses': expired_licenses, + 'inactive_licenses': inactive_licenses, + 'expiring_soon': expiring_soon, + 'full_licenses': license_types.get('full', 0), + 'test_licenses': license_types.get('test', 0), + 'test_data_count': test_data_count, + 'test_customers_count': test_customers_count, + 'test_resources_count': test_resources_count, + 'recent_licenses': recent_licenses, + 'expiring_licenses': expiring_licenses, + 'active_sessions': active_sessions_count, + 'last_backup': last_backup_info, + # Sicherheitsstatistiken + 'blocked_ips_count': blocked_ips_count, + 'failed_attempts_today': failed_attempts_today, + 'recent_security_events': recent_security_events, + 'security_level': security_level, + 'security_level_text': security_level_text, + 'resource_stats': resource_stats + } + + return render_template("dashboard.html", + stats=stats, + resource_stats=resource_stats, + resource_warning=resource_warning, + username=session.get('username')) + +@app.route("/create", methods=["GET", "POST"]) +@login_required +def create_license(): + if request.method == "POST": + customer_id = request.form.get("customer_id") + license_key = request.form["license_key"].upper() # Immer Großbuchstaben + license_type = request.form["license_type"] + valid_from = request.form["valid_from"] + is_test = request.form.get("is_test") == "on" # Checkbox value + + # Berechne valid_until basierend auf Laufzeit + duration = int(request.form.get("duration", 1)) + duration_type = request.form.get("duration_type", "years") + + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta + + start_date = datetime.strptime(valid_from, "%Y-%m-%d") + + if duration_type == "days": + end_date = start_date + timedelta(days=duration) + elif duration_type == "months": + end_date = start_date + relativedelta(months=duration) + else: # years + end_date = start_date + relativedelta(years=duration) + + # Ein Tag abziehen, da der Starttag mitgezählt wird + end_date = end_date - timedelta(days=1) + valid_until = end_date.strftime("%Y-%m-%d") + + # Validiere License Key Format + if not validate_license_key(license_key): + flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') + return redirect(url_for('create_license')) + + # Resource counts + domain_count = int(request.form.get("domain_count", 1)) + ipv4_count = int(request.form.get("ipv4_count", 1)) + phone_count = int(request.form.get("phone_count", 1)) + device_limit = int(request.form.get("device_limit", 3)) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfe ob neuer Kunde oder bestehender + if customer_id == "new": + # Neuer Kunde + name = request.form.get("customer_name") + email = request.form.get("email") + + if not name: + flash('Kundenname ist erforderlich!', 'error') + return redirect(url_for('create_license')) + + # Prüfe ob E-Mail bereits existiert + if email: + cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) + existing = cur.fetchone() + if existing: + flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') + return redirect(url_for('create_license')) + + # Kunde einfügen (erbt Test-Status von Lizenz) + cur.execute(""" + INSERT INTO customers (name, email, is_test, created_at) + VALUES (%s, %s, %s, NOW()) + RETURNING id + """, (name, email, is_test)) + customer_id = cur.fetchone()[0] + customer_info = {'name': name, 'email': email, 'is_test': is_test} + + # Audit-Log für neuen Kunden + log_audit('CREATE', 'customer', customer_id, + new_values={'name': name, 'email': email, 'is_test': is_test}) + else: + # Bestehender Kunde - hole Infos für Audit-Log + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + customer_data = cur.fetchone() + if not customer_data: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('create_license')) + customer_info = {'name': customer_data[0], 'email': customer_data[1]} + + # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren + if customer_data[2]: # is_test des Kunden + is_test = True + + # Lizenz hinzufügen + cur.execute(""" + INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, + domain_count, ipv4_count, phone_count, device_limit, is_test) + VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) + RETURNING id + """, (license_key, customer_id, license_type, valid_from, valid_until, + domain_count, ipv4_count, phone_count, device_limit, is_test)) + license_id = cur.fetchone()[0] + + # Ressourcen zuweisen + try: + # Prüfe Verfügbarkeit + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones + """, (is_test, is_test, is_test)) + available = cur.fetchone() + + if available[0] < domain_count: + raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") + if available[1] < ipv4_count: + raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") + if available[2] < phone_count: + raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") + + # Domains zuweisen + if domain_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, domain_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # IPv4s zuweisen + if ipv4_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, ipv4_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # Telefonnummern zuweisen + if phone_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, phone_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + except ValueError as e: + conn.rollback() + flash(str(e), 'error') + return redirect(url_for('create_license')) + + conn.commit() + + # Audit-Log + log_audit('CREATE', 'license', license_id, + new_values={ + 'license_key': license_key, + 'customer_name': customer_info['name'], + 'customer_email': customer_info['email'], + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'device_limit': device_limit, + 'is_test': is_test + }) + + flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') + + except Exception as e: + conn.rollback() + logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") + flash('Fehler beim Erstellen der Lizenz!', 'error') + finally: + cur.close() + conn.close() + + # Preserve show_test parameter if present + redirect_url = "/create" + if request.args.get('show_test') == 'true': + redirect_url += "?show_test=true" + return redirect(redirect_url) + + # Unterstützung für vorausgewählten Kunden + preselected_customer_id = request.args.get('customer_id', type=int) + return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) + +@app.route("/batch", methods=["GET", "POST"]) +@login_required +def batch_licenses(): + """Batch-Generierung mehrerer Lizenzen für einen Kunden""" + if request.method == "POST": + # Formulardaten + customer_id = request.form.get("customer_id") + license_type = request.form["license_type"] + quantity = int(request.form["quantity"]) + valid_from = request.form["valid_from"] + is_test = request.form.get("is_test") == "on" # Checkbox value + + # Berechne valid_until basierend auf Laufzeit + duration = int(request.form.get("duration", 1)) + duration_type = request.form.get("duration_type", "years") + + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta + + start_date = datetime.strptime(valid_from, "%Y-%m-%d") + + if duration_type == "days": + end_date = start_date + timedelta(days=duration) + elif duration_type == "months": + end_date = start_date + relativedelta(months=duration) + else: # years + end_date = start_date + relativedelta(years=duration) + + # Ein Tag abziehen, da der Starttag mitgezählt wird + end_date = end_date - timedelta(days=1) + valid_until = end_date.strftime("%Y-%m-%d") + + # Resource counts + domain_count = int(request.form.get("domain_count", 1)) + ipv4_count = int(request.form.get("ipv4_count", 1)) + phone_count = int(request.form.get("phone_count", 1)) + device_limit = int(request.form.get("device_limit", 3)) + + # Sicherheitslimit + if quantity < 1 or quantity > 100: + flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') + return redirect(url_for('batch_licenses')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfe ob neuer Kunde oder bestehender + if customer_id == "new": + # Neuer Kunde + name = request.form.get("customer_name") + email = request.form.get("email") + + if not name: + flash('Kundenname ist erforderlich!', 'error') + return redirect(url_for('batch_licenses')) + + # Prüfe ob E-Mail bereits existiert + if email: + cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) + existing = cur.fetchone() + if existing: + flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') + return redirect(url_for('batch_licenses')) + + # Kunde einfügen (erbt Test-Status von Lizenz) + cur.execute(""" + INSERT INTO customers (name, email, is_test, created_at) + VALUES (%s, %s, %s, NOW()) + RETURNING id + """, (name, email, is_test)) + customer_id = cur.fetchone()[0] + + # Audit-Log für neuen Kunden + log_audit('CREATE', 'customer', customer_id, + new_values={'name': name, 'email': email, 'is_test': is_test}) + else: + # Bestehender Kunde - hole Infos + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + customer_data = cur.fetchone() + if not customer_data: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('batch_licenses')) + name = customer_data[0] + email = customer_data[1] + + # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren + if customer_data[2]: # is_test des Kunden + is_test = True + + # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch + total_domains_needed = domain_count * quantity + total_ipv4s_needed = ipv4_count * quantity + total_phones_needed = phone_count * quantity + + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones + """, (is_test, is_test, is_test)) + available = cur.fetchone() + + if available[0] < total_domains_needed: + flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') + return redirect(url_for('batch_licenses')) + if available[1] < total_ipv4s_needed: + flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') + return redirect(url_for('batch_licenses')) + if available[2] < total_phones_needed: + flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') + return redirect(url_for('batch_licenses')) + + # Lizenzen generieren und speichern + generated_licenses = [] + for i in range(quantity): + # Eindeutigen Key generieren + attempts = 0 + while attempts < 10: + license_key = generate_license_key(license_type) + cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) + if not cur.fetchone(): + break + attempts += 1 + + # Lizenz einfügen + cur.execute(""" + INSERT INTO licenses (license_key, customer_id, license_type, is_test, + valid_from, valid_until, is_active, + domain_count, ipv4_count, phone_count, device_limit) + VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) + RETURNING id + """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, + domain_count, ipv4_count, phone_count, device_limit)) + license_id = cur.fetchone()[0] + + # Ressourcen für diese Lizenz zuweisen + # Domains + if domain_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, domain_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # IPv4s + if ipv4_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, ipv4_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # Telefonnummern + if phone_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, phone_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + generated_licenses.append({ + 'id': license_id, + 'key': license_key, + 'type': license_type + }) + + conn.commit() + + # Audit-Log + log_audit('CREATE_BATCH', 'license', + new_values={'customer': name, 'quantity': quantity, 'type': license_type}, + additional_info=f"Batch-Generierung von {quantity} Lizenzen") + + # Session für Export speichern + session['batch_export'] = { + 'customer': name, + 'email': email, + 'licenses': generated_licenses, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + } + + flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') + return render_template("batch_result.html", + customer=name, + email=email, + licenses=generated_licenses, + valid_from=valid_from, + valid_until=valid_until) + + except Exception as e: + conn.rollback() + logging.error(f"Fehler bei Batch-Generierung: {str(e)}") + flash('Fehler bei der Batch-Generierung!', 'error') + return redirect(url_for('batch_licenses')) + finally: + cur.close() + conn.close() + + # GET Request + return render_template("batch_form.html") + +@app.route("/batch/export") +@login_required +def export_batch(): + """Exportiert die zuletzt generierten Batch-Lizenzen""" + batch_data = session.get('batch_export') + if not batch_data: + flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') + return redirect(url_for('batch_licenses')) + + # CSV generieren + output = io.StringIO() + output.write('\ufeff') # UTF-8 BOM für Excel + + # Header + output.write(f"Kunde: {batch_data['customer']}\n") + output.write(f"E-Mail: {batch_data['email']}\n") + output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") + output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") + output.write("\n") + output.write("Nr;Lizenzschlüssel;Typ\n") + + # Lizenzen + for i, license in enumerate(batch_data['licenses'], 1): + typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" + output.write(f"{i};{license['key']};{typ_text}\n") + + output.seek(0) + + # Audit-Log + log_audit('EXPORT', 'batch_licenses', + additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" + ) + +@app.route("/licenses") +@login_required +def licenses(): + # Redirect zur kombinierten Ansicht + return redirect("/customers-licenses") + +@app.route("/license/edit/", methods=["GET", "POST"]) +@login_required +def edit_license(license_id): + conn = get_connection() + cur = conn.cursor() + + if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute(""" + SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit + FROM licenses WHERE id = %s + """, (license_id,)) + old_license = cur.fetchone() + + # Update license + license_key = request.form["license_key"] + license_type = request.form["license_type"] + valid_from = request.form["valid_from"] + valid_until = request.form["valid_until"] + is_active = request.form.get("is_active") == "on" + is_test = request.form.get("is_test") == "on" + device_limit = int(request.form.get("device_limit", 3)) + + cur.execute(""" + UPDATE licenses + SET license_key = %s, license_type = %s, valid_from = %s, + valid_until = %s, is_active = %s, is_test = %s, device_limit = %s + WHERE id = %s + """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'license_key': old_license[0], + 'license_type': old_license[1], + 'valid_from': str(old_license[2]), + 'valid_until': str(old_license[3]), + 'is_active': old_license[4], + 'is_test': old_license[5], + 'device_limit': old_license[6] + }, + new_values={ + 'license_key': license_key, + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'is_active': is_active, + 'is_test': is_test, + 'device_limit': device_limit + }) + + cur.close() + conn.close() + + # Redirect zurück zu customers-licenses mit beibehaltenen Parametern + redirect_url = "/customers-licenses" + + # Behalte show_test Parameter bei (aus Form oder GET-Parameter) + show_test = request.form.get('show_test') or request.args.get('show_test') + if show_test == 'true': + redirect_url += "?show_test=true" + + # Behalte customer_id bei wenn vorhanden + if request.referrer and 'customer_id=' in request.referrer: + import re + match = re.search(r'customer_id=(\d+)', request.referrer) + if match: + connector = "&" if "?" in redirect_url else "?" + redirect_url += f"{connector}customer_id={match.group(1)}" + + return redirect(redirect_url) + + # Get license data + cur.execute(""" + SELECT l.id, l.license_key, c.name, c.email, l.license_type, + l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + + license = cur.fetchone() + cur.close() + conn.close() + + if not license: + return redirect("/licenses") + + return render_template("edit_license.html", license=license, username=session.get('username')) + +@app.route("/license/delete/", methods=["POST"]) +@login_required +def delete_license(license_id): + conn = get_connection() + cur = conn.cursor() + + # Lizenzdetails für Audit-Log abrufen + cur.execute(""" + SELECT l.license_key, c.name, l.license_type + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + license_info = cur.fetchone() + + cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) + + conn.commit() + + # Audit-Log + if license_info: + log_audit('DELETE', 'license', license_id, + old_values={ + 'license_key': license_info[0], + 'customer_name': license_info[1], + 'license_type': license_info[2] + }) + + cur.close() + conn.close() + + return redirect("/licenses") + +@app.route("/customers") +@login_required +def customers(): + # Redirect zur kombinierten Ansicht + return redirect("/customers-licenses") + +@app.route("/customer/edit/", methods=["GET", "POST"]) +@login_required +def edit_customer(customer_id): + conn = get_connection() + cur = conn.cursor() + + if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + old_customer = cur.fetchone() + + # Update customer + name = request.form["name"] + email = request.form["email"] + is_test = request.form.get("is_test") == "on" + + cur.execute(""" + UPDATE customers + SET name = %s, email = %s, is_test = %s + WHERE id = %s + """, (name, email, is_test, customer_id)) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'customer', customer_id, + old_values={ + 'name': old_customer[0], + 'email': old_customer[1], + 'is_test': old_customer[2] + }, + new_values={ + 'name': name, + 'email': email, + 'is_test': is_test + }) + + cur.close() + conn.close() + + # Redirect zurück zu customers-licenses mit beibehaltenen Parametern + redirect_url = "/customers-licenses" + + # Behalte show_test Parameter bei (aus Form oder GET-Parameter) + show_test = request.form.get('show_test') or request.args.get('show_test') + if show_test == 'true': + redirect_url += "?show_test=true" + + # Behalte customer_id bei (immer der aktuelle Kunde) + connector = "&" if "?" in redirect_url else "?" + redirect_url += f"{connector}customer_id={customer_id}" + + return redirect(redirect_url) + + # Get customer data with licenses + cur.execute(""" + SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s + """, (customer_id,)) + + customer = cur.fetchone() + if not customer: + cur.close() + conn.close() + return "Kunde nicht gefunden", 404 + + + # Get customer's licenses + cur.execute(""" + SELECT id, license_key, license_type, valid_from, valid_until, is_active + FROM licenses + WHERE customer_id = %s + ORDER BY valid_until DESC + """, (customer_id,)) + + licenses = cur.fetchall() + + cur.close() + conn.close() + + if not customer: + return redirect("/customers-licenses") + + return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) + +@app.route("/customer/create", methods=["GET", "POST"]) +@login_required +def create_customer(): + """Erstellt einen neuen Kunden ohne Lizenz""" + if request.method == "POST": + name = request.form.get('name') + email = request.form.get('email') + is_test = request.form.get('is_test') == 'on' + + if not name or not email: + flash("Name und E-Mail sind Pflichtfelder!", "error") + return render_template("create_customer.html", username=session.get('username')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfen ob E-Mail bereits existiert + cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) + existing = cur.fetchone() + if existing: + flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") + return render_template("create_customer.html", username=session.get('username')) + + # Kunde erstellen + cur.execute(""" + INSERT INTO customers (name, email, created_at, is_test) + VALUES (%s, %s, %s, %s) RETURNING id + """, (name, email, datetime.now(), is_test)) + + customer_id = cur.fetchone()[0] + conn.commit() + + # Audit-Log + log_audit('CREATE', 'customer', customer_id, + new_values={ + 'name': name, + 'email': email, + 'is_test': is_test + }) + + flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") + return redirect(f"/customer/edit/{customer_id}") + + except Exception as e: + conn.rollback() + flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") + return render_template("create_customer.html", username=session.get('username')) + finally: + cur.close() + conn.close() + + # GET Request - Formular anzeigen + return render_template("create_customer.html", username=session.get('username')) + +@app.route("/customer/delete/", methods=["POST"]) +@login_required +def delete_customer(customer_id): + conn = get_connection() + cur = conn.cursor() + + # Prüfen ob Kunde Lizenzen hat + cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) + license_count = cur.fetchone()[0] + + if license_count > 0: + # Kunde hat Lizenzen - nicht löschen + cur.close() + conn.close() + return redirect("/customers") + + # Kundendetails für Audit-Log abrufen + cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) + customer_info = cur.fetchone() + + # Kunde löschen wenn keine Lizenzen vorhanden + cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) + + conn.commit() + + # Audit-Log + if customer_info: + log_audit('DELETE', 'customer', customer_id, + old_values={ + 'name': customer_info[0], + 'email': customer_info[1] + }) + + cur.close() + conn.close() + + return redirect("/customers") + +@app.route("/customers-licenses") +@login_required +def customers_licenses(): + """Kombinierte Ansicht für Kunden und deren Lizenzen""" + conn = get_connection() + cur = conn.cursor() + + # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + query = """ + SELECT + c.id, + c.name, + c.email, + c.created_at, + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + """ + + if not show_test: + query += " WHERE c.is_test = FALSE" + + query += """ + GROUP BY c.id, c.name, c.email, c.created_at + ORDER BY c.name + """ + + cur.execute(query) + customers = cur.fetchall() + + # Hole ausgewählten Kunden nur wenn explizit in URL angegeben + selected_customer_id = request.args.get('customer_id', type=int) + licenses = [] + selected_customer = None + + if customers and selected_customer_id: + # Hole Daten des ausgewählten Kunden + for customer in customers: + if customer[0] == selected_customer_id: + selected_customer = customer + break + + # Hole Lizenzen des ausgewählten Kunden + if selected_customer: + cur.execute(""" + SELECT + l.id, + l.license_key, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status, + l.domain_count, + l.ipv4_count, + l.phone_count, + l.device_limit, + (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, + -- Actual resource counts + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count + FROM licenses l + WHERE l.customer_id = %s + ORDER BY l.created_at DESC, l.id DESC + """, (selected_customer_id,)) + licenses = cur.fetchall() + + cur.close() + conn.close() + + return render_template("customers_licenses.html", + customers=customers, + selected_customer=selected_customer, + selected_customer_id=selected_customer_id, + licenses=licenses, + show_test=show_test) + +@app.route("/api/customer//licenses") +@login_required +def api_customer_licenses(customer_id): + """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" + conn = get_connection() + cur = conn.cursor() + + # Hole Lizenzen des Kunden + cur.execute(""" + SELECT + l.id, + l.license_key, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status, + l.domain_count, + l.ipv4_count, + l.phone_count, + l.device_limit, + (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, + -- Actual resource counts + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count + FROM licenses l + WHERE l.customer_id = %s + ORDER BY l.created_at DESC, l.id DESC + """, (customer_id,)) + + licenses = [] + for row in cur.fetchall(): + license_id = row[0] + + # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz + cur.execute(""" + SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at + FROM resource_pools rp + JOIN license_resources lr ON rp.id = lr.resource_id + WHERE lr.license_id = %s AND lr.is_active = true + ORDER BY rp.resource_type, rp.resource_value + """, (license_id,)) + + resources = { + 'domains': [], + 'ipv4s': [], + 'phones': [] + } + + for res_row in cur.fetchall(): + resource_info = { + 'id': res_row[0], + 'value': res_row[2], + 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' + } + + if res_row[1] == 'domain': + resources['domains'].append(resource_info) + elif res_row[1] == 'ipv4': + resources['ipv4s'].append(resource_info) + elif res_row[1] == 'phone': + resources['phones'].append(resource_info) + + licenses.append({ + 'id': row[0], + 'license_key': row[1], + 'license_type': row[2], + 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', + 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', + 'is_active': row[5], + 'status': row[6], + 'domain_count': row[7], # limit + 'ipv4_count': row[8], # limit + 'phone_count': row[9], # limit + 'device_limit': row[10], + 'active_devices': row[11], + 'actual_domain_count': row[12], # actual count + 'actual_ipv4_count': row[13], # actual count + 'actual_phone_count': row[14], # actual count + 'resources': resources + }) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'licenses': licenses, + 'count': len(licenses) + }) + +@app.route("/api/customer//quick-stats") +@login_required +def api_customer_quick_stats(customer_id): + """API-Endpoint für Schnellstatistiken eines Kunden""" + conn = get_connection() + cur = conn.cursor() + + # Hole Kundenstatistiken + cur.execute(""" + SELECT + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon + FROM licenses l + WHERE l.customer_id = %s + """, (customer_id,)) + + stats = cur.fetchone() + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'stats': { + 'total': stats[0], + 'active': stats[1], + 'expired': stats[2], + 'expiring_soon': stats[3] + } + }) + +@app.route("/api/license//quick-edit", methods=['POST']) +@login_required +def api_license_quick_edit(license_id): + """API-Endpoint für schnelle Lizenz-Bearbeitung""" + conn = get_connection() + cur = conn.cursor() + + try: + data = request.get_json() + + # Hole alte Werte für Audit-Log + cur.execute(""" + SELECT is_active, valid_until, license_type + FROM licenses WHERE id = %s + """, (license_id,)) + old_values = cur.fetchone() + + if not old_values: + return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 + + # Update-Felder vorbereiten + updates = [] + params = [] + new_values = {} + + if 'is_active' in data: + updates.append("is_active = %s") + params.append(data['is_active']) + new_values['is_active'] = data['is_active'] + + if 'valid_until' in data: + updates.append("valid_until = %s") + params.append(data['valid_until']) + new_values['valid_until'] = data['valid_until'] + + if 'license_type' in data: + updates.append("license_type = %s") + params.append(data['license_type']) + new_values['license_type'] = data['license_type'] + + if updates: + params.append(license_id) + cur.execute(f""" + UPDATE licenses + SET {', '.join(updates)} + WHERE id = %s + """, params) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'is_active': old_values[0], + 'valid_until': old_values[1].isoformat() if old_values[1] else None, + 'license_type': old_values[2] + }, + new_values=new_values) + + cur.close() + conn.close() + + return jsonify({'success': True}) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/api/license//resources") +@login_required +def api_license_resources(license_id): + """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" + conn = get_connection() + cur = conn.cursor() + + try: + # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz + cur.execute(""" + SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at + FROM resource_pools rp + JOIN license_resources lr ON rp.id = lr.resource_id + WHERE lr.license_id = %s AND lr.is_active = true + ORDER BY rp.resource_type, rp.resource_value + """, (license_id,)) + + resources = { + 'domains': [], + 'ipv4s': [], + 'phones': [] + } + + for row in cur.fetchall(): + resource_info = { + 'id': row[0], + 'value': row[2], + 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' + } + + if row[1] == 'domain': + resources['domains'].append(resource_info) + elif row[1] == 'ipv4': + resources['ipv4s'].append(resource_info) + elif row[1] == 'phone': + resources['phones'].append(resource_info) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'resources': resources + }) + + except Exception as e: + cur.close() + conn.close() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/sessions") +@login_required +def sessions(): + conn = get_connection() + cur = conn.cursor() + + # Sortierparameter + active_sort = request.args.get('active_sort', 'last_heartbeat') + active_order = request.args.get('active_order', 'desc') + ended_sort = request.args.get('ended_sort', 'ended_at') + ended_order = request.args.get('ended_order', 'desc') + + # Whitelist für erlaubte Sortierfelder - Aktive Sessions + active_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'last_heartbeat': 's.last_heartbeat', + 'inactive': 'minutes_inactive' + } + + # Whitelist für erlaubte Sortierfelder - Beendete Sessions + ended_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'ended_at': 's.ended_at', + 'duration': 'duration_minutes' + } + + # Validierung + if active_sort not in active_sort_fields: + active_sort = 'last_heartbeat' + if ended_sort not in ended_sort_fields: + ended_sort = 'ended_at' + if active_order not in ['asc', 'desc']: + active_order = 'desc' + if ended_order not in ['asc', 'desc']: + ended_order = 'desc' + + # Aktive Sessions abrufen + cur.execute(f""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.user_agent, s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = TRUE + ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} + """) + active_sessions = cur.fetchall() + + # Inaktive Sessions der letzten 24 Stunden + cur.execute(f""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = FALSE + AND s.ended_at > NOW() - INTERVAL '24 hours' + ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} + LIMIT 50 + """) + recent_sessions = cur.fetchall() + + cur.close() + conn.close() + + return render_template("sessions.html", + active_sessions=active_sessions, + recent_sessions=recent_sessions, + active_sort=active_sort, + active_order=active_order, + ended_sort=ended_sort, + ended_order=ended_order, + username=session.get('username')) + +@app.route("/session/end/", methods=["POST"]) +@login_required +def end_session(session_id): + conn = get_connection() + cur = conn.cursor() + + # Session beenden + cur.execute(""" + UPDATE sessions + SET is_active = FALSE, ended_at = NOW() + WHERE id = %s AND is_active = TRUE + """, (session_id,)) + + conn.commit() + cur.close() + conn.close() + + return redirect("/sessions") + +@app.route("/export/licenses") +@login_required +def export_licenses(): + conn = get_connection() + cur = conn.cursor() + + # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) + include_test = request.args.get('include_test', 'false').lower() == 'true' + customer_id = request.args.get('customer_id', type=int) + + query = """ + SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, + l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, + CASE + WHEN l.is_active = FALSE THEN 'Deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' + ELSE 'Aktiv' + END as status + FROM licenses l + JOIN customers c ON l.customer_id = c.id + """ + + # Build WHERE clause + where_conditions = [] + params = [] + + if not include_test: + where_conditions.append("l.is_test = FALSE") + + if customer_id: + where_conditions.append("l.customer_id = %s") + params.append(customer_id) + + if where_conditions: + query += " WHERE " + " AND ".join(where_conditions) + + query += " ORDER BY l.id" + + cur.execute(query, params) + + # Spaltennamen + columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', + 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] + + # Daten in DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + # Datumsformatierung + df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') + df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') + + # Typ und Aktiv Status anpassen + df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) + df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) + df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) + + cur.close() + conn.close() + + # Export Format + export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'license', + additional_info=f"Export aller Lizenzen als {export_format.upper()}") + filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Lizenzen', index=False) + + # Formatierung + worksheet = writer.sheets['Lizenzen'] + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/audit") +@login_required +def export_audit(): + conn = get_connection() + cur = conn.cursor() + + # Holen der Filter-Parameter + filter_user = request.args.get('user', '') + filter_action = request.args.get('action', '') + filter_entity = request.args.get('entity', '') + export_format = request.args.get('format', 'excel') + + # SQL Query mit Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + params = [] + + if filter_user: + query += " AND username ILIKE %s" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + query += " ORDER BY timestamp DESC" + + cur.execute(query, params) + audit_logs = cur.fetchall() + cur.close() + conn.close() + + # Daten für Export vorbereiten + data = [] + for log in audit_logs: + action_text = { + 'CREATE': 'Erstellt', + 'UPDATE': 'Bearbeitet', + 'DELETE': 'Gelöscht', + 'LOGIN': 'Anmeldung', + 'LOGOUT': 'Abmeldung', + 'AUTO_LOGOUT': 'Auto-Logout', + 'EXPORT': 'Export', + 'GENERATE_KEY': 'Key generiert', + 'CREATE_BATCH': 'Batch erstellt', + 'BACKUP': 'Backup erstellt', + 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', + 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', + 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', + 'LOGIN_BLOCKED': 'Login-Blockiert', + 'RESTORE': 'Wiederhergestellt', + 'PASSWORD_CHANGE': 'Passwort geändert', + '2FA_ENABLED': '2FA aktiviert', + '2FA_DISABLED': '2FA deaktiviert' + }.get(log[3], log[3]) + + data.append({ + 'ID': log[0], + 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), + 'Benutzer': log[2], + 'Aktion': action_text, + 'Entität': log[4], + 'Entität-ID': log[5] or '', + 'IP-Adresse': log[8] or '', + 'Zusatzinfo': log[10] or '' + }) + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'audit_log_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'audit_log', + additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Audit Log') + + # Spaltenbreiten anpassen + worksheet = writer.sheets['Audit Log'] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/customers") +@login_required +def export_customers(): + conn = get_connection() + cur = conn.cursor() + + # Check if test data should be included + include_test = request.args.get('include_test', 'false').lower() == 'true' + + # Build query based on test data filter + if include_test: + # Include all customers + query = """ + SELECT c.id, c.name, c.email, c.created_at, c.is_test, + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + GROUP BY c.id, c.name, c.email, c.created_at, c.is_test + ORDER BY c.id + """ + else: + # Exclude test customers and test licenses + query = """ + SELECT c.id, c.name, c.email, c.created_at, c.is_test, + COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.is_test = FALSE + GROUP BY c.id, c.name, c.email, c.created_at, c.is_test + ORDER BY c.id + """ + + cur.execute(query) + + # Spaltennamen + columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', + 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] + + # Daten in DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + # Datumsformatierung + df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') + + # Testdaten formatting + df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) + + cur.close() + conn.close() + + # Export Format + export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'customer', + additional_info=f"Export aller Kunden als {export_format.upper()}") + filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Kunden', index=False) + + # Formatierung + worksheet = writer.sheets['Kunden'] + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/sessions") +@login_required +def export_sessions(): + conn = get_connection() + cur = conn.cursor() + + # Holen des Session-Typs (active oder ended) + session_type = request.args.get('type', 'active') + export_format = request.args.get('format', 'excel') + + # Daten je nach Typ abrufen + if session_type == 'active': + # Aktive Lizenz-Sessions + cur.execute(""" + SELECT s.id, l.license_key, c.name as customer_name, s.session_id, + s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, + s.ip_address, s.user_agent + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = true + ORDER BY s.last_heartbeat DESC + """) + sessions = cur.fetchall() + + # Daten für Export vorbereiten + data = [] + for sess in sessions: + duration = sess[6] + hours = duration // 3600 + minutes = (duration % 3600) // 60 + seconds = duration % 60 + + data.append({ + 'Session-ID': sess[0], + 'Lizenzschlüssel': sess[1], + 'Kunde': sess[2], + 'Session-ID (Tech)': sess[3], + 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), + 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), + 'Dauer': f"{hours}h {minutes}m {seconds}s", + 'IP-Adresse': sess[7], + 'Browser': sess[8] + }) + + sheet_name = 'Aktive Sessions' + filename_prefix = 'aktive_sessions' + else: + # Beendete Lizenz-Sessions + cur.execute(""" + SELECT s.id, l.license_key, c.name as customer_name, s.session_id, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, + s.ip_address, s.user_agent + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = false AND s.ended_at IS NOT NULL + ORDER BY s.ended_at DESC + LIMIT 1000 + """) + sessions = cur.fetchall() + + # Daten für Export vorbereiten + data = [] + for sess in sessions: + duration = sess[6] if sess[6] else 0 + hours = duration // 3600 + minutes = (duration % 3600) // 60 + seconds = duration % 60 + + data.append({ + 'Session-ID': sess[0], + 'Lizenzschlüssel': sess[1], + 'Kunde': sess[2], + 'Session-ID (Tech)': sess[3], + 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), + 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', + 'Dauer': f"{hours}h {minutes}m {seconds}s", + 'IP-Adresse': sess[7], + 'Browser': sess[8] + }) + + sheet_name = 'Beendete Sessions' + filename_prefix = 'beendete_sessions' + + cur.close() + conn.close() + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'{filename_prefix}_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'sessions', + additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name=sheet_name) + + # Spaltenbreiten anpassen + worksheet = writer.sheets[sheet_name] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/resources") +@login_required +def export_resources(): + conn = get_connection() + cur = conn.cursor() + + # Holen der Filter-Parameter + filter_type = request.args.get('type', '') + filter_status = request.args.get('status', '') + search_query = request.args.get('search', '') + show_test = request.args.get('show_test', 'false').lower() == 'true' + export_format = request.args.get('format', 'excel') + + # SQL Query mit Filtern + query = """ + SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, + r.created_at, r.status_changed_at, + l.license_key, c.name as customer_name, c.email as customer_email, + l.license_type + FROM resource_pools r + LEFT JOIN licenses l ON r.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE 1=1 + """ + params = [] + + # Filter für Testdaten + if not show_test: + query += " AND (r.is_test = false OR r.is_test IS NULL)" + + # Filter für Ressourcentyp + if filter_type: + query += " AND r.resource_type = %s" + params.append(filter_type) + + # Filter für Status + if filter_status: + query += " AND r.status = %s" + params.append(filter_status) + + # Suchfilter + if search_query: + query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" + params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) + + query += " ORDER BY r.id DESC" + + cur.execute(query, params) + resources = cur.fetchall() + cur.close() + conn.close() + + # Daten für Export vorbereiten + data = [] + for res in resources: + status_text = { + 'available': 'Verfügbar', + 'allocated': 'Zugewiesen', + 'quarantine': 'Quarantäne' + }.get(res[3], res[3]) + + type_text = { + 'domain': 'Domain', + 'ipv4': 'IPv4', + 'phone': 'Telefon' + }.get(res[1], res[1]) + + data.append({ + 'ID': res[0], + 'Typ': type_text, + 'Ressource': res[2], + 'Status': status_text, + 'Lizenzschlüssel': res[7] or '', + 'Kunde': res[8] or '', + 'Kunden-Email': res[9] or '', + 'Lizenztyp': res[10] or '', + 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', + 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' + }) + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'resources_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'resources', + additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Resources') + + # Spaltenbreiten anpassen + worksheet = writer.sheets['Resources'] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/audit") +@login_required +def audit_log(): + conn = get_connection() + cur = conn.cursor() + + # Parameter + filter_user = request.args.get('user', '').strip() + filter_action = request.args.get('action', '').strip() + filter_entity = request.args.get('entity', '').strip() + page = request.args.get('page', 1, type=int) + sort = request.args.get('sort', 'timestamp') + order = request.args.get('order', 'desc') + per_page = 50 + + # Whitelist für erlaubte Sortierfelder + allowed_sort_fields = { + 'timestamp': 'timestamp', + 'username': 'username', + 'action': 'action', + 'entity': 'entity_type', + 'ip': 'ip_address' + } + + # Validierung + if sort not in allowed_sort_fields: + sort = 'timestamp' + if order not in ['asc', 'desc']: + order = 'desc' + + sort_field = allowed_sort_fields[sort] + + # SQL Query mit optionalen Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + + params = [] + + # Filter + if filter_user: + query += " AND LOWER(username) LIKE LOWER(%s)" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + # Gesamtanzahl für Pagination + count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" + cur.execute(count_query, params) + total = cur.fetchone()[0] + + # Pagination + offset = (page - 1) * per_page + query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + logs = cur.fetchall() + + # JSON-Werte parsen + parsed_logs = [] + for log in logs: + parsed_log = list(log) + # old_values und new_values sind bereits Dictionaries (JSONB) + # Keine Konvertierung nötig + parsed_logs.append(parsed_log) + + # Pagination Info + total_pages = (total + per_page - 1) // per_page + + cur.close() + conn.close() + + return render_template("audit_log.html", + logs=parsed_logs, + filter_user=filter_user, + filter_action=filter_action, + filter_entity=filter_entity, + page=page, + total_pages=total_pages, + total=total, + sort=sort, + order=order, + username=session.get('username')) + +@app.route("/backups") +@login_required +def backups(): + """Zeigt die Backup-Historie an""" + conn = get_connection() + cur = conn.cursor() + + # Letztes erfolgreiches Backup für Dashboard + cur.execute(""" + SELECT created_at, filesize, duration_seconds + FROM backup_history + WHERE status = 'success' + ORDER BY created_at DESC + LIMIT 1 + """) + last_backup = cur.fetchone() + + # Alle Backups abrufen + cur.execute(""" + SELECT id, filename, filesize, backup_type, status, error_message, + created_at, created_by, tables_count, records_count, + duration_seconds, is_encrypted + FROM backup_history + ORDER BY created_at DESC + """) + backups = cur.fetchall() + + cur.close() + conn.close() + + return render_template("backups.html", + backups=backups, + last_backup=last_backup, + username=session.get('username')) + +@app.route("/backup/create", methods=["POST"]) +@login_required +def create_backup_route(): + """Erstellt ein manuelles Backup""" + username = session.get('username') + success, result = create_backup(backup_type="manual", created_by=username) + + if success: + return jsonify({ + 'success': True, + 'message': f'Backup erfolgreich erstellt: {result}' + }) + else: + return jsonify({ + 'success': False, + 'message': f'Backup fehlgeschlagen: {result}' + }), 500 + +@app.route("/backup/restore/", methods=["POST"]) +@login_required +def restore_backup_route(backup_id): + """Stellt ein Backup wieder her""" + encryption_key = request.form.get('encryption_key') + + success, message = restore_backup(backup_id, encryption_key) + + if success: + return jsonify({ + 'success': True, + 'message': message + }) + else: + return jsonify({ + 'success': False, + 'message': message + }), 500 + +@app.route("/backup/download/") +@login_required +def download_backup(backup_id): + """Lädt eine Backup-Datei herunter""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT filename, filepath + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + cur.close() + conn.close() + + if not backup_info: + return "Backup nicht gefunden", 404 + + filename, filepath = backup_info + filepath = Path(filepath) + + if not filepath.exists(): + return "Backup-Datei nicht gefunden", 404 + + # Audit-Log + log_audit('DOWNLOAD', 'backup', backup_id, + additional_info=f"Backup heruntergeladen: {filename}") + + return send_file(filepath, as_attachment=True, download_name=filename) + +@app.route("/backup/delete/", methods=["DELETE"]) +@login_required +def delete_backup(backup_id): + """Löscht ein Backup""" + conn = get_connection() + cur = conn.cursor() + + try: + # Backup-Informationen abrufen + cur.execute(""" + SELECT filename, filepath + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + if not backup_info: + return jsonify({ + 'success': False, + 'message': 'Backup nicht gefunden' + }), 404 + + filename, filepath = backup_info + filepath = Path(filepath) + + # Datei löschen, wenn sie existiert + if filepath.exists(): + filepath.unlink() + + # Aus Datenbank löschen + cur.execute(""" + DELETE FROM backup_history + WHERE id = %s + """, (backup_id,)) + + conn.commit() + + # Audit-Log + log_audit('DELETE', 'backup', backup_id, + additional_info=f"Backup gelöscht: {filename}") + + return jsonify({ + 'success': True, + 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' + }) + + except Exception as e: + conn.rollback() + return jsonify({ + 'success': False, + 'message': f'Fehler beim Löschen des Backups: {str(e)}' + }), 500 + finally: + cur.close() + conn.close() + +@app.route("/security/blocked-ips") +@login_required +def blocked_ips(): + """Zeigt alle gesperrten IPs an""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT + ip_address, + attempt_count, + first_attempt, + last_attempt, + blocked_until, + last_username_tried, + last_error_message + FROM login_attempts + WHERE blocked_until IS NOT NULL + ORDER BY blocked_until DESC + """) + + blocked_ips_list = [] + for ip in cur.fetchall(): + blocked_ips_list.append({ + 'ip_address': ip[0], + 'attempt_count': ip[1], + 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), + 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), + 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), + 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), + 'last_username': ip[5], + 'last_error': ip[6] + }) + + cur.close() + conn.close() + + return render_template("blocked_ips.html", + blocked_ips=blocked_ips_list, + username=session.get('username')) + +@app.route("/security/unblock-ip", methods=["POST"]) +@login_required +def unblock_ip(): + """Entsperrt eine IP-Adresse""" + ip_address = request.form.get('ip_address') + + if ip_address: + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + UPDATE login_attempts + SET blocked_until = NULL + WHERE ip_address = %s + """, (ip_address,)) + + conn.commit() + cur.close() + conn.close() + + # Audit-Log + log_audit('UNBLOCK_IP', 'security', + additional_info=f"IP {ip_address} manuell entsperrt") + + return redirect(url_for('blocked_ips')) + +@app.route("/security/clear-attempts", methods=["POST"]) +@login_required +def clear_attempts(): + """Löscht alle Login-Versuche für eine IP""" + ip_address = request.form.get('ip_address') + + if ip_address: + reset_login_attempts(ip_address) + + # Audit-Log + log_audit('CLEAR_ATTEMPTS', 'security', + additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") + + return redirect(url_for('blocked_ips')) + +# API Endpoints for License Management +@app.route("/api/license//toggle", methods=["POST"]) +@login_required +def toggle_license_api(license_id): + """Toggle license active status via API""" + try: + data = request.get_json() + is_active = data.get('is_active', False) + + conn = get_connection() + cur = conn.cursor() + + # Update license status + cur.execute(""" + UPDATE licenses + SET is_active = %s + WHERE id = %s + """, (is_active, license_id)) + + conn.commit() + + # Log the action + log_audit('UPDATE', 'license', license_id, + new_values={'is_active': is_active}, + additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/licenses/bulk-activate", methods=["POST"]) +@login_required +def bulk_activate_licenses(): + """Activate multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Update all selected licenses (nur Live-Daten) + cur.execute(""" + UPDATE licenses + SET is_active = TRUE + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_UPDATE', 'licenses', None, + new_values={'is_active': True, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen aktiviert") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) +@login_required +def bulk_deactivate_licenses(): + """Deactivate multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Update all selected licenses (nur Live-Daten) + cur.execute(""" + UPDATE licenses + SET is_active = FALSE + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_UPDATE', 'licenses', None, + new_values={'is_active': False, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen deaktiviert") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/license//devices") +@login_required +def get_license_devices(license_id): + """Hole alle registrierten Geräte einer Lizenz""" + try: + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Lizenz existiert und hole device_limit + cur.execute(""" + SELECT device_limit FROM licenses WHERE id = %s + """, (license_id,)) + license_data = cur.fetchone() + + if not license_data: + return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + + device_limit = license_data[0] + + # Hole alle Geräte für diese Lizenz + cur.execute(""" + SELECT id, hardware_id, device_name, operating_system, + first_seen, last_seen, is_active, ip_address + FROM device_registrations + WHERE license_id = %s + ORDER BY is_active DESC, last_seen DESC + """, (license_id,)) + + devices = [] + for row in cur.fetchall(): + devices.append({ + 'id': row[0], + 'hardware_id': row[1], + 'device_name': row[2] or 'Unbekanntes Gerät', + 'operating_system': row[3] or 'Unbekannt', + 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', + 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', + 'is_active': row[6], + 'ip_address': row[7] or '-' + }) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'devices': devices, + 'device_limit': device_limit, + 'active_count': sum(1 for d in devices if d['is_active']) + }) + + except Exception as e: + logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 + +@app.route("/api/license//register-device", methods=["POST"]) +def register_device(license_id): + """Registriere ein neues Gerät für eine Lizenz""" + try: + data = request.get_json() + hardware_id = data.get('hardware_id') + device_name = data.get('device_name', '') + operating_system = data.get('operating_system', '') + + if not hardware_id: + return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Lizenz existiert und aktiv ist + cur.execute(""" + SELECT device_limit, is_active, valid_until + FROM licenses + WHERE id = %s + """, (license_id,)) + license_data = cur.fetchone() + + if not license_data: + return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + + device_limit, is_active, valid_until = license_data + + # Prüfe ob Lizenz aktiv und gültig ist + if not is_active: + return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 + + if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): + return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 + + # Prüfe ob Gerät bereits registriert ist + cur.execute(""" + SELECT id, is_active FROM device_registrations + WHERE license_id = %s AND hardware_id = %s + """, (license_id, hardware_id)) + existing_device = cur.fetchone() + + if existing_device: + device_id, is_device_active = existing_device + if is_device_active: + # Gerät ist bereits aktiv, update last_seen + cur.execute(""" + UPDATE device_registrations + SET last_seen = CURRENT_TIMESTAMP, + ip_address = %s, + user_agent = %s + WHERE id = %s + """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + conn.commit() + return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) + else: + # Gerät war deaktiviert, prüfe ob wir es reaktivieren können + cur.execute(""" + SELECT COUNT(*) FROM device_registrations + WHERE license_id = %s AND is_active = TRUE + """, (license_id,)) + active_count = cur.fetchone()[0] + + if active_count >= device_limit: + return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + + # Reaktiviere das Gerät + cur.execute(""" + UPDATE device_registrations + SET is_active = TRUE, + last_seen = CURRENT_TIMESTAMP, + deactivated_at = NULL, + deactivated_by = NULL, + ip_address = %s, + user_agent = %s + WHERE id = %s + """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + conn.commit() + return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) + + # Neues Gerät - prüfe Gerätelimit + cur.execute(""" + SELECT COUNT(*) FROM device_registrations + WHERE license_id = %s AND is_active = TRUE + """, (license_id,)) + active_count = cur.fetchone()[0] + + if active_count >= device_limit: + return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + + # Registriere neues Gerät + cur.execute(""" + INSERT INTO device_registrations + (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, (license_id, hardware_id, device_name, operating_system, + get_client_ip(), request.headers.get('User-Agent', ''))) + device_id = cur.fetchone()[0] + + conn.commit() + + # Audit Log + log_audit('DEVICE_REGISTER', 'device', device_id, + new_values={'license_id': license_id, 'hardware_id': hardware_id}) + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) + + except Exception as e: + logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 + +@app.route("/api/license//deactivate-device/", methods=["POST"]) +@login_required +def deactivate_device(license_id, device_id): + """Deaktiviere ein registriertes Gerät""" + try: + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob das Gerät zu dieser Lizenz gehört + cur.execute(""" + SELECT id FROM device_registrations + WHERE id = %s AND license_id = %s AND is_active = TRUE + """, (device_id, license_id)) + + if not cur.fetchone(): + return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 + + # Deaktiviere das Gerät + cur.execute(""" + UPDATE device_registrations + SET is_active = FALSE, + deactivated_at = CURRENT_TIMESTAMP, + deactivated_by = %s + WHERE id = %s + """, (session['username'], device_id)) + + conn.commit() + + # Audit Log + log_audit('DEVICE_DEACTIVATE', 'device', device_id, + old_values={'is_active': True}, + new_values={'is_active': False}) + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) + + except Exception as e: + logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 + +@app.route("/api/licenses/bulk-delete", methods=["POST"]) +@login_required +def bulk_delete_licenses(): + """Delete multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Get license info for audit log (nur Live-Daten) + cur.execute(""" + SELECT license_key + FROM licenses + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + license_keys = [row[0] for row in cur.fetchall()] + + # Delete all selected licenses (nur Live-Daten) + cur.execute(""" + DELETE FROM licenses + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_DELETE', 'licenses', None, + old_values={'license_keys': license_keys, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen gelöscht") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +# ===================== RESOURCE POOL MANAGEMENT ===================== + +@app.route('/resources') +@login_required +def resources(): + """Resource Pool Hauptübersicht""" + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + # Statistiken abrufen + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + WHERE is_test = %s + GROUP BY resource_type + """, (show_test,)) + + stats = {} + for row in cur.fetchall(): + stats[row[0]] = { + 'available': row[1], + 'allocated': row[2], + 'quarantine': row[3], + 'total': row[4], + 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) + } + + # Letzte Aktivitäten (gefiltert nach Test/Live) + cur.execute(""" + SELECT + rh.action, + rh.action_by, + rh.action_at, + rp.resource_type, + rp.resource_value, + rh.details + FROM resource_history rh + JOIN resource_pools rp ON rh.resource_id = rp.id + WHERE rp.is_test = %s + ORDER BY rh.action_at DESC + LIMIT 10 + """, (show_test,)) + recent_activities = cur.fetchall() + + # Ressourcen-Liste mit Pagination + page = request.args.get('page', 1, type=int) + per_page = 50 + offset = (page - 1) * per_page + + resource_type = request.args.get('type', '') + status_filter = request.args.get('status', '') + search = request.args.get('search', '') + + # Sortierung + sort_by = request.args.get('sort', 'id') + sort_order = request.args.get('order', 'desc') + + # Base Query + query = """ + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + rp.allocated_to_license, + l.license_key, + c.name as customer_name, + rp.status_changed_at, + rp.quarantine_reason, + rp.quarantine_until, + c.id as customer_id + FROM resource_pools rp + LEFT JOIN licenses l ON rp.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE rp.is_test = %s + """ + params = [show_test] + + if resource_type: + query += " AND rp.resource_type = %s" + params.append(resource_type) + + if status_filter: + query += " AND rp.status = %s" + params.append(status_filter) + + if search: + query += " AND rp.resource_value ILIKE %s" + params.append(f'%{search}%') + + # Count total + count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" + cur.execute(count_query, params) + total = cur.fetchone()[0] + total_pages = (total + per_page - 1) // per_page + + # Get paginated results with dynamic sorting + sort_column_map = { + 'id': 'rp.id', + 'type': 'rp.resource_type', + 'resource': 'rp.resource_value', + 'status': 'rp.status', + 'assigned': 'c.name', + 'changed': 'rp.status_changed_at' + } + + sort_column = sort_column_map.get(sort_by, 'rp.id') + sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' + + query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + resources = cur.fetchall() + + cur.close() + conn.close() + + return render_template('resources.html', + stats=stats, + resources=resources, + recent_activities=recent_activities, + page=page, + total_pages=total_pages, + total=total, + resource_type=resource_type, + status_filter=status_filter, + search=search, + show_test=show_test, + sort_by=sort_by, + sort_order=sort_order, + datetime=datetime, + timedelta=timedelta) + +@app.route('/resources/add', methods=['GET', 'POST']) +@login_required +def add_resources(): + """Ressourcen zum Pool hinzufügen""" + # Hole show_test Parameter für die Anzeige + show_test = request.args.get('show_test', 'false').lower() == 'true' + + if request.method == 'POST': + resource_type = request.form.get('resource_type') + resources_text = request.form.get('resources_text', '') + is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten + + # Parse resources (one per line) + resources = [r.strip() for r in resources_text.split('\n') if r.strip()] + + if not resources: + flash('Keine Ressourcen angegeben', 'error') + return redirect(url_for('add_resources', show_test=show_test)) + + conn = get_connection() + cur = conn.cursor() + + added = 0 + duplicates = 0 + + for resource_value in resources: + try: + cur.execute(""" + INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) + VALUES (%s, %s, %s, %s) + ON CONFLICT (resource_type, resource_value) DO NOTHING + """, (resource_type, resource_value, session['username'], is_test)) + + if cur.rowcount > 0: + added += 1 + # Get the inserted ID + cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", + (resource_type, resource_value)) + resource_id = cur.fetchone()[0] + + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address) + VALUES (%s, 'created', %s, %s) + """, (resource_id, session['username'], get_client_ip())) + else: + duplicates += 1 + + except Exception as e: + app.logger.error(f"Error adding resource {resource_value}: {e}") + + conn.commit() + cur.close() + conn.close() + + log_audit('CREATE', 'resource_pool', None, + new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, + additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") + + flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') + return redirect(url_for('resources', show_test=show_test)) + + return render_template('add_resources.html', show_test=show_test) + +@app.route('/resources/quarantine/', methods=['POST']) +@login_required +def quarantine_resource(resource_id): + """Ressource in Quarantäne setzen""" + reason = request.form.get('reason', 'review') + until_date = request.form.get('until_date') + notes = request.form.get('notes', '') + + conn = get_connection() + cur = conn.cursor() + + # Get current resource info + cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) + resource = cur.fetchone() + + if not resource: + flash('Ressource nicht gefunden', 'error') + return redirect(url_for('resources')) + + old_status = resource[2] + + # Update resource + cur.execute(""" + UPDATE resource_pools + SET status = 'quarantine', + quarantine_reason = %s, + quarantine_until = %s, + notes = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) + + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) + VALUES (%s, 'quarantined', %s, %s, %s) + """, (resource_id, session['username'], get_client_ip(), + Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) + + conn.commit() + cur.close() + conn.close() + + log_audit('UPDATE', 'resource', resource_id, + old_values={'status': old_status}, + new_values={'status': 'quarantine', 'reason': reason}, + additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") + + flash('Ressource in Quarantäne gesetzt', 'success') + + # Redirect mit allen aktuellen Filtern + return redirect(url_for('resources', + show_test=request.args.get('show_test', request.form.get('show_test', 'false')), + type=request.args.get('type', request.form.get('type', '')), + status=request.args.get('status', request.form.get('status', '')), + search=request.args.get('search', request.form.get('search', '')))) + +@app.route('/resources/release', methods=['POST']) +@login_required +def release_resources(): + """Ressourcen aus Quarantäne freigeben""" + resource_ids = request.form.getlist('resource_ids') + + if not resource_ids: + flash('Keine Ressourcen ausgewählt', 'error') + return redirect(url_for('resources')) + + conn = get_connection() + cur = conn.cursor() + + released = 0 + for resource_id in resource_ids: + cur.execute(""" + UPDATE resource_pools + SET status = 'available', + quarantine_reason = NULL, + quarantine_until = NULL, + allocated_to_license = NULL, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s AND status = 'quarantine' + """, (session['username'], resource_id)) + + if cur.rowcount > 0: + released += 1 + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address) + VALUES (%s, 'released', %s, %s) + """, (resource_id, session['username'], get_client_ip())) + + conn.commit() + cur.close() + conn.close() + + log_audit('UPDATE', 'resource_pool', None, + new_values={'released': released}, + additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") + + flash(f'{released} Ressourcen freigegeben', 'success') + + # Redirect mit allen aktuellen Filtern + return redirect(url_for('resources', + show_test=request.args.get('show_test', request.form.get('show_test', 'false')), + type=request.args.get('type', request.form.get('type', '')), + status=request.args.get('status', request.form.get('status', '')), + search=request.args.get('search', request.form.get('search', '')))) + +@app.route('/api/resources/allocate', methods=['POST']) +@login_required +def allocate_resources_api(): + """API für Ressourcen-Zuweisung bei Lizenzerstellung""" + data = request.json + license_id = data.get('license_id') + domain_count = data.get('domain_count', 1) + ipv4_count = data.get('ipv4_count', 1) + phone_count = data.get('phone_count', 1) + + conn = get_connection() + cur = conn.cursor() + + try: + allocated = {'domains': [], 'ipv4s': [], 'phones': []} + + # Allocate domains + if domain_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' + LIMIT %s FOR UPDATE + """, (domain_count,)) + domains = cur.fetchall() + + if len(domains) < domain_count: + raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") + + for domain_id, domain_value in domains: + # Update resource status + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], domain_id)) + + # Create assignment + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, domain_id, session['username'])) + + # Log history + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (domain_id, license_id, session['username'], get_client_ip())) + + allocated['domains'].append(domain_value) + + # Allocate IPv4s (similar logic) + if ipv4_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' + LIMIT %s FOR UPDATE + """, (ipv4_count,)) + ipv4s = cur.fetchall() + + if len(ipv4s) < ipv4_count: + raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") + + for ipv4_id, ipv4_value in ipv4s: + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], ipv4_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, ipv4_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (ipv4_id, license_id, session['username'], get_client_ip())) + + allocated['ipv4s'].append(ipv4_value) + + # Allocate phones (similar logic) + if phone_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' + LIMIT %s FOR UPDATE + """, (phone_count,)) + phones = cur.fetchall() + + if len(phones) < phone_count: + raise ValueError(f"Nicht genügend Telefonnummern verfügbar") + + for phone_id, phone_value in phones: + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], phone_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, phone_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (phone_id, license_id, session['username'], get_client_ip())) + + allocated['phones'].append(phone_value) + + # Update license resource counts + cur.execute(""" + UPDATE licenses + SET domain_count = %s, + ipv4_count = %s, + phone_count = %s + WHERE id = %s + """, (domain_count, ipv4_count, phone_count, license_id)) + + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'allocated': allocated + }) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({ + 'success': False, + 'error': str(e) + }), 400 + +@app.route('/api/resources/check-availability', methods=['GET']) +@login_required +def check_resource_availability(): + """Prüft verfügbare Ressourcen""" + resource_type = request.args.get('type', '') + count = request.args.get('count', 10, type=int) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + conn = get_connection() + cur = conn.cursor() + + if resource_type: + # Spezifische Ressourcen für einen Typ + cur.execute(""" + SELECT id, resource_value + FROM resource_pools + WHERE status = 'available' + AND resource_type = %s + AND is_test = %s + ORDER BY resource_value + LIMIT %s + """, (resource_type, show_test, count)) + + resources = [] + for row in cur.fetchall(): + resources.append({ + 'id': row[0], + 'value': row[1] + }) + + cur.close() + conn.close() + + return jsonify({ + 'available': resources, + 'type': resource_type, + 'count': len(resources) + }) + else: + # Zusammenfassung aller Typen + cur.execute(""" + SELECT + resource_type, + COUNT(*) as available + FROM resource_pools + WHERE status = 'available' + AND is_test = %s + GROUP BY resource_type + """, (show_test,)) + + availability = {} + for row in cur.fetchall(): + availability[row[0]] = row[1] + + cur.close() + conn.close() + + return jsonify(availability) + +@app.route('/api/global-search', methods=['GET']) +@login_required +def global_search(): + """Global search API endpoint for searching customers and licenses""" + query = request.args.get('q', '').strip() + + if not query or len(query) < 2: + return jsonify({'customers': [], 'licenses': []}) + + conn = get_connection() + cur = conn.cursor() + + # Search pattern with wildcards + search_pattern = f'%{query}%' + + # Search customers + cur.execute(""" + SELECT id, name, email, company_name + FROM customers + WHERE (LOWER(name) LIKE LOWER(%s) + OR LOWER(email) LIKE LOWER(%s) + OR LOWER(company_name) LIKE LOWER(%s)) + AND is_test = FALSE + ORDER BY name + LIMIT 5 + """, (search_pattern, search_pattern, search_pattern)) + + customers = [] + for row in cur.fetchall(): + customers.append({ + 'id': row[0], + 'name': row[1], + 'email': row[2], + 'company_name': row[3] + }) + + # Search licenses + cur.execute(""" + SELECT l.id, l.license_key, c.name as customer_name + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE LOWER(l.license_key) LIKE LOWER(%s) + AND l.is_test = FALSE + ORDER BY l.created_at DESC + LIMIT 5 + """, (search_pattern,)) + + licenses = [] + for row in cur.fetchall(): + licenses.append({ + 'id': row[0], + 'license_key': row[1], + 'customer_name': row[2] + }) + + cur.close() + conn.close() + + return jsonify({ + 'customers': customers, + 'licenses': licenses + }) + +@app.route('/resources/history/') +@login_required +def resource_history(resource_id): + """Zeigt die komplette Historie einer Ressource""" + conn = get_connection() + cur = conn.cursor() + + # Get complete resource info using named columns + cur.execute(""" + SELECT id, resource_type, resource_value, status, allocated_to_license, + status_changed_at, status_changed_by, quarantine_reason, + quarantine_until, created_at, notes + FROM resource_pools + WHERE id = %s + """, (resource_id,)) + row = cur.fetchone() + + if not row: + flash('Ressource nicht gefunden', 'error') + return redirect(url_for('resources')) + + # Create resource object with named attributes + resource = { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'allocated_to_license': row[4], + 'status_changed_at': row[5], + 'status_changed_by': row[6], + 'quarantine_reason': row[7], + 'quarantine_until': row[8], + 'created_at': row[9], + 'notes': row[10] + } + + # Get license info if allocated + license_info = None + if resource['allocated_to_license']: + cur.execute("SELECT license_key FROM licenses WHERE id = %s", + (resource['allocated_to_license'],)) + lic = cur.fetchone() + if lic: + license_info = {'license_key': lic[0]} + + # Get history with named columns + cur.execute(""" + SELECT + rh.action, + rh.action_by, + rh.action_at, + rh.details, + rh.license_id, + rh.ip_address + FROM resource_history rh + WHERE rh.resource_id = %s + ORDER BY rh.action_at DESC + """, (resource_id,)) + + history = [] + for row in cur.fetchall(): + history.append({ + 'action': row[0], + 'action_by': row[1], + 'action_at': row[2], + 'details': row[3], + 'license_id': row[4], + 'ip_address': row[5] + }) + + cur.close() + conn.close() + + # Convert to object-like for template + class ResourceObj: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + + resource_obj = ResourceObj(resource) + history_objs = [ResourceObj(h) for h in history] + + return render_template('resource_history.html', + resource=resource_obj, + license_info=license_info, + history=history_objs) + +@app.route('/resources/metrics') +@login_required +def resources_metrics(): + """Dashboard für Resource Metrics und Reports""" + conn = get_connection() + cur = conn.cursor() + + # Overall stats with fallback values + cur.execute(""" + SELECT + COUNT(DISTINCT resource_id) as total_resources, + COALESCE(AVG(performance_score), 0) as avg_performance, + COALESCE(SUM(cost), 0) as total_cost, + COALESCE(SUM(revenue), 0) as total_revenue, + COALESCE(SUM(issues_count), 0) as total_issues + FROM resource_metrics + WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' + """) + row = cur.fetchone() + + # Calculate ROI + roi = 0 + if row[2] > 0: # if total_cost > 0 + roi = row[3] / row[2] # revenue / cost + + stats = { + 'total_resources': row[0] or 0, + 'avg_performance': row[1] or 0, + 'total_cost': row[2] or 0, + 'total_revenue': row[3] or 0, + 'total_issues': row[4] or 0, + 'roi': roi + } + + # Performance by type + cur.execute(""" + SELECT + rp.resource_type, + COALESCE(AVG(rm.performance_score), 0) as avg_score, + COUNT(DISTINCT rp.id) as resource_count + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY rp.resource_type + ORDER BY rp.resource_type + """) + performance_by_type = cur.fetchall() + + # Utilization data + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) as total, + ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent + FROM resource_pools + GROUP BY resource_type + """) + utilization_rows = cur.fetchall() + utilization_data = [ + { + 'type': row[0].upper(), + 'allocated': row[1], + 'total': row[2], + 'allocated_percent': row[3] + } + for row in utilization_rows + ] + + # Top performing resources + cur.execute(""" + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + COALESCE(AVG(rm.performance_score), 0) as avg_score, + COALESCE(SUM(rm.revenue), 0) as total_revenue, + COALESCE(SUM(rm.cost), 1) as total_cost, + CASE + WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 + ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) + END as roi + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + WHERE rp.status != 'quarantine' + GROUP BY rp.id, rp.resource_type, rp.resource_value + HAVING AVG(rm.performance_score) IS NOT NULL + ORDER BY avg_score DESC + LIMIT 10 + """) + top_rows = cur.fetchall() + top_performers = [ + { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'avg_score': row[3], + 'roi': row[6] + } + for row in top_rows + ] + + # Resources with issues + cur.execute(""" + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + COALESCE(SUM(rm.issues_count), 0) as total_issues + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + WHERE rm.issues_count > 0 OR rp.status = 'quarantine' + GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status + HAVING SUM(rm.issues_count) > 0 + ORDER BY total_issues DESC + LIMIT 10 + """) + problem_rows = cur.fetchall() + problem_resources = [ + { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'total_issues': row[4] + } + for row in problem_rows + ] + + # Daily metrics for trend chart (last 30 days) + cur.execute(""" + SELECT + metric_date, + COALESCE(AVG(performance_score), 0) as avg_performance, + COALESCE(SUM(issues_count), 0) as total_issues + FROM resource_metrics + WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY metric_date + ORDER BY metric_date + """) + daily_rows = cur.fetchall() + daily_metrics = [ + { + 'date': row[0].strftime('%d.%m'), + 'performance': float(row[1]), + 'issues': int(row[2]) + } + for row in daily_rows + ] + + cur.close() + conn.close() + + return render_template('resource_metrics.html', + stats=stats, + performance_by_type=performance_by_type, + utilization_data=utilization_data, + top_performers=top_performers, + problem_resources=problem_resources, + daily_metrics=daily_metrics) + +@app.route('/resources/report', methods=['GET']) +@login_required +def resources_report(): + """Generiert Ressourcen-Reports oder zeigt Report-Formular""" + # Prüfe ob Download angefordert wurde + if request.args.get('download') == 'true': + report_type = request.args.get('type', 'usage') + format_type = request.args.get('format', 'excel') + date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) + date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) + + conn = get_connection() + cur = conn.cursor() + + if report_type == 'usage': + # Auslastungsreport + query = """ + SELECT + rp.resource_type, + rp.resource_value, + rp.status, + COUNT(DISTINCT rh.license_id) as unique_licenses, + COUNT(rh.id) as total_allocations, + MIN(rh.action_at) as first_used, + MAX(rh.action_at) as last_used + FROM resource_pools rp + LEFT JOIN resource_history rh ON rp.id = rh.resource_id + AND rh.action = 'allocated' + AND rh.action_at BETWEEN %s AND %s + GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status + ORDER BY rp.resource_type, total_allocations DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] + + elif report_type == 'performance': + # Performance-Report + query = """ + SELECT + rp.resource_type, + rp.resource_value, + AVG(rm.performance_score) as avg_performance, + SUM(rm.usage_count) as total_usage, + SUM(rm.revenue) as total_revenue, + SUM(rm.cost) as total_cost, + SUM(rm.revenue - rm.cost) as profit, + SUM(rm.issues_count) as total_issues + FROM resource_pools rp + JOIN resource_metrics rm ON rp.id = rm.resource_id + WHERE rm.metric_date BETWEEN %s AND %s + GROUP BY rp.id, rp.resource_type, rp.resource_value + ORDER BY profit DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] + + elif report_type == 'compliance': + # Compliance-Report + query = """ + SELECT + rh.action_at, + rh.action, + rh.action_by, + rp.resource_type, + rp.resource_value, + l.license_key, + c.name as customer_name, + rh.ip_address + FROM resource_history rh + JOIN resource_pools rp ON rh.resource_id = rp.id + LEFT JOIN licenses l ON rh.license_id = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE rh.action_at BETWEEN %s AND %s + ORDER BY rh.action_at DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] + + else: # inventory report + # Inventar-Report + query = """ + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + GROUP BY resource_type + ORDER BY resource_type + """ + cur.execute(query) + columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] + + # Convert to DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + cur.close() + conn.close() + + # Generate file + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f"resource_report_{report_type}_{timestamp}" + + if format_type == 'excel': + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Report', index=False) + + # Auto-adjust columns width + worksheet = writer.sheets['Report'] + for column in worksheet.columns: + max_length = 0 + column = [cell for cell in column] + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = (max_length + 2) + worksheet.column_dimensions[column[0].column_letter].width = adjusted_width + + output.seek(0) + + log_audit('EXPORT', 'resource_report', None, + new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, + additional_info=f"Resource Report {report_type} exportiert") + + return send_file(output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx') + + else: # CSV + output = io.StringIO() + df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') + output.seek(0) + + log_audit('EXPORT', 'resource_report', None, + new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, + additional_info=f"Resource Report {report_type} exportiert") + + return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv') + + # Wenn kein Download, zeige Report-Formular + return render_template('resource_report.html', + datetime=datetime, + timedelta=timedelta, + username=session.get('username')) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/v2_adminpanel/app_new.py b/v2_adminpanel/app_new.py new file mode 100644 index 0000000..c391073 --- /dev/null +++ b/v2_adminpanel/app_new.py @@ -0,0 +1,124 @@ +import os +import time +import json +import logging +import requests +from io import BytesIO +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from pathlib import Path + +from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash +from flask_session import Session +from werkzeug.middleware.proxy_fix import ProxyFix +from apscheduler.schedulers.background import BackgroundScheduler +import pandas as pd +from psycopg2.extras import Json + +# Import our new modules +import config +from db import get_connection, get_db_connection, get_db_cursor, execute_query +from auth.decorators import login_required +from auth.password import hash_password, verify_password +from auth.two_factor import ( + generate_totp_secret, generate_qr_code, verify_totp, + generate_backup_codes, hash_backup_code, verify_backup_code +) +from auth.rate_limiting import ( + get_client_ip, check_ip_blocked, record_failed_attempt, + reset_login_attempts, get_login_attempts +) +from utils.audit import log_audit +from utils.license import generate_license_key, validate_license_key +from utils.backup import create_backup, restore_backup, get_or_create_encryption_key +from utils.export import ( + create_excel_export, format_datetime_for_export, + prepare_license_export_data, prepare_customer_export_data, + prepare_session_export_data, prepare_audit_export_data +) +from models import get_user_by_username + +app = Flask(__name__) +# Load configuration from config module +app.config['SECRET_KEY'] = config.SECRET_KEY +app.config['SESSION_TYPE'] = config.SESSION_TYPE +app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII +app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE +app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME +app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY +app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE +app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE +app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME +app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST +Session(app) + +# ProxyFix für korrekte IP-Adressen hinter Nginx +app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 +) + +# Configuration is now loaded from config module + +# Scheduler für automatische Backups +scheduler = BackgroundScheduler() +scheduler.start() + +# Logging konfigurieren +logging.basicConfig(level=logging.INFO) + + +# Scheduled Backup Job +def scheduled_backup(): + """Führt ein geplantes Backup aus""" + logging.info("Starte geplantes Backup...") + create_backup(backup_type="scheduled", created_by="scheduler") + +# Scheduler konfigurieren - täglich um 3:00 Uhr +scheduler.add_job( + scheduled_backup, + 'cron', + hour=config.SCHEDULER_CONFIG['backup_hour'], + minute=config.SCHEDULER_CONFIG['backup_minute'], + id='daily_backup', + replace_existing=True +) + + +def verify_recaptcha(response): + """Verifiziert die reCAPTCHA v2 Response mit Google""" + secret_key = config.RECAPTCHA_SECRET_KEY + + # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) + if not secret_key: + logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") + return True + + # Verifizierung bei Google + try: + verify_url = 'https://www.google.com/recaptcha/api/siteverify' + data = { + 'secret': secret_key, + 'response': response + } + + # Timeout für Request setzen + r = requests.post(verify_url, data=data, timeout=5) + result = r.json() + + # Log für Debugging + if not result.get('success'): + logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") + + return result.get('success', False) + + except requests.exceptions.RequestException as e: + logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") + # Bei Netzwerkfehlern CAPTCHA als bestanden werten + return True + except Exception as e: + logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") + return False + + +# Now copy all the route handlers from the original file +# Starting from line 693... \ No newline at end of file diff --git a/v2_adminpanel/auth/__init__.py b/v2_adminpanel/auth/__init__.py new file mode 100644 index 0000000..8ca1225 --- /dev/null +++ b/v2_adminpanel/auth/__init__.py @@ -0,0 +1 @@ +# Auth module initialization \ No newline at end of file diff --git a/v2_adminpanel/auth/decorators.py b/v2_adminpanel/auth/decorators.py new file mode 100644 index 0000000..fda9c05 --- /dev/null +++ b/v2_adminpanel/auth/decorators.py @@ -0,0 +1,44 @@ +from functools import wraps +from flask import session, redirect, url_for, flash, request +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +import logging +from utils.audit import log_audit + +logger = logging.getLogger(__name__) + + +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'logged_in' not in session: + return redirect(url_for('login')) + + # Check if session has expired + if 'last_activity' in session: + last_activity = datetime.fromisoformat(session['last_activity']) + time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity + + # Debug logging + logger.info(f"Session check for {session.get('username', 'unknown')}: " + f"Last activity: {last_activity}, " + f"Time since: {time_since_activity.total_seconds()} seconds") + + if time_since_activity > timedelta(minutes=5): + # Session expired - Logout + username = session.get('username', 'unbekannt') + logger.info(f"Session timeout for user {username} - auto logout") + # Audit log for automatic logout (before session.clear()!) + try: + log_audit('AUTO_LOGOUT', 'session', + additional_info={'reason': 'Session timeout (5 minutes)', 'username': username}) + except: + pass + session.clear() + flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning') + return redirect(url_for('login')) + + # Activity is NOT automatically updated + # Only on explicit user actions (done by heartbeat) + return f(*args, **kwargs) + return decorated_function \ No newline at end of file diff --git a/v2_adminpanel/auth/password.py b/v2_adminpanel/auth/password.py new file mode 100644 index 0000000..785466f --- /dev/null +++ b/v2_adminpanel/auth/password.py @@ -0,0 +1,11 @@ +import bcrypt + + +def hash_password(password): + """Hash a password using bcrypt""" + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + +def verify_password(password, hashed): + """Verify a password against its hash""" + return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) \ No newline at end of file diff --git a/v2_adminpanel/auth/rate_limiting.py b/v2_adminpanel/auth/rate_limiting.py new file mode 100644 index 0000000..8aca82b --- /dev/null +++ b/v2_adminpanel/auth/rate_limiting.py @@ -0,0 +1,124 @@ +import random +import logging +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from flask import request +from db import execute_query, get_db_connection, get_db_cursor +from config import FAIL_MESSAGES, MAX_LOGIN_ATTEMPTS, BLOCK_DURATION_HOURS, EMAIL_ENABLED +from utils.audit import log_audit +from utils.network import get_client_ip + +logger = logging.getLogger(__name__) + + +def check_ip_blocked(ip_address): + """Check if an IP address is blocked""" + result = execute_query( + """ + SELECT blocked_until FROM login_attempts + WHERE ip_address = %s AND blocked_until IS NOT NULL + """, + (ip_address,), + fetch_one=True + ) + + if result and result[0]: + if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None): + return True, result[0] + return False, None + + +def record_failed_attempt(ip_address, username): + """Record a failed login attempt""" + # Random error message + error_message = random.choice(FAIL_MESSAGES) + + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + try: + # Check if IP already exists + cur.execute(""" + SELECT attempt_count FROM login_attempts + WHERE ip_address = %s + """, (ip_address,)) + + result = cur.fetchone() + + if result: + # Update existing entry + new_count = result[0] + 1 + blocked_until = None + + if new_count >= MAX_LOGIN_ATTEMPTS: + blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS) + # Email notification (if enabled) + if EMAIL_ENABLED: + send_security_alert_email(ip_address, username, new_count) + + cur.execute(""" + UPDATE login_attempts + SET attempt_count = %s, + last_attempt = CURRENT_TIMESTAMP, + blocked_until = %s, + last_username_tried = %s, + last_error_message = %s + WHERE ip_address = %s + """, (new_count, blocked_until, username, error_message, ip_address)) + else: + # Create new entry + cur.execute(""" + INSERT INTO login_attempts + (ip_address, attempt_count, last_username_tried, last_error_message) + VALUES (%s, 1, %s, %s) + """, (ip_address, username, error_message)) + + conn.commit() + + # Audit log + log_audit('LOGIN_FAILED', 'user', + additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}") + + except Exception as e: + logger.error(f"Rate limiting error: {e}") + conn.rollback() + + return error_message + + +def reset_login_attempts(ip_address): + """Reset login attempts for an IP""" + execute_query( + "DELETE FROM login_attempts WHERE ip_address = %s", + (ip_address,) + ) + + +def get_login_attempts(ip_address): + """Get the number of login attempts for an IP""" + result = execute_query( + "SELECT attempt_count FROM login_attempts WHERE ip_address = %s", + (ip_address,), + fetch_one=True + ) + return result[0] if result else 0 + + +def send_security_alert_email(ip_address, username, attempt_count): + """Send a security alert email""" + subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche" + body = f""" + WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt! + + IP-Adresse: {ip_address} + Versuchter Benutzername: {username} + Anzahl Versuche: {attempt_count} + Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')} + + Die IP-Adresse wurde für 24 Stunden gesperrt. + + Dies ist eine automatische Nachricht vom v2-Docker Admin Panel. + """ + + # TODO: Email sending implementation when SMTP is configured + logger.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") + print(f"E-Mail würde gesendet: {subject}") \ No newline at end of file diff --git a/v2_adminpanel/auth/two_factor.py b/v2_adminpanel/auth/two_factor.py new file mode 100644 index 0000000..474555d --- /dev/null +++ b/v2_adminpanel/auth/two_factor.py @@ -0,0 +1,57 @@ +import pyotp +import qrcode +import random +import string +import hashlib +from io import BytesIO +import base64 + + +def generate_totp_secret(): + """Generate a new TOTP secret""" + return pyotp.random_base32() + + +def generate_qr_code(username, totp_secret): + """Generate QR code for TOTP setup""" + totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( + name=username, + issuer_name='V2 Admin Panel' + ) + + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(totp_uri) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + buf = BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + + return base64.b64encode(buf.getvalue()).decode() + + +def verify_totp(totp_secret, token): + """Verify a TOTP token""" + totp = pyotp.TOTP(totp_secret) + return totp.verify(token, valid_window=1) + + +def generate_backup_codes(count=8): + """Generate backup codes for 2FA recovery""" + codes = [] + for _ in range(count): + code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) + codes.append(code) + return codes + + +def hash_backup_code(code): + """Hash a backup code for storage""" + return hashlib.sha256(code.encode()).hexdigest() + + +def verify_backup_code(code, hashed_codes): + """Verify a backup code against stored hashes""" + code_hash = hashlib.sha256(code.encode()).hexdigest() + return code_hash in hashed_codes \ No newline at end of file diff --git a/v2_adminpanel/config.py b/v2_adminpanel/config.py new file mode 100644 index 0000000..9beeadb --- /dev/null +++ b/v2_adminpanel/config.py @@ -0,0 +1,64 @@ +import os +from datetime import timedelta +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv() + +# Flask Configuration +SECRET_KEY = os.urandom(24) +SESSION_TYPE = 'filesystem' +JSON_AS_ASCII = False +JSONIFY_MIMETYPE = 'application/json; charset=utf-8' +PERMANENT_SESSION_LIFETIME = timedelta(minutes=5) +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SECURE = False # Set to True when HTTPS (internal runs HTTP) +SESSION_COOKIE_SAMESITE = 'Lax' +SESSION_COOKIE_NAME = 'admin_session' +SESSION_REFRESH_EACH_REQUEST = False + +# Database Configuration +DATABASE_CONFIG = { + 'host': os.getenv("POSTGRES_HOST", "postgres"), + 'port': os.getenv("POSTGRES_PORT", "5432"), + 'dbname': os.getenv("POSTGRES_DB"), + 'user': os.getenv("POSTGRES_USER"), + 'password': os.getenv("POSTGRES_PASSWORD"), + 'options': '-c client_encoding=UTF8' +} + +# Backup Configuration +BACKUP_DIR = Path("/app/backups") +BACKUP_DIR.mkdir(exist_ok=True) +BACKUP_ENCRYPTION_KEY = os.getenv("BACKUP_ENCRYPTION_KEY") + +# Rate Limiting Configuration +FAIL_MESSAGES = [ + "NOPE!", + "ACCESS DENIED, TRY HARDER", + "WRONG! 🚫", + "COMPUTER SAYS NO", + "YOU FAILED" +] +MAX_LOGIN_ATTEMPTS = 5 +BLOCK_DURATION_HOURS = 24 +CAPTCHA_AFTER_ATTEMPTS = 2 + +# reCAPTCHA Configuration +RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY') +RECAPTCHA_SECRET_KEY = os.getenv('RECAPTCHA_SECRET_KEY') + +# Email Configuration +EMAIL_ENABLED = os.getenv("EMAIL_ENABLED", "false").lower() == "true" + +# Admin Users (for backward compatibility) +ADMIN_USERS = { + os.getenv("ADMIN1_USERNAME"): os.getenv("ADMIN1_PASSWORD"), + os.getenv("ADMIN2_USERNAME"): os.getenv("ADMIN2_PASSWORD") +} + +# Scheduler Configuration +SCHEDULER_CONFIG = { + 'backup_hour': 3, + 'backup_minute': 0 +} \ No newline at end of file diff --git a/v2_adminpanel/db.py b/v2_adminpanel/db.py new file mode 100644 index 0000000..be8284e --- /dev/null +++ b/v2_adminpanel/db.py @@ -0,0 +1,84 @@ +import psycopg2 +from psycopg2.extras import Json, RealDictCursor +from contextlib import contextmanager +from config import DATABASE_CONFIG + + +def get_connection(): + """Create and return a new database connection""" + conn = psycopg2.connect(**DATABASE_CONFIG) + conn.set_client_encoding('UTF8') + return conn + + +@contextmanager +def get_db_connection(): + """Context manager for database connections""" + conn = get_connection() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +@contextmanager +def get_db_cursor(conn=None): + """Context manager for database cursors""" + if conn is None: + with get_db_connection() as connection: + cur = connection.cursor() + try: + yield cur + finally: + cur.close() + else: + cur = conn.cursor() + try: + yield cur + finally: + cur.close() + + +@contextmanager +def get_dict_cursor(conn=None): + """Context manager for dictionary cursors""" + if conn is None: + with get_db_connection() as connection: + cur = connection.cursor(cursor_factory=RealDictCursor) + try: + yield cur + finally: + cur.close() + else: + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + yield cur + finally: + cur.close() + + +def execute_query(query, params=None, fetch_one=False, fetch_all=False, as_dict=False): + """Execute a query and optionally fetch results""" + with get_db_connection() as conn: + cursor_func = get_dict_cursor if as_dict else get_db_cursor + with cursor_func(conn) as cur: + cur.execute(query, params) + + if fetch_one: + return cur.fetchone() + elif fetch_all: + return cur.fetchall() + else: + return cur.rowcount + + +def execute_many(query, params_list): + """Execute a query multiple times with different parameters""" + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.executemany(query, params_list) + return cur.rowcount \ No newline at end of file diff --git a/v2_adminpanel/models.py b/v2_adminpanel/models.py new file mode 100644 index 0000000..4c3bb1c --- /dev/null +++ b/v2_adminpanel/models.py @@ -0,0 +1,29 @@ +# Temporary models file - will be expanded in Phase 3 +from db import execute_query + + +def get_user_by_username(username): + """Get user from database by username""" + result = execute_query( + """ + SELECT id, username, password_hash, email, totp_secret, totp_enabled, + backup_codes, last_password_change, failed_2fa_attempts + FROM users WHERE username = %s + """, + (username,), + fetch_one=True + ) + + if result: + return { + 'id': result[0], + 'username': result[1], + 'password_hash': result[2], + 'email': result[3], + 'totp_secret': result[4], + 'totp_enabled': result[5], + 'backup_codes': result[6], + 'last_password_change': result[7], + 'failed_2fa_attempts': result[8] + } + return None \ No newline at end of file diff --git a/v2_adminpanel/utils/__init__.py b/v2_adminpanel/utils/__init__.py new file mode 100644 index 0000000..921d9bb --- /dev/null +++ b/v2_adminpanel/utils/__init__.py @@ -0,0 +1 @@ +# Utils module initialization \ No newline at end of file diff --git a/v2_adminpanel/utils/audit.py b/v2_adminpanel/utils/audit.py new file mode 100644 index 0000000..b480547 --- /dev/null +++ b/v2_adminpanel/utils/audit.py @@ -0,0 +1,37 @@ +import logging +from flask import session, request +from psycopg2.extras import Json +from db import get_db_connection, get_db_cursor +from utils.network import get_client_ip + +logger = logging.getLogger(__name__) + + +def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): + """Log changes to the audit log""" + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + try: + username = session.get('username', 'system') + ip_address = get_client_ip() if request else None + user_agent = request.headers.get('User-Agent') if request else None + + # Debug logging + logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") + + # Convert dictionaries to JSONB + old_json = Json(old_values) if old_values else None + new_json = Json(new_values) if new_values else None + + cur.execute(""" + INSERT INTO audit_log + (username, action, entity_type, entity_id, old_values, new_values, + ip_address, user_agent, additional_info) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """, (username, action, entity_type, entity_id, old_json, new_json, + ip_address, user_agent, additional_info)) + + conn.commit() + except Exception as e: + logger.error(f"Audit log error: {e}") + conn.rollback() \ No newline at end of file diff --git a/v2_adminpanel/utils/backup.py b/v2_adminpanel/utils/backup.py new file mode 100644 index 0000000..def2648 --- /dev/null +++ b/v2_adminpanel/utils/backup.py @@ -0,0 +1,223 @@ +import os +import time +import gzip +import logging +import subprocess +from pathlib import Path +from datetime import datetime +from zoneinfo import ZoneInfo +from cryptography.fernet import Fernet +from db import get_db_connection, get_db_cursor +from config import BACKUP_DIR, DATABASE_CONFIG, EMAIL_ENABLED, BACKUP_ENCRYPTION_KEY +from utils.audit import log_audit + +logger = logging.getLogger(__name__) + + +def get_or_create_encryption_key(): + """Get or create an encryption key""" + key_file = BACKUP_DIR / ".backup_key" + + # Try to read key from environment variable + if BACKUP_ENCRYPTION_KEY: + try: + # Validate the key + Fernet(BACKUP_ENCRYPTION_KEY.encode()) + return BACKUP_ENCRYPTION_KEY.encode() + except: + pass + + # If no valid key in ENV, check file + if key_file.exists(): + return key_file.read_bytes() + + # Create new key + key = Fernet.generate_key() + key_file.write_bytes(key) + logger.info("New backup encryption key created") + return key + + +def create_backup(backup_type="manual", created_by=None): + """Create an encrypted database backup""" + start_time = time.time() + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S") + filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc" + filepath = BACKUP_DIR / filename + + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + # Create backup entry + cur.execute(""" + INSERT INTO backup_history + (filename, filepath, backup_type, status, created_by, is_encrypted) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, (filename, str(filepath), backup_type, 'in_progress', + created_by or 'system', True)) + backup_id = cur.fetchone()[0] + conn.commit() + + try: + # PostgreSQL dump command + dump_command = [ + 'pg_dump', + '-h', DATABASE_CONFIG['host'], + '-p', DATABASE_CONFIG['port'], + '-U', DATABASE_CONFIG['user'], + '-d', DATABASE_CONFIG['dbname'], + '--no-password', + '--verbose' + ] + + # Set PGPASSWORD + env = os.environ.copy() + env['PGPASSWORD'] = DATABASE_CONFIG['password'] + + # Execute dump + result = subprocess.run(dump_command, capture_output=True, text=True, env=env) + + if result.returncode != 0: + raise Exception(f"pg_dump failed: {result.stderr}") + + dump_data = result.stdout.encode('utf-8') + + # Compress + compressed_data = gzip.compress(dump_data) + + # Encrypt + key = get_or_create_encryption_key() + f = Fernet(key) + encrypted_data = f.encrypt(compressed_data) + + # Save + filepath.write_bytes(encrypted_data) + + # Collect statistics + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'") + tables_count = cur.fetchone()[0] + + cur.execute("SELECT SUM(n_live_tup) FROM pg_stat_user_tables") + records_count = cur.fetchone()[0] or 0 + + duration = time.time() - start_time + filesize = filepath.stat().st_size + + # Update backup entry + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute(""" + UPDATE backup_history + SET status = %s, filesize = %s, tables_count = %s, + records_count = %s, duration_seconds = %s + WHERE id = %s + """, ('success', filesize, tables_count, records_count, duration, backup_id)) + conn.commit() + + # Audit log + log_audit('BACKUP', 'database', backup_id, + additional_info=f"Backup created: {filename} ({filesize} bytes)") + + # Email notification (if configured) + send_backup_notification(True, filename, filesize, duration) + + logger.info(f"Backup successfully created: {filename}") + return True, filename + + except Exception as e: + # Log error + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute(""" + UPDATE backup_history + SET status = %s, error_message = %s, duration_seconds = %s + WHERE id = %s + """, ('failed', str(e), time.time() - start_time, backup_id)) + conn.commit() + + logger.error(f"Backup failed: {e}") + send_backup_notification(False, filename, error=str(e)) + + return False, str(e) + + +def restore_backup(backup_id, encryption_key=None): + """Restore a backup""" + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + # Get backup info + cur.execute(""" + SELECT filename, filepath, is_encrypted + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + if not backup_info: + raise Exception("Backup not found") + + filename, filepath, is_encrypted = backup_info + filepath = Path(filepath) + + if not filepath.exists(): + raise Exception("Backup file not found") + + try: + # Read file + encrypted_data = filepath.read_bytes() + + # Decrypt + if is_encrypted: + key = encryption_key.encode() if encryption_key else get_or_create_encryption_key() + try: + f = Fernet(key) + compressed_data = f.decrypt(encrypted_data) + except: + raise Exception("Decryption failed. Wrong password?") + else: + compressed_data = encrypted_data + + # Decompress + dump_data = gzip.decompress(compressed_data) + sql_commands = dump_data.decode('utf-8') + + # Restore database + restore_command = [ + 'psql', + '-h', DATABASE_CONFIG['host'], + '-p', DATABASE_CONFIG['port'], + '-U', DATABASE_CONFIG['user'], + '-d', DATABASE_CONFIG['dbname'], + '--no-password' + ] + + env = os.environ.copy() + env['PGPASSWORD'] = DATABASE_CONFIG['password'] + + result = subprocess.run(restore_command, input=sql_commands, + capture_output=True, text=True, env=env) + + if result.returncode != 0: + raise Exception(f"Restore failed: {result.stderr}") + + # Audit log + log_audit('RESTORE', 'database', backup_id, + additional_info=f"Backup restored: {filename}") + + return True, "Backup successfully restored" + + except Exception as e: + logger.error(f"Restore failed: {e}") + return False, str(e) + + +def send_backup_notification(success, filename, filesize=None, duration=None, error=None): + """Send email notification (if configured)""" + if not EMAIL_ENABLED: + return + + # Email function prepared but disabled + # TODO: Implement when email server is configured + logger.info(f"Email notification prepared: Backup {'successful' if success else 'failed'}") \ No newline at end of file diff --git a/v2_adminpanel/utils/export.py b/v2_adminpanel/utils/export.py new file mode 100644 index 0000000..0ccbd31 --- /dev/null +++ b/v2_adminpanel/utils/export.py @@ -0,0 +1,127 @@ +import pandas as pd +from io import BytesIO +from datetime import datetime +from zoneinfo import ZoneInfo +from openpyxl.utils import get_column_letter +from flask import send_file + + +def create_excel_export(data, columns, filename_prefix="export"): + """Create an Excel file from data""" + df = pd.DataFrame(data, columns=columns) + + # Create Excel file in memory + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Data') + + # Auto-adjust column widths + worksheet = writer.sheets['Data'] + for idx, col in enumerate(df.columns): + max_length = max(df[col].astype(str).map(len).max(), len(col)) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + # Generate filename with timestamp + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f"{filename_prefix}_{timestamp}.xlsx" + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + + +def format_datetime_for_export(dt): + """Format datetime for export""" + if dt: + if isinstance(dt, str): + try: + dt = datetime.fromisoformat(dt) + except: + return dt + return dt.strftime('%Y-%m-%d %H:%M:%S') + return '' + + +def prepare_license_export_data(licenses): + """Prepare license data for export""" + export_data = [] + for license in licenses: + export_data.append([ + license[0], # ID + license[1], # Key + license[2], # Customer Name + license[3], # Email + 'Aktiv' if license[4] else 'Inaktiv', # Active + license[5], # Max Users + format_datetime_for_export(license[6]), # Valid From + format_datetime_for_export(license[7]), # Valid Until + format_datetime_for_export(license[8]), # Created At + license[9], # Device Limit + license[10] or 0, # Current Devices + 'Test' if license[11] else 'Full' # Is Test License + ]) + return export_data + + +def prepare_customer_export_data(customers): + """Prepare customer data for export""" + export_data = [] + for customer in customers: + export_data.append([ + customer[0], # ID + customer[1], # Name + customer[2], # Email + customer[3], # Company + customer[4], # Address + customer[5], # Phone + format_datetime_for_export(customer[6]), # Created At + customer[7] or 0, # License Count + customer[8] or 0 # Active License Count + ]) + return export_data + + +def prepare_session_export_data(sessions): + """Prepare session data for export""" + export_data = [] + for session in sessions: + export_data.append([ + session[0], # ID + session[1], # License Key + session[2], # Username + session[3], # Computer Name + session[4], # Hardware ID + format_datetime_for_export(session[5]), # Login Time + format_datetime_for_export(session[6]), # Last Activity + 'Aktiv' if session[7] else 'Beendet', # Active + session[8], # IP Address + session[9], # App Version + session[10], # Customer Name + session[11] # Email + ]) + return export_data + + +def prepare_audit_export_data(audit_logs): + """Prepare audit log data for export""" + export_data = [] + for log in audit_logs: + export_data.append([ + log['id'], + format_datetime_for_export(log['timestamp']), + log['username'], + log['action'], + log['entity_type'], + log['entity_id'] or '', + log['ip_address'] or '', + log['user_agent'] or '', + str(log['old_values']) if log['old_values'] else '', + str(log['new_values']) if log['new_values'] else '', + log['additional_info'] or '' + ]) + return export_data \ No newline at end of file diff --git a/v2_adminpanel/utils/license.py b/v2_adminpanel/utils/license.py new file mode 100644 index 0000000..6c5cb7d --- /dev/null +++ b/v2_adminpanel/utils/license.py @@ -0,0 +1,50 @@ +import re +import secrets +from datetime import datetime +from zoneinfo import ZoneInfo + + +def generate_license_key(license_type='full'): + """ + Generate a license key in format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ + + AF = Account Factory (Product identifier) + F/T = F for Full version, T for Test version + YYYY = Year + MM = Month + XXXX-YYYY-ZZZZ = Random alphanumeric characters + """ + # Allowed characters (without confusing ones like 0/O, 1/I/l) + chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + + # Date part + now = datetime.now(ZoneInfo("Europe/Berlin")) + date_part = now.strftime('%Y%m') + type_char = 'F' if license_type == 'full' else 'T' + + # Generate random parts (3 blocks of 4 characters) + parts = [] + for _ in range(3): + part = ''.join(secrets.choice(chars) for _ in range(4)) + parts.append(part) + + # Assemble key + key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" + + return key + + +def validate_license_key(key): + """ + Validate the License Key Format + Expected: AF-F-YYYYMM-XXXX-YYYY-ZZZZ or AF-T-YYYYMM-XXXX-YYYY-ZZZZ + """ + if not key: + return False + + # Pattern for the new format + # AF- (fixed) + F or T + - + 6 digits (YYYYMM) + - + 4 characters + - + 4 characters + - + 4 characters + pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' + + # Uppercase for comparison + return bool(re.match(pattern, key.upper())) \ No newline at end of file diff --git a/v2_adminpanel/utils/network.py b/v2_adminpanel/utils/network.py new file mode 100644 index 0000000..3714331 --- /dev/null +++ b/v2_adminpanel/utils/network.py @@ -0,0 +1,23 @@ +import logging +from flask import request + +logger = logging.getLogger(__name__) + + +def get_client_ip(): + """Get the real IP address of the client""" + # Debug logging + logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, " + f"X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, " + f"Remote-Addr: {request.remote_addr}") + + # Try X-Real-IP first (set by nginx) + if request.headers.get('X-Real-IP'): + return request.headers.get('X-Real-IP') + # Then X-Forwarded-For + elif request.headers.get('X-Forwarded-For'): + # X-Forwarded-For can contain multiple IPs, take the first one + return request.headers.get('X-Forwarded-For').split(',')[0].strip() + # Fallback to remote_addr + else: + return request.remote_addr \ No newline at end of file diff --git a/v2_lizenzserver_backup/Dockerfile b/v2_lizenzserver_backup/Dockerfile deleted file mode 100644 index 69b8d95..0000000 --- a/v2_lizenzserver_backup/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM python:3.11-slim - -# Zeitzone setzen -ENV TZ=Europe/Berlin -ENV DEBIAN_FRONTEND=noninteractive - -RUN apt-get update && apt-get install -y tzdata \ - && ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime \ - && echo "Europe/Berlin" > /etc/timezone \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -# Placeholder für Lizenzserver -RUN echo "Lizenzserver noch nicht implementiert" > info.txt - -CMD ["python", "-c", "print('Lizenzserver Container läuft, aber noch keine Implementierung vorhanden'); import time; time.sleep(86400)"] \ No newline at end of file