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 0000000..ae11b74 Binary files /dev/null and b/v2_adminpanel/__pycache__/app.cpython-312.pyc differ diff --git a/v2_adminpanel/__pycache__/app_refactored.cpython-312.pyc b/v2_adminpanel/__pycache__/app_refactored.cpython-312.pyc new file mode 100644 index 0000000..9c9153c Binary files /dev/null and b/v2_adminpanel/__pycache__/app_refactored.cpython-312.pyc differ diff --git a/v2_adminpanel/__pycache__/config.cpython-312.pyc b/v2_adminpanel/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..09aafeb Binary files /dev/null and b/v2_adminpanel/__pycache__/config.cpython-312.pyc differ 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