From 312f85530cf43c8ed2731bca4f96b35062c3c9c5 Mon Sep 17 00:00:00 2001 From: Iliyan Angelov Date: Fri, 28 Nov 2025 02:40:05 +0200 Subject: [PATCH] updates --- .../add_borica_payment_method.cpython-312.pyc | Bin 0 -> 1376 bytes ...d_rate_plan_id_to_bookings.cpython-312.pyc | Bin 1699 -> 1699 bytes ...nt_lockout_fields_to_users.cpython-312.pyc | Bin 0 -> 1365 bytes ...6b3_add_account_lockout_fields_to_users.py | 29 + Backend/requirements.txt | 1 + Backend/src/__pycache__/main.cpython-312.pyc | Bin 21162 -> 19619 bytes .../__pycache__/settings.cpython-312.pyc | Bin 7794 -> 10479 bytes Backend/src/config/settings.py | 52 +- Backend/src/main.py | 187 +- .../admin_ip_whitelist.cpython-312.pyc | Bin 0 -> 5991 bytes .../__pycache__/auth.cpython-312.pyc | Bin 4456 -> 5343 bytes .../__pycache__/csrf.cpython-312.pyc | Bin 0 -> 6051 bytes .../__pycache__/error_handler.cpython-312.pyc | Bin 4825 -> 7535 bytes .../request_size_limit.cpython-312.pyc | Bin 0 -> 2726 bytes .../__pycache__/security.cpython-312.pyc | Bin 1893 -> 2109 bytes Backend/src/middleware/admin_ip_whitelist.py | 114 + Backend/src/middleware/auth.py | 39 +- Backend/src/middleware/csrf.py | 158 + Backend/src/middleware/error_handler.py | 106 +- Backend/src/middleware/request_size_limit.py | 53 + Backend/src/middleware/security.py | 26 +- .../__pycache__/booking.cpython-312.pyc | Bin 3065 -> 3267 bytes .../__pycache__/invoice.cpython-312.pyc | Bin 4890 -> 5126 bytes .../__pycache__/payment.cpython-312.pyc | Bin 2870 -> 3053 bytes .../models/__pycache__/review.cpython-312.pyc | Bin 1703 -> 1887 bytes .../models/__pycache__/user.cpython-312.pyc | Bin 3579 -> 3687 bytes Backend/src/models/booking.py | 18 +- Backend/src/models/invoice.py | 13 +- Backend/src/models/payment.py | 12 +- Backend/src/models/review.py | 14 +- Backend/src/models/user.py | 4 + .../advanced_room_routes.cpython-312.pyc | Bin 43079 -> 43572 bytes .../__pycache__/auth_routes.cpython-312.pyc | Bin 18519 -> 22634 bytes .../__pycache__/banner_routes.cpython-312.pyc | Bin 14020 -> 14105 bytes .../booking_routes.cpython-312.pyc | Bin 104003 -> 105702 bytes .../__pycache__/chat_routes.cpython-312.pyc | Bin 29956 -> 30722 bytes .../contact_routes.cpython-312.pyc | Bin 4146 -> 4214 bytes .../invoice_routes.cpython-312.pyc | Bin 10581 -> 12091 bytes .../page_content_routes.cpython-312.pyc | Bin 41626 -> 42345 bytes .../payment_routes.cpython-312.pyc | Bin 71155 -> 74416 bytes .../promotion_routes.cpython-312.pyc | Bin 17411 -> 18103 bytes .../__pycache__/review_routes.cpython-312.pyc | Bin 10683 -> 12131 bytes .../__pycache__/room_routes.cpython-312.pyc | Bin 42503 -> 43593 bytes .../service_booking_routes.cpython-312.pyc | Bin 16206 -> 16857 bytes .../__pycache__/user_routes.cpython-312.pyc | Bin 14562 -> 14989 bytes Backend/src/routes/advanced_room_routes.py | 10 +- Backend/src/routes/auth_routes.py | 178 +- Backend/src/routes/banner_routes.py | 11 +- Backend/src/routes/booking_routes.py | 120 +- Backend/src/routes/chat_routes.py | 27 +- Backend/src/routes/contact_routes.py | 1 + Backend/src/routes/invoice_routes.py | 79 +- Backend/src/routes/page_content_routes.py | 137 +- Backend/src/routes/payment_routes.py | 139 +- Backend/src/routes/promotion_routes.py | 106 +- Backend/src/routes/review_routes.py | 46 +- Backend/src/routes/room_routes.py | 10 + Backend/src/routes/service_booking_routes.py | 36 +- Backend/src/routes/user_routes.py | 92 +- .../__pycache__/booking.cpython-312.pyc | Bin 0 -> 8684 bytes .../__pycache__/invoice.cpython-312.pyc | Bin 0 -> 3710 bytes .../__pycache__/page_content.cpython-312.pyc | Bin 0 -> 7906 bytes .../__pycache__/payment.cpython-312.pyc | Bin 0 -> 3743 bytes .../__pycache__/promotion.cpython-312.pyc | Bin 0 -> 5966 bytes .../__pycache__/review.cpython-312.pyc | Bin 0 -> 1083 bytes .../service_booking.cpython-312.pyc | Bin 0 -> 3074 bytes .../schemas/__pycache__/user.cpython-312.pyc | Bin 0 -> 3211 bytes Backend/src/schemas/booking.py | 167 + Backend/src/schemas/invoice.py | 77 + Backend/src/schemas/page_content.py | 110 + Backend/src/schemas/payment.py | 55 + Backend/src/schemas/promotion.py | 122 + Backend/src/schemas/review.py | 23 + Backend/src/schemas/service_booking.py | 63 + Backend/src/schemas/user.py | 38 + .../__pycache__/audit_service.cpython-312.pyc | Bin 0 -> 2231 bytes .../__pycache__/auth_service.cpython-312.pyc | Bin 21539 -> 24915 bytes .../invoice_service.cpython-312.pyc | Bin 20753 -> 21271 bytes .../paypal_service.cpython-312.pyc | Bin 24302 -> 24246 bytes .../stripe_service.cpython-312.pyc | Bin 24604 -> 24502 bytes .../__pycache__/task_service.cpython-312.pyc | Bin 17877 -> 17877 bytes Backend/src/services/auth_service.py | 68 +- Backend/src/services/invoice_service.py | 11 +- Backend/src/services/paypal_service.py | 8 +- Backend/src/services/stripe_service.py | 22 +- .../html_sanitizer.cpython-312.pyc | Bin 0 -> 2833 bytes .../request_helpers.cpython-312.pyc | Bin 0 -> 813 bytes .../response_helpers.cpython-312.pyc | Bin 1291 -> 2354 bytes Backend/src/utils/file_validation.py | 148 + Backend/src/utils/html_sanitizer.py | 99 + Backend/src/utils/password_validation.py | 59 + Backend/src/utils/request_helpers.py | 21 + Backend/src/utils/response_helpers.py | 35 + .../__pycache__/six.cpython-312.pyc | Bin 41413 -> 41413 bytes .../bleach-6.1.0.dist-info/INSTALLER | 1 + .../bleach-6.1.0.dist-info/LICENSE | 13 + .../bleach-6.1.0.dist-info/METADATA | 1247 +++++++ .../bleach-6.1.0.dist-info/RECORD | 103 + .../bleach-6.1.0.dist-info/REQUESTED | 0 .../bleach-6.1.0.dist-info/WHEEL | 5 + .../bleach-6.1.0.dist-info/top_level.txt | 1 + .../site-packages/bleach/__init__.py | 125 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 3871 bytes .../__pycache__/callbacks.cpython-312.pyc | Bin 0 -> 1274 bytes .../__pycache__/css_sanitizer.cpython-312.pyc | Bin 0 -> 2383 bytes .../__pycache__/html5lib_shim.cpython-312.pyc | Bin 0 -> 18728 bytes .../__pycache__/linkifier.cpython-312.pyc | Bin 0 -> 18371 bytes .../__pycache__/parse_shim.cpython-312.pyc | Bin 0 -> 247 bytes .../__pycache__/sanitizer.cpython-312.pyc | Bin 0 -> 17928 bytes .../site-packages/bleach/_vendor/README.rst | 61 + .../site-packages/bleach/_vendor/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 197 bytes .../_vendor/__pycache__/parse.cpython-312.pyc | Bin 0 -> 43429 bytes .../html5lib-1.1.dist-info/AUTHORS.rst | 66 + .../_vendor/html5lib-1.1.dist-info/INSTALLER | 1 + .../_vendor/html5lib-1.1.dist-info/LICENSE | 20 + .../_vendor/html5lib-1.1.dist-info/METADATA | 552 +++ .../_vendor/html5lib-1.1.dist-info/RECORD | 41 + .../_vendor/html5lib-1.1.dist-info/REQUESTED | 0 .../_vendor/html5lib-1.1.dist-info/WHEEL | 6 + .../html5lib-1.1.dist-info/top_level.txt | 1 + .../bleach/_vendor/html5lib/__init__.py | 35 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 1328 bytes .../__pycache__/_ihatexml.cpython-312.pyc | Bin 0 -> 17798 bytes .../__pycache__/_inputstream.cpython-312.pyc | Bin 0 -> 35323 bytes .../__pycache__/_tokenizer.cpython-312.pyc | Bin 0 -> 87200 bytes .../__pycache__/_utils.cpython-312.pyc | Bin 0 -> 6695 bytes .../__pycache__/constants.cpython-312.pyc | Bin 0 -> 117443 bytes .../__pycache__/html5parser.cpython-312.pyc | Bin 0 -> 155917 bytes .../__pycache__/serializer.cpython-312.pyc | Bin 0 -> 15846 bytes .../bleach/_vendor/html5lib/_ihatexml.py | 289 ++ .../bleach/_vendor/html5lib/_inputstream.py | 918 +++++ .../bleach/_vendor/html5lib/_tokenizer.py | 1735 ++++++++++ .../bleach/_vendor/html5lib/_trie/__init__.py | 5 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 378 bytes .../_trie/__pycache__/_base.cpython-312.pyc | Bin 0 -> 1969 bytes .../_trie/__pycache__/py.cpython-312.pyc | Bin 0 -> 3660 bytes .../bleach/_vendor/html5lib/_trie/_base.py | 40 + .../bleach/_vendor/html5lib/_trie/py.py | 67 + .../bleach/_vendor/html5lib/_utils.py | 159 + .../bleach/_vendor/html5lib/constants.py | 2946 ++++++++++++++++ .../_vendor/html5lib/filters/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 214 bytes .../alphabeticalattributes.cpython-312.pyc | Bin 0 -> 1683 bytes .../filters/__pycache__/base.cpython-312.pyc | Bin 0 -> 1020 bytes .../inject_meta_charset.cpython-312.pyc | Bin 0 -> 2949 bytes .../filters/__pycache__/lint.cpython-312.pyc | Bin 0 -> 4056 bytes .../__pycache__/optionaltags.cpython-312.pyc | Bin 0 -> 3914 bytes .../__pycache__/sanitizer.cpython-312.pyc | Bin 0 -> 28524 bytes .../__pycache__/whitespace.cpython-312.pyc | Bin 0 -> 1869 bytes .../filters/alphabeticalattributes.py | 29 + .../bleach/_vendor/html5lib/filters/base.py | 12 + .../html5lib/filters/inject_meta_charset.py | 73 + .../bleach/_vendor/html5lib/filters/lint.py | 93 + .../_vendor/html5lib/filters/optionaltags.py | 207 ++ .../_vendor/html5lib/filters/sanitizer.py | 916 +++++ .../_vendor/html5lib/filters/whitespace.py | 38 + .../bleach/_vendor/html5lib/html5parser.py | 2795 ++++++++++++++++ .../bleach/_vendor/html5lib/serializer.py | 409 +++ .../_vendor/html5lib/treeadapters/__init__.py | 30 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 1008 bytes .../__pycache__/genshi.cpython-312.pyc | Bin 0 -> 2140 bytes .../__pycache__/sax.cpython-312.pyc | Bin 0 -> 2197 bytes .../_vendor/html5lib/treeadapters/genshi.py | 54 + .../_vendor/html5lib/treeadapters/sax.py | 50 + .../_vendor/html5lib/treebuilders/__init__.py | 88 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 3716 bytes .../__pycache__/base.cpython-312.pyc | Bin 0 -> 16075 bytes .../__pycache__/dom.cpython-312.pyc | Bin 0 -> 15616 bytes .../__pycache__/etree.cpython-312.pyc | Bin 0 -> 19830 bytes .../__pycache__/etree_lxml.cpython-312.pyc | Bin 0 -> 22427 bytes .../_vendor/html5lib/treebuilders/base.py | 417 +++ .../_vendor/html5lib/treebuilders/dom.py | 239 ++ .../_vendor/html5lib/treebuilders/etree.py | 343 ++ .../html5lib/treebuilders/etree_lxml.py | 392 +++ .../_vendor/html5lib/treewalkers/__init__.py | 154 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 5648 bytes .../__pycache__/base.cpython-312.pyc | Bin 0 -> 8717 bytes .../__pycache__/dom.cpython-312.pyc | Bin 0 -> 3012 bytes .../__pycache__/etree.cpython-312.pyc | Bin 0 -> 5516 bytes .../__pycache__/etree_lxml.cpython-312.pyc | Bin 0 -> 10601 bytes .../__pycache__/genshi.cpython-312.pyc | Bin 0 -> 3042 bytes .../_vendor/html5lib/treewalkers/base.py | 252 ++ .../_vendor/html5lib/treewalkers/dom.py | 43 + .../_vendor/html5lib/treewalkers/etree.py | 131 + .../html5lib/treewalkers/etree_lxml.py | 215 ++ .../_vendor/html5lib/treewalkers/genshi.py | 69 + .../site-packages/bleach/_vendor/parse.py | 1078 ++++++ .../bleach/_vendor/parse.py.SHA256SUM | 1 + .../site-packages/bleach/_vendor/vendor.txt | 3 + .../bleach/_vendor/vendor_install.sh | 14 + .../site-packages/bleach/callbacks.py | 32 + .../site-packages/bleach/css_sanitizer.py | 104 + .../site-packages/bleach/html5lib_shim.py | 748 +++++ .../site-packages/bleach/linkifier.py | 633 ++++ .../site-packages/bleach/parse_shim.py | 1 + .../site-packages/bleach/sanitizer.py | 638 ++++ .../DESCRIPTION.rst | 27 + .../webencodings-0.5.1.dist-info/INSTALLER | 1 + .../webencodings-0.5.1.dist-info/METADATA | 52 + .../webencodings-0.5.1.dist-info/RECORD | 17 + .../webencodings-0.5.1.dist-info/WHEEL | 6 + .../metadata.json | 1 + .../top_level.txt | 1 + .../site-packages/webencodings/__init__.py | 342 ++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 12001 bytes .../__pycache__/labels.cpython-312.pyc | Bin 0 -> 7132 bytes .../__pycache__/mklabels.cpython-312.pyc | Bin 0 -> 2699 bytes .../__pycache__/tests.cpython-312.pyc | Bin 0 -> 9251 bytes .../x_user_defined.cpython-312.pyc | Bin 0 -> 3295 bytes .../site-packages/webencodings/labels.py | 231 ++ .../site-packages/webencodings/mklabels.py | 59 + .../site-packages/webencodings/tests.py | 153 + .../webencodings/x_user_defined.py | 325 ++ Frontend/index.html | 4 + Frontend/package-lock.json | 2951 +---------------- Frontend/package.json | 2 + Frontend/src/App.tsx | 90 +- .../src/components/common/ErrorBoundary.tsx | 6 +- .../components/common/ErrorBoundaryRoute.tsx | 25 + .../src/components/common/ErrorMessage.tsx | 53 + .../src/components/common/LoadingButton.tsx | 52 + .../components/layout/SidebarAccountant.tsx | 3 +- .../src/components/layout/SidebarAdmin.tsx | 3 +- .../src/components/layout/SidebarStaff.tsx | 3 +- Frontend/src/hooks/useApiCall.ts | 172 + Frontend/src/pages/AboutPage.tsx | 3 +- Frontend/src/pages/AccessibilityPage.tsx | 7 +- Frontend/src/pages/CancellationPolicyPage.tsx | 7 +- Frontend/src/pages/FAQPage.tsx | 7 +- Frontend/src/pages/PrivacyPolicyPage.tsx | 7 +- Frontend/src/pages/RefundsPolicyPage.tsx | 7 +- Frontend/src/pages/TermsPage.tsx | 7 +- .../admin/AdvancedRoomManagementPage.tsx | 15 +- .../pages/admin/AnalyticsDashboardPage.tsx | 3 +- .../src/pages/admin/BookingManagementPage.tsx | 3 +- Frontend/src/pages/admin/DashboardPage.tsx | 5 +- .../src/pages/admin/TaskManagementPage.tsx | 3 +- .../src/pages/admin/UserManagementPage.tsx | 129 +- Frontend/src/services/api/apiClient.ts | 161 +- Frontend/src/store/useAuthStore.ts | 13 +- Frontend/src/utils/errorSanitizer.ts | 101 + Frontend/src/utils/htmlSanitizer.ts | 41 + Frontend/src/utils/logger.ts | 164 + Frontend/src/vite-env.d.ts | 4 + Frontend/vite.config.ts | 15 + 246 files changed, 23535 insertions(+), 3428 deletions(-) create mode 100644 Backend/alembic/versions/__pycache__/add_borica_payment_method.cpython-312.pyc create mode 100644 Backend/alembic/versions/__pycache__/fff4b67466b3_add_account_lockout_fields_to_users.cpython-312.pyc create mode 100644 Backend/alembic/versions/fff4b67466b3_add_account_lockout_fields_to_users.py create mode 100644 Backend/src/middleware/__pycache__/admin_ip_whitelist.cpython-312.pyc create mode 100644 Backend/src/middleware/__pycache__/csrf.cpython-312.pyc create mode 100644 Backend/src/middleware/__pycache__/request_size_limit.cpython-312.pyc create mode 100644 Backend/src/middleware/admin_ip_whitelist.py create mode 100644 Backend/src/middleware/csrf.py create mode 100644 Backend/src/middleware/request_size_limit.py create mode 100644 Backend/src/schemas/__pycache__/booking.cpython-312.pyc create mode 100644 Backend/src/schemas/__pycache__/invoice.cpython-312.pyc create mode 100644 Backend/src/schemas/__pycache__/page_content.cpython-312.pyc create mode 100644 Backend/src/schemas/__pycache__/payment.cpython-312.pyc create mode 100644 Backend/src/schemas/__pycache__/promotion.cpython-312.pyc create mode 100644 Backend/src/schemas/__pycache__/review.cpython-312.pyc create mode 100644 Backend/src/schemas/__pycache__/service_booking.cpython-312.pyc create mode 100644 Backend/src/schemas/__pycache__/user.cpython-312.pyc create mode 100644 Backend/src/schemas/booking.py create mode 100644 Backend/src/schemas/invoice.py create mode 100644 Backend/src/schemas/page_content.py create mode 100644 Backend/src/schemas/payment.py create mode 100644 Backend/src/schemas/promotion.py create mode 100644 Backend/src/schemas/review.py create mode 100644 Backend/src/schemas/service_booking.py create mode 100644 Backend/src/schemas/user.py create mode 100644 Backend/src/services/__pycache__/audit_service.cpython-312.pyc create mode 100644 Backend/src/utils/__pycache__/html_sanitizer.cpython-312.pyc create mode 100644 Backend/src/utils/__pycache__/request_helpers.cpython-312.pyc create mode 100644 Backend/src/utils/file_validation.py create mode 100644 Backend/src/utils/html_sanitizer.py create mode 100644 Backend/src/utils/password_validation.py create mode 100644 Backend/src/utils/request_helpers.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/INSTALLER create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/LICENSE create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/METADATA create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/RECORD create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/REQUESTED create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/WHEEL create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/top_level.txt create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/__init__.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/__init__.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/callbacks.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/css_sanitizer.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/html5lib_shim.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/linkifier.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/parse_shim.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/sanitizer.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/README.rst create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__init__.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__pycache__/__init__.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__pycache__/parse.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/AUTHORS.rst create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/INSTALLER create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/LICENSE create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/METADATA create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/RECORD create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/REQUESTED create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/WHEEL create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/top_level.txt create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/__init__.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/__pycache__/__init__.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/__pycache__/_ihatexml.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/__pycache__/_inputstream.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/__pycache__/_tokenizer.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/__pycache__/_utils.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/__pycache__/constants.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/__pycache__/html5parser.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/__pycache__/serializer.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/_ihatexml.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/_inputstream.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/_tokenizer.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/_trie/__init__.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/_trie/__pycache__/__init__.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/_trie/__pycache__/_base.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/_trie/__pycache__/py.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/_trie/_base.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/_trie/py.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/_utils.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/constants.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/filters/__init__.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/filters/__pycache__/__init__.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/filters/__pycache__/alphabeticalattributes.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/filters/__pycache__/base.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/filters/__pycache__/inject_meta_charset.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/filters/__pycache__/lint.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/filters/__pycache__/optionaltags.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/filters/__pycache__/sanitizer.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/filters/__pycache__/whitespace.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/filters/alphabeticalattributes.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/filters/base.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/filters/inject_meta_charset.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/filters/lint.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/filters/optionaltags.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/filters/sanitizer.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/filters/whitespace.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/html5parser.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/serializer.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treeadapters/__init__.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treeadapters/__pycache__/__init__.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treeadapters/__pycache__/genshi.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treeadapters/__pycache__/sax.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treeadapters/genshi.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treeadapters/sax.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treebuilders/__init__.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treebuilders/__pycache__/__init__.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treebuilders/__pycache__/base.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treebuilders/__pycache__/dom.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treebuilders/__pycache__/etree.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treebuilders/__pycache__/etree_lxml.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treebuilders/base.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treebuilders/dom.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treebuilders/etree.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treebuilders/etree_lxml.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treewalkers/__init__.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treewalkers/__pycache__/__init__.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treewalkers/__pycache__/base.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treewalkers/__pycache__/dom.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treewalkers/__pycache__/etree.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treewalkers/__pycache__/etree_lxml.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treewalkers/__pycache__/genshi.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treewalkers/base.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treewalkers/dom.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treewalkers/etree.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treewalkers/etree_lxml.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib/treewalkers/genshi.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/parse.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/parse.py.SHA256SUM create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/vendor.txt create mode 100755 Backend/venv/lib/python3.12/site-packages/bleach/_vendor/vendor_install.sh create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/callbacks.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/css_sanitizer.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/html5lib_shim.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/linkifier.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/parse_shim.py create mode 100644 Backend/venv/lib/python3.12/site-packages/bleach/sanitizer.py create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings-0.5.1.dist-info/DESCRIPTION.rst create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings-0.5.1.dist-info/INSTALLER create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings-0.5.1.dist-info/METADATA create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings-0.5.1.dist-info/RECORD create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings-0.5.1.dist-info/WHEEL create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings-0.5.1.dist-info/metadata.json create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings-0.5.1.dist-info/top_level.txt create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings/__init__.py create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings/__pycache__/__init__.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings/__pycache__/labels.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings/__pycache__/mklabels.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings/__pycache__/tests.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings/__pycache__/x_user_defined.cpython-312.pyc create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings/labels.py create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings/mklabels.py create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings/tests.py create mode 100644 Backend/venv/lib/python3.12/site-packages/webencodings/x_user_defined.py create mode 100644 Frontend/src/components/common/ErrorBoundaryRoute.tsx create mode 100644 Frontend/src/components/common/ErrorMessage.tsx create mode 100644 Frontend/src/components/common/LoadingButton.tsx create mode 100644 Frontend/src/hooks/useApiCall.ts create mode 100644 Frontend/src/utils/errorSanitizer.ts create mode 100644 Frontend/src/utils/htmlSanitizer.ts create mode 100644 Frontend/src/utils/logger.ts diff --git a/Backend/alembic/versions/__pycache__/add_borica_payment_method.cpython-312.pyc b/Backend/alembic/versions/__pycache__/add_borica_payment_method.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..43415a7a4d3f066dff6a9b4c0eb2a38a574cc686 GIT binary patch literal 1376 zcmdT^%WD%s7@yrdcC+bfOG~YnviM2^w(G$vQWaWLACRUX&B0uj$<8F%W?#;3+D0lw z5TttON$SmW>7U|BFNU!N!IPc}(TfLhX0xFM6+G%J^F4RI$9%utH>#=tw#bh&))x_g zPh1%+rbnw+U~C5I;l#E*^-qsN>wBzR%Es_NJif{8(i9H*bl1 zab^Lu*^^I{2v6|n^?Y$<@xHb)zffG%;;sU1X}NIk_5hF4-Pl-%wv9tB2O+V1%q5oBhc=h`*PF|0 zrR5c^R4x{GQ-oh1mFqY(s+NnWge+)dGo(ouI+#j$1DmZ7?+P@@{Q>V&`(D-?o`dze zyP+3w& zM@HD$yhzgohb`ik@%!XmZDz*cvAnY@M28QQGXd}FjT@Y0g_4r-B?s%wW4# n@eknC0XW~yUg*f(;mMBL9Ufyu9qA~&WJ*xKo=6C`7=`-@Ia^MX literal 0 HcmV?d00001 diff --git a/Backend/alembic/versions/__pycache__/add_rate_plan_id_to_bookings.cpython-312.pyc b/Backend/alembic/versions/__pycache__/add_rate_plan_id_to_bookings.cpython-312.pyc index e6927cd9b782d06cdf135ee88e41bde80129616a..a773cbb9866ace6aa18b5db3941c4a759208ce0a 100644 GIT binary patch delta 18 YcmZ3?yO@{rG%qg~0}yQ7$T^D*04qWSWB>pF delta 18 YcmZ3?yO@{rG%qg~0}ym?7fK6yDiguh)wmpak((33H&uLcxx060o8kAgY=JK7?>Fl+k);>})dLS!Q>E za0*gYuH0y)O7vQV3!+k0wG!7#z2p!iG;nJ#l{iqPy>O^_vtG*qA%WD9=Kaj<&iCeh z-|p|FlB2;m^WucR+t9Q>R5BWR7HrQ!a8DyzMOePwnE;Pc>}HKS6v^|ea~c0G?LL5vyqR(TTRo=+KxaLjQMQ4u-LBE9ZMJ`c(Z z^R05P*K2lXW}7oJ-NtZ-My;|sN4PP?A#(%l6I`oJJLd(3rSd$OR=HlQpPrhYo~oZI z*XphMOsn2J)oe`H8nce`m%35~Nu7snS=@;rC*O6lm$e{u1+dT~ZTmkk_p}(Lt6)|} zof<8GCn(O1(iLkIjXlwK?Azn@QM8c-#31H_+=e+!H!>vjf6=WS=&1rmxEam}J^#So zP-5{AM~r|T!0Hm)n#0N=~{n8`)n5_57lo;b@ zfmpLG@_-?V$|zk{^dTp^h~QyHiug$Fsw%}K7+E0vMlkHiLC6omjE;gCcBFl~eRuYo z_Lg~4-8{`UKW;oRo9PLl5=+oH5kb4MOXe{q+{0MrGomst zmJ~J~r(NL&-ZBQ&Q5H#u&=6>kFb`6)gsShY@?Y6=15Oe~KNH7bSAEt|1I$YUA@sLy tA@kn}4IOx<9e8PL#l!3N(> None: + # Add account lockout fields to users table + op.add_column('users', sa.Column('failed_login_attempts', sa.Integer(), nullable=False, server_default='0')) + op.add_column('users', sa.Column('locked_until', sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + # Remove account lockout fields from users table + op.drop_column('users', 'locked_until') + op.drop_column('users', 'failed_login_attempts') + diff --git a/Backend/requirements.txt b/Backend/requirements.txt index fd03ba5a..4b675f61 100644 --- a/Backend/requirements.txt +++ b/Backend/requirements.txt @@ -22,6 +22,7 @@ pyotp==2.9.0 qrcode[pil]==7.4.2 httpx==0.25.2 cryptography>=41.0.7 +bleach==6.1.0 # Testing dependencies pytest==7.4.3 diff --git a/Backend/src/__pycache__/main.cpython-312.pyc b/Backend/src/__pycache__/main.cpython-312.pyc index cbbe56823f363ef64cdedba8ad2c421d51ba6701..911945009a60c510306f437f8057f73782b0a666 100644 GIT binary patch literal 19619 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 QuBz^nCezPOnn?Bk0)Vfj!2kdN diff --git a/Backend/src/config/__pycache__/settings.cpython-312.pyc b/Backend/src/config/__pycache__/settings.cpython-312.pyc index 3fb66bc875148d2740600a57e7f3055ec7f39f0b..86f5533b457fb4ab9797ef408eb8f85a928dc5cd 100644 GIT binary patch delta 4398 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 diff --git a/Backend/src/config/settings.py b/Backend/src/config/settings.py index 2f05da3b..6e0825c4 100644 --- a/Backend/src/config/settings.py +++ b/Backend/src/config/settings.py @@ -20,12 +20,16 @@ class Settings(BaseSettings): JWT_SECRET: str = Field(default='dev-secret-key-change-in-production-12345', description='JWT secret key') JWT_ALGORITHM: str = Field(default='HS256', description='JWT algorithm') JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30, description='JWT access token expiration in minutes') - JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, description='JWT refresh token expiration in days') + JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=3, description='JWT refresh token expiration in days (reduced from 7 for better security)') + MAX_LOGIN_ATTEMPTS: int = Field(default=5, description='Maximum failed login attempts before account lockout') + ACCOUNT_LOCKOUT_DURATION_MINUTES: int = Field(default=30, description='Account lockout duration in minutes after max failed attempts') ENCRYPTION_KEY: str = Field(default='', description='Base64-encoded encryption key for data encryption at rest') CLIENT_URL: str = Field(default='http://localhost:5173', description='Frontend client URL') CORS_ORIGINS: List[str] = Field(default_factory=lambda: ['http://localhost:5173', 'http://localhost:3000', 'http://127.0.0.1:5173'], description='Allowed CORS origins') RATE_LIMIT_ENABLED: bool = Field(default=True, description='Enable rate limiting') RATE_LIMIT_PER_MINUTE: int = Field(default=60, description='Requests per minute per IP') + CSRF_PROTECTION_ENABLED: bool = Field(default=True, description='Enable CSRF protection') + HSTS_PRELOAD_ENABLED: bool = Field(default=False, description='Enable HSTS preload directive (requires domain submission to hstspreload.org)') LOG_LEVEL: str = Field(default='INFO', description='Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL') LOG_FILE: str = Field(default='logs/app.log', description='Log file path') LOG_MAX_BYTES: int = Field(default=10485760, description='Max log file size (10MB)') @@ -38,6 +42,7 @@ class Settings(BaseSettings): SMTP_FROM_NAME: str = Field(default='Hotel Booking', description='From name') UPLOAD_DIR: str = Field(default='uploads', description='Upload directory') MAX_UPLOAD_SIZE: int = Field(default=5242880, description='Max upload size in bytes (5MB)') + MAX_REQUEST_BODY_SIZE: int = Field(default=10485760, description='Max request body size in bytes (10MB)') ALLOWED_EXTENSIONS: List[str] = Field(default_factory=lambda: ['jpg', 'jpeg', 'png', 'gif', 'webp'], description='Allowed file extensions') REDIS_ENABLED: bool = Field(default=False, description='Enable Redis caching') REDIS_HOST: str = Field(default='localhost', description='Redis host') @@ -76,4 +81,49 @@ class Settings(BaseSettings): if self.REDIS_PASSWORD: return f'redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}' return f'redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}' + + IP_WHITELIST_ENABLED: bool = Field(default=False, description='Enable IP whitelisting for admin endpoints') + ADMIN_IP_WHITELIST: List[str] = Field(default_factory=list, description='List of allowed IP addresses/CIDR ranges for admin endpoints') + + def validate_encryption_key(self) -> None: + """ + Validate encryption key is properly configured. + Raises ValueError if key is missing or invalid in production. + """ + if not self.ENCRYPTION_KEY: + if self.is_production: + raise ValueError( + 'CRITICAL: ENCRYPTION_KEY is not configured in production. ' + 'Please set ENCRYPTION_KEY environment variable to a base64-encoded 32-byte key.' + ) + else: + # In development, warn but don't fail + import logging + logger = logging.getLogger(__name__) + logger.warning( + 'ENCRYPTION_KEY is not configured. Encryption operations may fail. ' + 'Please set ENCRYPTION_KEY environment variable.' + ) + return + + # Validate base64 encoding and key length (32 bytes = 44 base64 chars) + try: + import base64 + decoded = base64.b64decode(self.ENCRYPTION_KEY) + if len(decoded) != 32: + raise ValueError( + f'ENCRYPTION_KEY must be a base64-encoded 32-byte key. ' + f'Received {len(decoded)} bytes after decoding.' + ) + except Exception as e: + if self.is_production: + raise ValueError( + f'Invalid ENCRYPTION_KEY format: {str(e)}. ' + 'Must be a valid base64-encoded 32-byte key.' + ) + else: + import logging + logger = logging.getLogger(__name__) + logger.warning(f'Invalid ENCRYPTION_KEY format: {str(e)}') + settings = Settings() \ No newline at end of file diff --git a/Backend/src/main.py b/Backend/src/main.py index 920396c4..88219184 100644 --- a/Backend/src/main.py +++ b/Backend/src/main.py @@ -14,6 +14,7 @@ import sys import secrets import os import re +import logging from .config.settings import settings from .config.logging_config import setup_logging, get_logger from .config.database import engine, Base, get_db @@ -26,6 +27,9 @@ from .middleware.request_id import RequestIDMiddleware from .middleware.security import SecurityHeadersMiddleware from .middleware.timeout import TimeoutMiddleware from .middleware.cookie_consent import CookieConsentMiddleware +from .middleware.csrf import CSRFProtectionMiddleware +from .middleware.request_size_limit import RequestSizeLimitMiddleware +from .middleware.admin_ip_whitelist import AdminIPWhitelistMiddleware if settings.is_development: logger.info('Creating database tables (development mode)') Base.metadata.create_all(bind=engine) @@ -48,6 +52,14 @@ app.add_middleware(CookieConsentMiddleware) if settings.REQUEST_TIMEOUT > 0: app.add_middleware(TimeoutMiddleware) app.add_middleware(SecurityHeadersMiddleware) +app.add_middleware(RequestSizeLimitMiddleware, max_size=settings.MAX_REQUEST_BODY_SIZE) +logger.info(f'Request size limiting enabled: {settings.MAX_REQUEST_BODY_SIZE // 1024 // 1024}MB max body size') +if settings.CSRF_PROTECTION_ENABLED: + app.add_middleware(CSRFProtectionMiddleware) + logger.info('CSRF protection enabled') +if settings.IP_WHITELIST_ENABLED: + app.add_middleware(AdminIPWhitelistMiddleware) + logger.info(f'Admin IP whitelisting enabled with {len(settings.ADMIN_IP_WHITELIST)} IP(s)/CIDR range(s)') if settings.RATE_LIMIT_ENABLED: limiter = Limiter(key_func=get_remote_address, default_limits=[f'{settings.RATE_LIMIT_PER_MINUTE}/minute']) app.state.limiter = limiter @@ -57,8 +69,17 @@ if settings.is_development: app.add_middleware(CORSMiddleware, allow_origin_regex='http://(localhost|127\\.0\\.0\\.1)(:\\d+)?', allow_credentials=True, allow_methods=['*'], allow_headers=['*']) logger.info('CORS configured for development (allowing localhost)') else: - app.add_middleware(CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], allow_headers=['*']) - logger.info(f'CORS configured for production with {len(settings.CORS_ORIGINS)} allowed origins') + # Validate CORS_ORIGINS in production + if not settings.CORS_ORIGINS or len(settings.CORS_ORIGINS) == 0: + logger.warning('CORS_ORIGINS is empty in production. This may block legitimate requests.') + logger.warning('Please set CORS_ORIGINS environment variable with allowed origins.') + else: + # Log CORS configuration for security audit + logger.info(f'CORS configured for production with {len(settings.CORS_ORIGINS)} allowed origin(s)') + if logger.isEnabledFor(logging.DEBUG): + logger.debug(f'Allowed CORS origins: {", ".join(settings.CORS_ORIGINS)}') + + app.add_middleware(CORSMiddleware, allow_origins=settings.CORS_ORIGINS or [], allow_credentials=True, allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], allow_headers=['*']) uploads_dir = Path(__file__).parent.parent / settings.UPLOAD_DIR uploads_dir.mkdir(exist_ok=True) app.mount('/uploads', StaticFiles(directory=str(uploads_dir)), name='uploads') @@ -93,93 +114,85 @@ async def health_check(db: Session=Depends(get_db)): @app.get('/metrics', tags=['monitoring']) async def metrics(): return {'status': 'success', 'service': settings.APP_NAME, 'version': settings.APP_VERSION, 'environment': settings.ENVIRONMENT, 'timestamp': datetime.utcnow().isoformat()} -app.include_router(auth_routes.router, prefix='/api') -app.include_router(auth_routes.router, prefix=settings.API_V1_PREFIX) -from .routes import room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes, system_settings_routes, contact_routes, page_content_routes, home_routes, about_routes, contact_content_routes, footer_routes, chat_routes, privacy_routes, terms_routes, refunds_routes, cancellation_routes, accessibility_routes, faq_routes, loyalty_routes, guest_profile_routes, analytics_routes, workflow_routes, task_routes, notification_routes, group_booking_routes, advanced_room_routes, rate_plan_routes, package_routes, security_routes, email_campaign_routes -app.include_router(room_routes.router, prefix='/api') -app.include_router(booking_routes.router, prefix='/api') -app.include_router(group_booking_routes.router, prefix='/api') -app.include_router(payment_routes.router, prefix='/api') -app.include_router(invoice_routes.router, prefix='/api') -app.include_router(banner_routes.router, prefix='/api') -app.include_router(favorite_routes.router, prefix='/api') -app.include_router(service_routes.router, prefix='/api') -app.include_router(service_booking_routes.router, prefix='/api') -app.include_router(promotion_routes.router, prefix='/api') -app.include_router(report_routes.router, prefix='/api') -app.include_router(review_routes.router, prefix='/api') -app.include_router(user_routes.router, prefix='/api') -app.include_router(audit_routes.router, prefix='/api') -app.include_router(admin_privacy_routes.router, prefix='/api') -app.include_router(system_settings_routes.router, prefix='/api') -app.include_router(contact_routes.router, prefix='/api') -app.include_router(home_routes.router, prefix='/api') -app.include_router(about_routes.router, prefix='/api') -app.include_router(contact_content_routes.router, prefix='/api') -app.include_router(footer_routes.router, prefix='/api') -app.include_router(privacy_routes.router, prefix='/api') -app.include_router(terms_routes.router, prefix='/api') -app.include_router(refunds_routes.router, prefix='/api') -app.include_router(cancellation_routes.router, prefix='/api') -app.include_router(accessibility_routes.router, prefix='/api') -app.include_router(faq_routes.router, prefix='/api') -app.include_router(chat_routes.router, prefix='/api') -app.include_router(loyalty_routes.router, prefix='/api') -app.include_router(guest_profile_routes.router, prefix='/api') -app.include_router(analytics_routes.router, prefix='/api') -app.include_router(workflow_routes.router, prefix='/api') -app.include_router(task_routes.router, prefix='/api') -app.include_router(notification_routes.router, prefix='/api') -app.include_router(advanced_room_routes.router, prefix='/api') -app.include_router(rate_plan_routes.router, prefix='/api') -app.include_router(package_routes.router, prefix='/api') -app.include_router(security_routes.router, prefix='/api') -app.include_router(email_campaign_routes.router, prefix='/api') -app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(booking_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(payment_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(invoice_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(banner_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(favorite_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(service_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(service_booking_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(promotion_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(report_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(review_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(user_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(audit_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(admin_privacy_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(system_settings_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(contact_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(home_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(about_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(contact_content_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(footer_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(privacy_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(terms_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(refunds_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(cancellation_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(accessibility_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(faq_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(chat_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(loyalty_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(guest_profile_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(analytics_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(workflow_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(task_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(notification_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(advanced_room_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(rate_plan_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(package_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(page_content_routes.router, prefix='/api') -app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX) +# Import all route modules +from .routes import ( + room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, + favorite_routes, service_routes, service_booking_routes, promotion_routes, + report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes, + system_settings_routes, contact_routes, page_content_routes, home_routes, + about_routes, contact_content_routes, footer_routes, chat_routes, privacy_routes, + terms_routes, refunds_routes, cancellation_routes, accessibility_routes, + faq_routes, loyalty_routes, guest_profile_routes, analytics_routes, + workflow_routes, task_routes, notification_routes, group_booking_routes, + advanced_room_routes, rate_plan_routes, package_routes, security_routes, + email_campaign_routes +) + +# Register all routes with /api prefix (removed duplicate registrations) +# Using /api prefix as standard, API versioning can be handled via headers if needed +api_prefix = '/api' +app.include_router(auth_routes.router, prefix=api_prefix) +app.include_router(room_routes.router, prefix=api_prefix) +app.include_router(booking_routes.router, prefix=api_prefix) +app.include_router(group_booking_routes.router, prefix=api_prefix) +app.include_router(payment_routes.router, prefix=api_prefix) +app.include_router(invoice_routes.router, prefix=api_prefix) +app.include_router(banner_routes.router, prefix=api_prefix) +app.include_router(favorite_routes.router, prefix=api_prefix) +app.include_router(service_routes.router, prefix=api_prefix) +app.include_router(service_booking_routes.router, prefix=api_prefix) +app.include_router(promotion_routes.router, prefix=api_prefix) +app.include_router(report_routes.router, prefix=api_prefix) +app.include_router(review_routes.router, prefix=api_prefix) +app.include_router(user_routes.router, prefix=api_prefix) +app.include_router(audit_routes.router, prefix=api_prefix) +app.include_router(admin_privacy_routes.router, prefix=api_prefix) +app.include_router(system_settings_routes.router, prefix=api_prefix) +app.include_router(contact_routes.router, prefix=api_prefix) +app.include_router(home_routes.router, prefix=api_prefix) +app.include_router(about_routes.router, prefix=api_prefix) +app.include_router(contact_content_routes.router, prefix=api_prefix) +app.include_router(footer_routes.router, prefix=api_prefix) +app.include_router(privacy_routes.router, prefix=api_prefix) +app.include_router(terms_routes.router, prefix=api_prefix) +app.include_router(refunds_routes.router, prefix=api_prefix) +app.include_router(cancellation_routes.router, prefix=api_prefix) +app.include_router(accessibility_routes.router, prefix=api_prefix) +app.include_router(faq_routes.router, prefix=api_prefix) +app.include_router(chat_routes.router, prefix=api_prefix) +app.include_router(loyalty_routes.router, prefix=api_prefix) +app.include_router(guest_profile_routes.router, prefix=api_prefix) +app.include_router(analytics_routes.router, prefix=api_prefix) +app.include_router(workflow_routes.router, prefix=api_prefix) +app.include_router(task_routes.router, prefix=api_prefix) +app.include_router(notification_routes.router, prefix=api_prefix) +app.include_router(advanced_room_routes.router, prefix=api_prefix) +app.include_router(rate_plan_routes.router, prefix=api_prefix) +app.include_router(package_routes.router, prefix=api_prefix) +app.include_router(security_routes.router, prefix=api_prefix) +app.include_router(email_campaign_routes.router, prefix=api_prefix) +app.include_router(page_content_routes.router, prefix=api_prefix) logger.info('All routes registered successfully') def ensure_jwt_secret(): - """Generate and save JWT secret if it's using the default value.""" + """Generate and save JWT secret if it's using the default value. + + In production, fail fast if default secret is used for security. + In development, auto-generate a secure secret if needed. + """ default_secret = 'dev-secret-key-change-in-production-12345' current_secret = settings.JWT_SECRET + # Security check: Fail fast in production if using default secret + if settings.is_production and (not current_secret or current_secret == default_secret): + error_msg = ( + 'CRITICAL SECURITY ERROR: JWT_SECRET is using default value in production! ' + 'Please set a secure JWT_SECRET in your environment variables.' + ) + logger.error(error_msg) + raise ValueError(error_msg) + + # Development mode: Auto-generate if needed if not current_secret or current_secret == default_secret: new_secret = secrets.token_urlsafe(64) @@ -219,6 +232,14 @@ def ensure_jwt_secret(): async def startup_event(): ensure_jwt_secret() + # Validate encryption key configuration + try: + settings.validate_encryption_key() + except ValueError as e: + logger.error(str(e)) + if settings.is_production: + raise # Fail fast in production + logger.info(f'{settings.APP_NAME} started successfully') logger.info(f'Environment: {settings.ENVIRONMENT}') logger.info(f'Debug mode: {settings.DEBUG}') diff --git a/Backend/src/middleware/__pycache__/admin_ip_whitelist.cpython-312.pyc b/Backend/src/middleware/__pycache__/admin_ip_whitelist.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..11f9e10f94aa12f924bedf48b1452a8c725f5d76 GIT binary patch literal 5991 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 delta 1765 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 jO$`>i_pKn(s7{+{YLfcxu__|&FaTbutsb*R=j literal 0 HcmV?d00001 diff --git a/Backend/src/middleware/__pycache__/error_handler.cpython-312.pyc b/Backend/src/middleware/__pycache__/error_handler.cpython-312.pyc index 0d3a2bd51a98f85e7f7754046d1a0c573e0b877a..8cb39fdf568ff394c0cd2d2af38052cfbaf92374 100644 GIT binary patch literal 7535 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 N+15Y2#2K0Xe*;_jn-~B9 literal 4825 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 delta 376 zcmdlh@RX15G%qg~0}wokP{@46vXRf3k&$C^5MvD&YYJx#OA40_1HyDO}53&Zx;dIiKkV zQ`OqZIm~M(ceB_{e$67o$U2#WRZ);Nvm`aQ_!fIcYGO)iQL!fbWM@`mVKxZ&7He5z zPHAc}JJ8U{vsmjGmrs^t3y@-Bu+Cz>z$0@(*6bpW`5kWI>)eW$xD{{MBu<{iW-Teq zAfofDSOBP}iN}eFv5(P-fu-Nk$cbt4J2pLIDWLdDhR;Bfp-2)$fWiSxfc#U;2P9e; zKCp1HN-RkK!T_X-b%7 diff --git a/Backend/src/middleware/admin_ip_whitelist.py b/Backend/src/middleware/admin_ip_whitelist.py new file mode 100644 index 00000000..fd0aff19 --- /dev/null +++ b/Backend/src/middleware/admin_ip_whitelist.py @@ -0,0 +1,114 @@ +from fastapi import Request, HTTPException, status +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse +from typing import List +import ipaddress +from ..config.settings import settings +from ..config.logging_config import get_logger + +logger = get_logger(__name__) + +class AdminIPWhitelistMiddleware(BaseHTTPMiddleware): + """ + Middleware to enforce IP whitelisting for admin endpoints. + Only applies to routes starting with /api/admin/ or containing 'admin' in path. + """ + + def __init__(self, app, enabled: bool = None, whitelist: List[str] = None): + super().__init__(app) + self.enabled = enabled if enabled is not None else settings.IP_WHITELIST_ENABLED + self.whitelist = whitelist if whitelist is not None else settings.ADMIN_IP_WHITELIST + + # Pre-compile IP networks for faster lookup + self._compiled_networks = [] + if self.enabled and self.whitelist: + for ip_or_cidr in self.whitelist: + try: + if '/' in ip_or_cidr: + # CIDR notation + network = ipaddress.ip_network(ip_or_cidr, strict=False) + self._compiled_networks.append(network) + else: + # Single IP address + ip = ipaddress.ip_address(ip_or_cidr) + # Convert to /32 network for consistent handling + self._compiled_networks.append(ipaddress.ip_network(f'{ip}/32', strict=False)) + except (ValueError, ipaddress.AddressValueError) as e: + logger.warning(f'Invalid IP/CIDR in admin whitelist: {ip_or_cidr} - {str(e)}') + + if self.enabled: + logger.info(f'Admin IP whitelisting enabled with {len(self._compiled_networks)} allowed IP(s)/CIDR range(s)') + + def _is_admin_route(self, path: str) -> bool: + """Check if the path is an admin route""" + return '/admin/' in path.lower() or path.lower().startswith('/api/admin') + + def _get_client_ip(self, request: Request) -> str: + """Extract client IP address from request""" + # Check for forwarded IP (when behind proxy/load balancer) + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + # X-Forwarded-For can contain multiple IPs, take the first one (original client) + return forwarded_for.split(",")[0].strip() + + real_ip = request.headers.get("X-Real-IP") + if real_ip: + return real_ip.strip() + + # Fallback to direct client IP + if request.client: + return request.client.host + + return None + + def _is_ip_allowed(self, ip_address: str) -> bool: + """Check if IP address is in whitelist""" + if not self._compiled_networks: + # Empty whitelist means deny all (security-first approach) + return False + + try: + client_ip = ipaddress.ip_address(ip_address) + for network in self._compiled_networks: + if client_ip in network: + return True + return False + except (ValueError, ipaddress.AddressValueError): + logger.warning(f'Invalid IP address format: {ip_address}') + return False + + async def dispatch(self, request: Request, call_next): + # Skip if not enabled + if not self.enabled: + return await call_next(request) + + # Only apply to admin routes + if not self._is_admin_route(request.url.path): + return await call_next(request) + + # Skip IP check for health checks and public endpoints + if request.url.path in ['/health', '/api/health', '/metrics']: + return await call_next(request) + + client_ip = self._get_client_ip(request) + + if not client_ip: + logger.warning("Could not determine client IP address for admin route") + # Deny by default if IP cannot be determined (security-first) + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"status": "error", "message": "Access denied: Unable to verify IP address"} + ) + + # Check whitelist + if not self._is_ip_allowed(client_ip): + logger.warning( + f"Admin route access denied for IP: {client_ip} from path: {request.url.path}" + ) + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"status": "error", "message": "Access denied. IP address not whitelisted."} + ) + + # IP is whitelisted, continue + return await call_next(request) diff --git a/Backend/src/middleware/auth.py b/Backend/src/middleware/auth.py index fe895692..7531f4a8 100644 --- a/Backend/src/middleware/auth.py +++ b/Backend/src/middleware/auth.py @@ -10,17 +10,48 @@ from ..models.user import User from ..models.role import Role security = HTTPBearer() +def get_jwt_secret() -> str: + """ + Get JWT secret securely, fail if not configured. + Never use hardcoded fallback secrets. + """ + default_secret = 'dev-secret-key-change-in-production-12345' + jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', None) + + # Fail fast if secret is not configured or using default value + if not jwt_secret or jwt_secret == default_secret: + if settings.is_production: + raise ValueError( + 'CRITICAL: JWT_SECRET is not properly configured in production. ' + 'Please set JWT_SECRET environment variable to a secure random string.' + ) + # In development, warn but allow (startup validation should catch this) + import warnings + warnings.warn( + f'JWT_SECRET not configured. Using settings value but this is insecure. ' + f'Set JWT_SECRET environment variable.', + UserWarning + ) + jwt_secret = getattr(settings, 'JWT_SECRET', None) + if not jwt_secret: + raise ValueError('JWT_SECRET must be configured') + + return jwt_secret + def get_current_user(credentials: HTTPAuthorizationCredentials=Depends(security), db: Session=Depends(get_db)) -> User: token = credentials.credentials credentials_exception = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Could not validate credentials', headers={'WWW-Authenticate': 'Bearer'}) try: - jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', 'dev-secret-key-change-in-production-12345') + jwt_secret = get_jwt_secret() payload = jwt.decode(token, jwt_secret, algorithms=['HS256']) user_id: int = payload.get('userId') if user_id is None: raise credentials_exception except JWTError: raise credentials_exception + except ValueError as e: + # JWT secret configuration error - should not happen in production + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Server configuration error') user = db.query(User).filter(User.id == user_id).first() if user is None: raise credentials_exception @@ -43,17 +74,17 @@ def get_current_user_optional(credentials: Optional[HTTPAuthorizationCredentials return None token = credentials.credentials try: - jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', 'dev-secret-key-change-in-production-12345') + jwt_secret = get_jwt_secret() payload = jwt.decode(token, jwt_secret, algorithms=['HS256']) user_id: int = payload.get('userId') if user_id is None: return None - except JWTError: + except (JWTError, ValueError): return None user = db.query(User).filter(User.id == user_id).first() return user def verify_token(token: str) -> dict: - jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', 'dev-secret-key-change-in-production-12345') + jwt_secret = get_jwt_secret() payload = jwt.decode(token, jwt_secret, algorithms=['HS256']) return payload \ No newline at end of file diff --git a/Backend/src/middleware/csrf.py b/Backend/src/middleware/csrf.py new file mode 100644 index 00000000..2b162cd4 --- /dev/null +++ b/Backend/src/middleware/csrf.py @@ -0,0 +1,158 @@ +from fastapi import Request, HTTPException, status +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response +from typing import Optional +import secrets +import hmac +import hashlib +from ..config.logging_config import get_logger +from ..config.settings import settings + +logger = get_logger(__name__) + +# Safe HTTP methods that don't require CSRF protection +SAFE_METHODS = {'GET', 'HEAD', 'OPTIONS'} + + +class CSRFProtectionMiddleware(BaseHTTPMiddleware): + """ + CSRF Protection Middleware + + Validates CSRF tokens for state-changing requests (POST, PUT, DELETE, PATCH). + Uses Double Submit Cookie pattern for stateless CSRF protection. + """ + + CSRF_TOKEN_COOKIE_NAME = 'XSRF-TOKEN' + CSRF_TOKEN_HEADER_NAME = 'X-XSRF-TOKEN' + CSRF_SECRET_LENGTH = 32 + + async def dispatch(self, request: Request, call_next): + path = request.url.path + + # Skip CSRF protection for certain endpoints that don't need it + # (e.g., public APIs, webhooks with their own validation) + is_exempt = self._is_exempt_path(path) + + # Get or generate CSRF token (always generate for all requests to ensure cookie is set) + csrf_token = request.cookies.get(self.CSRF_TOKEN_COOKIE_NAME) + if not csrf_token: + csrf_token = self._generate_token() + + # Skip CSRF validation for safe methods (GET, HEAD, OPTIONS) and exempt paths + if request.method in SAFE_METHODS or is_exempt: + response = await call_next(request) + else: + # For state-changing requests, validate the token + if request.method in {'POST', 'PUT', 'DELETE', 'PATCH'}: + header_token = request.headers.get(self.CSRF_TOKEN_HEADER_NAME) + + if not header_token: + logger.warning(f"CSRF token missing in header for {request.method} {path}") + # Create error response with CSRF cookie set so frontend can retry + from fastapi.responses import JSONResponse + error_response = JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"status": "error", "message": "CSRF token missing. Please include X-XSRF-TOKEN header."} + ) + # Set cookie even on error so client can get the token and retry + if not request.cookies.get(self.CSRF_TOKEN_COOKIE_NAME): + error_response.set_cookie( + key=self.CSRF_TOKEN_COOKIE_NAME, + value=csrf_token, + httponly=False, + secure=settings.is_production, + samesite='lax', # Changed to 'lax' for better cross-origin support + max_age=86400 * 7, + path='/' + ) + return error_response + + # Validate token using constant-time comparison + if not self._verify_token(csrf_token, header_token): + logger.warning(f"CSRF token validation failed for {request.method} {path}") + # Create error response with CSRF cookie set so frontend can retry + from fastapi.responses import JSONResponse + error_response = JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"status": "error", "message": "Invalid CSRF token. Please refresh the page and try again."} + ) + # Set cookie even on error so client can get the token and retry + if not request.cookies.get(self.CSRF_TOKEN_COOKIE_NAME): + error_response.set_cookie( + key=self.CSRF_TOKEN_COOKIE_NAME, + value=csrf_token, + httponly=False, + secure=settings.is_production, + samesite='lax', # Changed to 'lax' for better cross-origin support + max_age=86400 * 7, + path='/' + ) + return error_response + + # Process request + response = await call_next(request) + + # Always set CSRF token cookie if not present (ensures client always has it) + # This allows frontend to read it from cookies for subsequent requests + if not request.cookies.get(self.CSRF_TOKEN_COOKIE_NAME): + # Set secure cookie with SameSite protection + response.set_cookie( + key=self.CSRF_TOKEN_COOKIE_NAME, + value=csrf_token, + httponly=False, # Must be accessible to JavaScript for header submission + secure=settings.is_production, # HTTPS only in production + samesite='lax', # Changed to 'lax' for better cross-origin support + max_age=86400 * 7, # 7 days + path='/' + ) + + return response + + def _is_exempt_path(self, path: str) -> bool: + """ + Check if path is exempt from CSRF protection. + + Exempt paths: + - Authentication endpoints (login, register, logout, refresh token) + - Webhook endpoints (they have their own signature validation) + - Health check endpoints + - Static file endpoints + """ + exempt_patterns = [ + '/api/auth/', # All authentication endpoints + '/api/webhooks/', + '/api/stripe/webhook', + '/api/payments/stripe/webhook', + '/api/paypal/webhook', + '/health', + '/api/health', + '/static/', + '/docs', + '/redoc', + '/openapi.json' + ] + + return any(path.startswith(pattern) for pattern in exempt_patterns) + + def _generate_token(self) -> str: + """Generate a secure random CSRF token.""" + return secrets.token_urlsafe(self.CSRF_SECRET_LENGTH) + + def _verify_token(self, cookie_token: str, header_token: str) -> bool: + """ + Verify CSRF token using constant-time comparison. + + Uses the Double Submit Cookie pattern - the token in the cookie + must match the token in the header. + """ + if not cookie_token or not header_token: + return False + + # Constant-time comparison to prevent timing attacks + return hmac.compare_digest(cookie_token, header_token) + + +def get_csrf_token(request: Request) -> Optional[str]: + """Helper function to get CSRF token from request cookies.""" + return request.cookies.get(CSRFProtectionMiddleware.CSRF_TOKEN_COOKIE_NAME) + diff --git a/Backend/src/middleware/error_handler.py b/Backend/src/middleware/error_handler.py index c05e3ec8..fd07f125 100644 --- a/Backend/src/middleware/error_handler.py +++ b/Backend/src/middleware/error_handler.py @@ -4,6 +4,30 @@ from fastapi.exceptions import RequestValidationError from sqlalchemy.exc import IntegrityError from jose.exceptions import JWTError import traceback +import os +from ..utils.response_helpers import error_response +from ..config.settings import settings + +def _add_cors_headers(response: JSONResponse, request: Request) -> JSONResponse: + """Add CORS headers to response for cross-origin requests.""" + origin = request.headers.get('Origin') + if origin: + # Check if origin is allowed (development or production) + if settings.is_development: + # Allow localhost origins in development + if origin.startswith('http://localhost') or origin.startswith('http://127.0.0.1'): + response.headers['Access-Control-Allow-Origin'] = origin + response.headers['Access-Control-Allow-Credentials'] = 'true' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, PATCH, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = '*' + else: + # In production, check against CORS_ORIGINS + if origin in settings.CORS_ORIGINS: + response.headers['Access-Control-Allow-Origin'] = origin + response.headers['Access-Control-Allow-Credentials'] = 'true' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, PATCH, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = '*' + return response async def validation_exception_handler(request: Request, exc: RequestValidationError): errors = [] @@ -11,25 +35,69 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE field = '.'.join((str(loc) for loc in error['loc'] if loc != 'body')) errors.append({'field': field, 'message': error['msg']}) first_error = errors[0]['message'] if errors else 'Validation error' - return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': first_error, 'errors': errors}) + request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None + response_content = error_response( + message=first_error, + errors=errors, + request_id=request_id + ) + response = JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content=response_content) + # Add CORS headers to error responses + return _add_cors_headers(response, request) async def integrity_error_handler(request: Request, exc: IntegrityError): error_msg = str(exc.orig) if hasattr(exc, 'orig') else str(exc) + request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None if 'Duplicate entry' in error_msg or 'UNIQUE constraint' in error_msg: - return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': 'Duplicate entry', 'errors': [{'message': 'This record already exists'}]}) - return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': 'Database integrity error'}) + response_content = error_response( + message='Duplicate entry', + errors=[{'message': 'This record already exists'}], + request_id=request_id + ) + response = JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content=response_content) + else: + response_content = error_response( + message='Database integrity error', + request_id=request_id + ) + response = JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content=response_content) + # Add CORS headers to error responses + return _add_cors_headers(response, request) async def jwt_error_handler(request: Request, exc: JWTError): - return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content={'status': 'error', 'message': 'Invalid token'}) + request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None + response_content = error_response( + message='Invalid token', + request_id=request_id + ) + response = JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content=response_content) + # Add CORS headers to error responses + return _add_cors_headers(response, request) async def http_exception_handler(request: Request, exc: HTTPException): + request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None if isinstance(exc.detail, dict): - return JSONResponse(status_code=exc.status_code, content=exc.detail) - return JSONResponse(status_code=exc.status_code, content={'status': 'error', 'message': str(exc.detail) if exc.detail else 'An error occurred'}) + response_content = exc.detail.copy() + if request_id and 'request_id' not in response_content: + response_content['request_id'] = request_id + # Ensure it has standard error response format + if 'status' not in response_content: + response_content['status'] = 'error' + if 'success' not in response_content: + response_content['success'] = False + response = JSONResponse(status_code=exc.status_code, content=response_content) + else: + response_content = error_response( + message=str(exc.detail) if exc.detail else 'An error occurred', + request_id=request_id + ) + response = JSONResponse(status_code=exc.status_code, content=response_content) + + # Add CORS headers to error responses + return _add_cors_headers(response, request) async def general_exception_handler(request: Request, exc: Exception): from ..config.logging_config import get_logger - from ..config.settings import settings logger = get_logger(__name__) request_id = getattr(request.state, 'request_id', None) logger.error(f'Unhandled exception: {type(exc).__name__}: {str(exc)}', extra={'request_id': request_id, 'path': request.url.path, 'method': request.method, 'exception_type': type(exc).__name__}, exc_info=True) @@ -38,14 +106,30 @@ async def general_exception_handler(request: Request, exc: Exception): if hasattr(exc, 'detail'): detail = exc.detail if isinstance(detail, dict): - return JSONResponse(status_code=status_code, content=detail) + response = JSONResponse(status_code=status_code, content=detail) + return _add_cors_headers(response, request) message = str(detail) if detail else 'An error occurred' else: message = str(exc) if str(exc) else 'Internal server error' else: status_code = status.HTTP_500_INTERNAL_SERVER_ERROR message = str(exc) if str(exc) else 'Internal server error' - response_content = {'status': 'error', 'message': message} + response_content = error_response( + message=message, + request_id=request_id + ) + # NEVER include stack traces in production responses + # Always log stack traces server-side only for debugging if settings.is_development: - response_content['stack'] = traceback.format_exc() - return JSONResponse(status_code=status_code, content=response_content) \ No newline at end of file + # Only include stack traces in development mode + # Double-check environment to prevent accidental exposure + env_check = os.getenv('ENVIRONMENT', 'development').lower() + if env_check == 'development': + response_content['stack'] = traceback.format_exc() + else: + # Log warning if development flag is set but environment says otherwise + logger.warning(f'is_development=True but ENVIRONMENT={env_check}. Not including stack trace in response.') + # Stack traces are always logged server-side via exc_info=True above + response = JSONResponse(status_code=status_code, content=response_content) + # Add CORS headers to error responses + return _add_cors_headers(response, request) \ No newline at end of file diff --git a/Backend/src/middleware/request_size_limit.py b/Backend/src/middleware/request_size_limit.py new file mode 100644 index 00000000..4006dac2 --- /dev/null +++ b/Backend/src/middleware/request_size_limit.py @@ -0,0 +1,53 @@ +from fastapi import Request, HTTPException, status +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse +from ..config.logging_config import get_logger +from ..config.settings import settings + +logger = get_logger(__name__) + + +class RequestSizeLimitMiddleware(BaseHTTPMiddleware): + """ + Middleware to enforce maximum request body size limits. + + Prevents DoS attacks by rejecting requests that exceed the configured + maximum body size before they are processed. + """ + + def __init__(self, app, max_size: int = None): + super().__init__(app) + self.max_size = max_size or settings.MAX_REQUEST_BODY_SIZE + + async def dispatch(self, request: Request, call_next): + # Skip size check for methods that don't have bodies + if request.method in ['GET', 'HEAD', 'OPTIONS', 'DELETE']: + return await call_next(request) + + # Check Content-Length header if available + content_length = request.headers.get('content-length') + if content_length: + try: + size = int(content_length) + if size > self.max_size: + logger.warning( + f"Request body size {size} bytes exceeds maximum {self.max_size} bytes " + f"from {request.client.host if request.client else 'unknown'}" + ) + return JSONResponse( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + content={ + 'status': 'error', + 'message': f'Request body too large. Maximum size: {self.max_size // 1024 // 1024}MB' + } + ) + except (ValueError, TypeError): + # Invalid content-length header, let it pass and let FastAPI handle it + pass + + # For streaming requests without Content-Length, we need to check the body + # This is handled by limiting the body read size + response = await call_next(request) + + return response + diff --git a/Backend/src/middleware/security.py b/Backend/src/middleware/security.py index 9dfe8e2a..4f6b9bb3 100644 --- a/Backend/src/middleware/security.py +++ b/Backend/src/middleware/security.py @@ -12,9 +12,29 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): security_headers = {'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'X-XSS-Protection': '1; mode=block', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'Permissions-Policy': 'geolocation=(), microphone=(), camera=()'} security_headers.setdefault('Cross-Origin-Resource-Policy', 'cross-origin') if settings.is_production: - security_headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'" - if settings.is_production: - security_headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' + # Enhanced CSP with additional directives + # Note: unsafe-inline and unsafe-eval are kept for React/Vite compatibility + # Consider moving to nonces/hashes in future for stricter policy + security_headers['Content-Security-Policy'] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "font-src 'self' data:; " + "connect-src 'self' https:; " + "base-uri 'self'; " + "form-action 'self'; " + "frame-ancestors 'none'; " + "object-src 'none'; " + "upgrade-insecure-requests" + ) + # HSTS with preload directive (only add preload if domain is ready for it) + # Preload requires manual submission to hstspreload.org + # Include preload directive only if explicitly enabled + hsts_directive = 'max-age=31536000; includeSubDomains' + if getattr(settings, 'HSTS_PRELOAD_ENABLED', False): + hsts_directive += '; preload' + security_headers['Strict-Transport-Security'] = hsts_directive for header, value in security_headers.items(): response.headers[header] = value return response \ No newline at end of file diff --git a/Backend/src/models/__pycache__/booking.cpython-312.pyc b/Backend/src/models/__pycache__/booking.cpython-312.pyc index 4ef816505bd7a0a352e27d24d2f4cdf1a6742c34..487efac117ff4f30bca8ac76845058c3df632b8f 100644 GIT binary patch delta 1244 zcmZvcO-vI(6vuaVx7}^aZVPSs{wUvFMMxAB2oX&%fC(fd5KYxIrL$mRx78_4z=Ora zL@yd~E_fq)!IXoE7ZT&igCtxqU<{iW&m2rJ>OoJ=%$9}{C+)xg{mpwb?@ea=r{Y7E zx~(W~j*RTraN@mtMXdxcSK7O>raR&W9ONKxc+8SW3G;%Xm`X%pUNqE*3LVgCcuil# z2RNP^=AiVDgR)+#t8+fOXz|F2_H(3+#kxqWTleLJJoPV$n&*H%XzrCyW0K_5N(GkrtIjfN%kXtR9I9oONSu5NL(03KlX#7fJ0ZUz=R_4*j#)ua?tJR$S|6+aRhiM}T4#oNq(7czvzMGg4y6LBzGTIjWrtk49@ zAlUHd`21eYj0K8V3u8f84l6b)3;VY+T6Kc9FrA)k1{E66;~Zu@X-m%L@HB>om)1ENY^Iursg;wl~9;qvaWDg>8_s{0*4lBb`g_ zS{Q$HOlF%%N%!PTT1V;51k`xcMgX!YN~KIn4`|)_DNBO-Oe|w89MFr4ymSWGN%Vk8 zRG4zQiY72`$*E-K4va16j9TIp*-QhLIvtD84x7bO~A$IbSgEU zNX|xSQX0RLLS5y@F|F2~vn1vda9ry`m2~4OD%E&S>aRls#9I>WBAS56NoV?~^b^6B zG-Y33Kkku7$o;u5w*Updl9$hw&^$F7U5HH^dNhV+)6poZp_%NG*HPDSBq}lKU-+_X z;QZFmo;tBSysrkHU3)RNUcFo2y_q?Jf$G)Xb$zpYFK}sjY(H4DIAxgFD?ryYiKNS=mwB)?IG~cZMc+<*7r5Q0{y%Zr4D!*8(``>B}O! z-1J=|X2fsn=2B-0nMk4a(b_CgPi9QZ8&4%?60@D;NMqA6a)9(N!V=O82(g!#^;6vA sUwObx!Hl6_M7KyVy#(oAB8LJ1;2W3t!ajJ*6Y@3z=VSPrBR{OXe-2Ok delta 1007 zcmZvaJ#5oJ6vutGLhO3q=Y2&15+qMY6X-EB)t3rhM-_;m1rRf7?zo2p!kG= zlGSCY3tgmO=c@6|eL<(OGL8*f(Hi%U##VSUvO_ke90=!}YGWFiVB3gc;>L+2h!{k= zQJkF-bYgW>Vh@JX)M6=Bbpzy4jV;aArB>}Q%UHY>!YV_#25=l+Fw_!&OM z4cJ{zwpt#bv}X?;q_lSr9ilXaX^5?-(XEiN+Sj@z+jdKn2JVBIww4ZIw)-~w|C=>T zv$7pcA706s`9M7DIQPnncLJ%{G~S%a1(78eWrXoq4!`3=$@eVR8!1B*xC6)$j8g*` z2O_j?&-?cB4w*bxBSPNDWZfa+NsP%+A4txkzXa!(Eu8;Jm;u8`7ms6|7OzET%_DA- zB=ivJMS?V*3`BC|%`yx`*KsP?xB~u*-icXsLz-)jO$=>HQ$R&P?gt7Ql-DvTQi0A< zA90yoA-#DYQA3^>+0rhoO*XW|vnwwbHd0?QqnmycB~t4rHmt40=-TCmkzSwL@VAVy zx0k}#f%a&+S(MZ>7%Q6y9C$t~XxqdgIvnPaHRC66eQh(}a>XAwQ)7B8Z?ADp^8ilbN`i+A+-9 zQB#CmB!m#BMx!3!lw+g#fRsy*NN_+Pp-8EOx+;pqrRpt+!l7K48UL-TWTg4#%{Sk^ zH}l@?eAoZgp#H0-`8d)Fe+*g+#j@o5Ku!lTM#A7ZfqR1k-Y+?zz(%OA>(ps|+Y?LkM38A4h}H*T&k#eL5fgflUCCP(c z47g8H))=R1LZ|sDxEzH~6S*|aCu>|=ZaG;|x8>HVCQx^-qKY@g$!P6tT~r9W$%!S` zCL*{;3WsZ1>S3xV%^dz$jHtDhUi`5XcFp_fe7LrLw!9H9XxigTb*RgKKi-kTlQsQ7 zRUfSCCD-8xT|4U^8yeLO`72jm|jcgS{)jK{;R2M)P z@Wtk;w+Y6zKsCh^zO{N^Cd>R-iZ8ZR)vuJ9l$Gd9Ut0sU> z&;qxU*2ZSmT7^kFXXf&@BbUb(_Zp2}5qR0e0#3-S0C|`MW)3<^#afPCMy(_qYNOCj z18ev z8ih*)91$_kq_WiW%ekayLlkClcjJF5OER6t!niLuRY0YB*q^4&^y*ER*@|aTrsSLk zbVRt3&p3hA?8cgvij#|Bu9zf}LDxKX4k1=)c2*XtzLGDL=}Z>n)9^L)8To>~LiSYx p#~z;Nk2vdhiFD4wPl7$2W4!z|c+QdG`3fK4`|tJr!;yg%>VKWfikkoc delta 1650 zcma)6-)kII6rMXfJ3HCgnayPOSJHHoYTaa$x`}C86B=z(NJCpJQUc0?#@)Ts?bO{J zytA8{zQlrxFJj{bq2eE4*?lP~=zEI~3W8XX&WjX8DZW&QFZRWI?rvt16449u&AH$G z?m6e4Gj~25|2(IEr)g1vjbC3MwWs5odIsIu9Glue0v51Xi`Lbm%I#1sR*w~9+?Hxu zQNv*@*YtY47)OFAEDJdDzJL|0-_ow=;BfC~OWk?V;MzW>jau<`sK*X)R%L9Avjdzj zn(I0pgRaUE6a`;W=H%%GPV9T;`L$Q z0#pAfnMk4G@W}a~NYD0sN3gM5yHl>>;s02Fj!uP>lYzgSuMc8}cQs{z&I#r{62n!?SG3i&RQ(rG%{u`FWCN1jzsl(s%pLyx>J3#Hx*n45S8nxz(&RoU2~c zcHNe>ic3w)Q*h6szx6o=zoT%4J=txRnk{#$5}iRPMn6+$vP0mAU)^D_vO56Tx}VY~ z>O$(iasIt?5A?*1*KS_^&$We*#VPm&|-MV<$kq}%t(XWcEB&eU1sn&gb?~gDE};!e-g^{TXQr! Zj^08a;cWrl+bbxJayJhDC9oH7%|9T$Mu`9b diff --git a/Backend/src/models/__pycache__/payment.cpython-312.pyc b/Backend/src/models/__pycache__/payment.cpython-312.pyc index f30adc218207b7ce00757ab93449a5f989562d7a..9b7a4f2928719243ebf3d4621ca962d260b736dd 100644 GIT binary patch delta 1172 zcmZ8g&ui2`6wV~sY?9q1-Tv6RRa?9LnW~l2u8K(YAZS%86j6x6HtnR_)nr#ES!hpH z5b-3G`3to-MNAFI*7uBJ?2WK|uv^-o)10K)$^B=9}-mnR$7V{oH9hHw;~1 z&&vDZ@~>4RhrX{4?LQf4j)nv*VA0oul#}AI!f62~ z?+94+T0CR*{$4A?DUDJ3Ce_NR6r<9AseBu!3`UurRhOU?L2%laW!u_-(AMYxI+biD zrYhK5qGj}hmc+bBa`divR>?30$u=jSM=YoN&#$9A|NQ*!(_hVYJq=<@G4O;jNGUeHq9O5uI-w+YV~@# zQrgt&o8vN`wgtO=()5MeJ)Yvri^T}epy-lPSS;>rrvs1&09rUaOm%HoY0O-DmAOm{ z+NZHw7rv^3W;V=xqkrP1T6m`#4Rhq-`NpxcFV%Bj5>i&VJso#BnAjcE)+bg-oH@Md zyZ-#57c7rfNkGz&%3#oxy-F=;TJzP)Lb)``4%(e_LyuT6CxsE=8;>{2x5$h9|3CPt v9uKOx=6jRmB3lM+8(wAbMM6kNAB5Sr!t85dmVVSvSy`mq!`}q<;g$XYL%IV1 delta 958 zcmZ8fPiWIn7=JHG)BI`D#Jbhmt?M7>7KSp!qRinggH9$YUSv@m-Co#+Ni);*(A$PP zcrDL^c=jSKc+;E5T|6o{3L;*Ffx-|x=s|s7qIK>;e)+y%e&6r=^4|NBeKD-P)-(mN z75pr3%l@X04x}$e_=#G$`X$w|*A3{r_7M`ae{w|m-r7l;Y zkGPs>up}_(NA7|+8ixgbDlK^>u5DjH? zo{`_a603`}z~|@zfBWJQ|I^?w0Wb+*0UT*E4%|3>AXJp7Gvuosa|oyCt}rL`#4bh9|Hp;DL=vOQE_AW0;s1Zq%31F?+WtF6rJ)}5KsUR*(< zN0))1x9BBd_o1MN{(yp@9tfXB_=!N;+bAIP{&pl`Ed2Jk+#%IIO z09k84hKoO|Mj!XQdhX1cC)+XyAb`B9c&e>3TW~c`vo&Umu5Rm4f(f_7Gi-wcJeUMf z-T+WRCeoJ`n)CRoi4EUQGObFq8Zv4^lkHp;E&UgDEV=seq8v-pC5Tq4f8Xkzgv2Qd zUC}#kI-jH_1(|$44QzltGh3P5OZHW!2&_-xY)i9HO{KY~(KUX24)XOxy9$K|NJJ7r z8Ok+|$jKrd9o47}4V3Cijc+Jyjd7&+x)OciFLP?d3l^tEBf|JSy`4QxR2kR-xw_Fl znPr@LjO!&_A56l|mWuf)*hQG;pIPMX+-kSg6TK3y&+)NR3SHNkcM(2CDte;ea@q6q zMQCxjpAZ($wpfN}(Fxsv_ydG)!~|x?O1Tt1fKGsLe`JY+H{w(gt`snOR78&pe&7V5 zA19i7=17QM)r!l^DZ&}+1n#C6^iW7gcg4Jfhlw3+iYL?TrP%Wg(J-9~tVbWjvlDda zVlng)jzyXZtCTPcg@E=}aPX2}DBuh|z@D;~TDcZ<5X1i-Wu@WVr-^Uo&C2Ay*|T}) z#qw5e=k%rbAtR&PQgER%@mm3^xor-<9Nkey_7!8>9NJRf5NGUA5;BSPnZ^Y)x0Q-^ zrSogeTKtEu<1XAs-fF&#JxnXcI)PY3rO=C$3+2*caVbxJq%-f3MhqGg`~b7rY!rLE wfq%-FSB9aBuHq|1Ooz@NC2%Nk9CrX_cfsseFdMBT29hZ*@$A+gKsHw1e})CuWB>pF delta 659 zcmY+AJ7^R^7{_OJ_WjIh?k?x!j1SOFaS9rQ7))V8B8Zh>SOi%!BVL%z>g;ZDRtkb* zQ?Y^dr@r>)xjQyoBsdI zu42JHHZ=<4h;O4Si?FdbDoWEklsvRqo@d2ih+~cQMa9O#Ly?@3RqS)fEjWeAu;nJh!Z{g&No3Ga29{SwB@U9Yw?;2(J zc=2zpOp7P@Q*)-(J5QXo+vmQLYM5rC;pVL~xY2Cjx1Sq;K$vnW}x;iwkM zfyL0BCJ#}Ws*vS0*_2scQ3=6^I24%#t14xduU7`DQb`d`VO%2ub7Yh%SWGlU3`tB4 zEGC{JmLjo65*{4tU{R?Q=@gkYvT#uisHj|uJd&s;SX2SURIPfjpdy;F+F(&76s0;~ zG36AM6jdZcb)lkaDe6d~dSFovpsB1t=W4EIfyR_RSX?VbJ4FYo+8-zrWdIh|#ZYbt z7S~JB2RqLIZj2FF+%Uxm7;46FUmJr&CqHBn7B>OQo28he$eT{)X6@!F0;QUlpyXD> zyLl076%(V`WLb{OnxbimnK`K`@j3bFnR)SvB_*l31trBroIr)QcyjWSvw?D@c_o=S zliza~ZdT>&VPu>$c@x)dDOu$O<{Lr}I9`!)yTBrOQ&@6_#)^Xr#3O delta 678 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 ChQzS| delta 6376 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 x4aqLm5sGjVLB2~|T^x6Ob delta 766 zcmYk4PfXKL0LI^IyLMgIvaS0AjKG#f*+5`0j2b~SqFum)1`ZlS4APN|z(z{V6g&K% zkmyC7CcwhcgNc_-O`LG?y&=RV{ob$N``-83m(C=X$D}n; zv;cg4z3hvx`QJ*O2IyNl+|WfKv#*Y3RV&&$;x%yw9H~RaM)_Gihg?lICT%v%sIIZm zE-p~>6e$4UJ~X%yKl)&CBC+XYpJbRa;02ZD^}yad00x+cdz`lDhYnocukr}T|Pk*?rWAuI?u zlXXLdW3rwQ<{W@g3FaUqOhcT>naawQ9iCOZT&@Bt4r|WF5%zE-S?h=j4muZhM_GE6 zHAMw_RiL91Ium)sKEUUt-9sNE^EKr7UIUH%P?{ghj=5tjsoofkozgN(sC|G6Rl$zA bQ~}^&ak4zDU%mE&JpQBs)dPzZ#`5~VBdNn0 diff --git a/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc index 418b1b2bbbdb9bac0004f347c5d2643df3418706..879a25c9b8ca6ddfacbf9b158897b64b94fc5523 100644 GIT binary patch delta 21957 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 delta 19977 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 delta 7104 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 Pu4vzsJ@MW?(&f-Ut!jjD delta 576 zcmeySut|aUG%qg~0}wokP{>S~$ScWcFj2j;o{1rqCrb|`jtWvGRx?6Gq9lQ87*lvs zc+(lz@U3Qoib^ptxHF{iw=kp#q;jlghKfq32&S^72&KxTv7`vMutdqGN@dxinhfHl zh(Jx(WdIt^l*$LQER8WmG!<+J&;YR(*40qGQSz0nn&Oi)7#B=dV*1Z0HCc%{j7t`pvA`TR}#h;v?mzJ5XSDac>l9`uYT%-vW4fYKQ zh<6L}_l(uVkyWkE&&@+qyQ2G m2N%e$UmP~M`6;D2sdh!tKrSfSi|0)~%da{4Cx0aiNEraoa(UnY diff --git a/Backend/src/routes/__pycache__/invoice_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/invoice_routes.cpython-312.pyc index 009ca470b498a20abee686fecbae294fd81417a9..d897a5595a343aba805aaef7ef35bcd824d6035d 100644 GIT binary patch literal 12091 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 R5UWUK{R0jl@*eQye*?H0M_>Q| literal 10581 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 OuVv3O)FLi`d;AZwiv|<` diff --git a/Backend/src/routes/__pycache__/page_content_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/page_content_routes.cpython-312.pyc index c767158dfd3a024a52ea44670c5127c8f5461146..6864a7a5bcb5878316211fff21e6e12bdb5099f0 100644 GIT binary patch delta 9424 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 diff --git a/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc index 6943c26430e5dc22a91132472c9807520902e379..c8b8e0d36c2ad76c5880bddab0532fdc3cc864d7 100644 GIT binary patch delta 18389 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 VQ8uK4(9rIu|70gBHJIJp{{>b45?cTO delta 14893 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 literal 10683 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 diff --git a/Backend/src/routes/__pycache__/room_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/room_routes.cpython-312.pyc index 3b3b020e272ffc88a8d02b2807663b98dbb569fc..7928a89eaad0c333c937d48655396b4872d63537 100644 GIT binary patch delta 8277 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 delta 7444 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 delta 5338 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 diff --git a/Backend/src/routes/advanced_room_routes.py b/Backend/src/routes/advanced_room_routes.py index eca434f6..d9ceee4d 100644 --- a/Backend/src/routes/advanced_room_routes.py +++ b/Backend/src/routes/advanced_room_routes.py @@ -4,6 +4,7 @@ from sqlalchemy import and_, or_, func, desc from typing import List, Optional from datetime import datetime, timedelta from ..config.database import get_db +from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.role import Role @@ -17,6 +18,7 @@ from ..services.room_assignment_service import RoomAssignmentService from pydantic import BaseModel from typing import Dict, Any +logger = get_logger(__name__) router = APIRouter(prefix='/advanced-rooms', tags=['advanced-room-management']) @@ -468,9 +470,9 @@ async def create_housekeeping_task( try: await manager.staff_connections[assigned_to].send_json(notification_data) except Exception as e: - print(f'Error sending housekeeping task notification to staff {assigned_to}: {e}') + logger.error(f'Error sending housekeeping task notification to staff {assigned_to}: {str(e)}', exc_info=True, extra={'staff_id': assigned_to}) except Exception as e: - print(f'Error setting up housekeeping task notification: {e}') + logger.error(f'Error setting up housekeeping task notification: {str(e)}', exc_info=True) return { 'status': 'success', @@ -577,9 +579,9 @@ async def update_housekeeping_task( try: await manager.staff_connections[task.assigned_to].send_json(notification_data) except Exception as e: - print(f'Error sending housekeeping task notification to staff {task.assigned_to}: {e}') + logger.error(f'Error sending housekeeping task notification to staff {task.assigned_to}: {str(e)}', exc_info=True, extra={'staff_id': task.assigned_to}) except Exception as e: - print(f'Error setting up housekeeping task notification: {e}') + logger.error(f'Error setting up housekeeping task notification: {str(e)}', exc_info=True) return { 'status': 'success', diff --git a/Backend/src/routes/auth_routes.py b/Backend/src/routes/auth_routes.py index 0e8ba0a0..f2e2e3b6 100644 --- a/Backend/src/routes/auth_routes.py +++ b/Backend/src/routes/auth_routes.py @@ -10,8 +10,29 @@ from ..services.auth_service import auth_service from ..schemas.auth import RegisterRequest, LoginRequest, RefreshTokenRequest, ForgotPasswordRequest, ResetPasswordRequest, AuthResponse, TokenResponse, MessageResponse, MFAInitResponse, EnableMFARequest, VerifyMFARequest, MFAStatusResponse from ..middleware.auth import get_current_user from ..models.user import User +from ..services.audit_service import audit_service +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded + router = APIRouter(prefix='/auth', tags=['auth']) +# Stricter rate limits for authentication endpoints +AUTH_RATE_LIMIT = "5/minute" # 5 attempts per minute per IP +PASSWORD_RESET_LIMIT = "3/hour" # 3 password reset requests per hour per IP +LOGIN_RATE_LIMIT = "10/minute" # 10 login attempts per minute per IP + +def get_limiter(request: Request) -> Limiter: + """Get limiter instance from app state.""" + return request.app.state.limiter if hasattr(request.app.state, 'limiter') else None + +def apply_rate_limit(func, limit_value: str): + """Helper to apply rate limiting decorator if limiter is available.""" + def decorator(*args, **kwargs): + # This will be applied at runtime when route is called + return func(*args, **kwargs) + return decorator + def get_base_url(request: Request) -> str: return os.getenv('SERVER_URL') or f'http://{request.headers.get('host', 'localhost:8000')}' @@ -25,27 +46,133 @@ def normalize_image_url(image_url: str, base_url: str) -> str: return f'{base_url}/{image_url}' @router.post('/register', status_code=status.HTTP_201_CREATED) -async def register(request: RegisterRequest, response: Response, db: Session=Depends(get_db)): +async def register( + request: Request, + register_request: RegisterRequest, + response: Response, + db: Session=Depends(get_db) +): + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + try: - result = await auth_service.register(db=db, name=request.name, email=request.email, password=request.password, phone=request.phone) - response.set_cookie(key='refreshToken', value=result['refreshToken'], httponly=True, secure=False, samesite='strict', max_age=7 * 24 * 60 * 60, path='/') + result = await auth_service.register(db=db, name=register_request.name, email=register_request.email, password=register_request.password, phone=register_request.phone) + from ..config.settings import settings + # Use secure cookies in production (HTTPS required) + response.set_cookie( + key='refreshToken', + value=result['refreshToken'], + httponly=True, + secure=settings.is_production, # Secure flag enabled in production + samesite='strict', + max_age=7 * 24 * 60 * 60, + path='/' + ) + + # Log successful registration + await audit_service.log_action( + db=db, + action='user_registered', + resource_type='user', + user_id=result['user']['id'], + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'email': register_request.email, 'name': register_request.name}, + status='success' + ) + return {'status': 'success', 'message': 'Registration successful', 'data': {'token': result['token'], 'user': result['user']}} except ValueError as e: error_message = str(e) + # Log failed registration attempt + await audit_service.log_action( + db=db, + action='user_registration_failed', + resource_type='user', + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'email': register_request.email, 'name': register_request.name}, + status='failed', + error_message=error_message + ) return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': error_message}) @router.post('/login') -async def login(request: LoginRequest, response: Response, db: Session=Depends(get_db)): +async def login( + request: Request, + login_request: LoginRequest, + response: Response, + db: Session=Depends(get_db) +): + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + try: - result = await auth_service.login(db=db, email=request.email, password=request.password, remember_me=request.rememberMe or False, mfa_token=request.mfaToken) + result = await auth_service.login(db=db, email=login_request.email, password=login_request.password, remember_me=login_request.rememberMe or False, mfa_token=login_request.mfaToken) if result.get('requires_mfa'): + # Log MFA required + user = db.query(User).filter(User.email == login_request.email.lower().strip()).first() + if user: + await audit_service.log_action( + db=db, + action='login_mfa_required', + resource_type='authentication', + user_id=user.id, + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'email': login_request.email}, + status='success' + ) return {'status': 'success', 'requires_mfa': True, 'user_id': result['user_id']} - max_age = 7 * 24 * 60 * 60 if request.rememberMe else 1 * 24 * 60 * 60 - response.set_cookie(key='refreshToken', value=result['refreshToken'], httponly=True, secure=False, samesite='strict', max_age=max_age, path='/') + from ..config.settings import settings + max_age = 7 * 24 * 60 * 60 if login_request.rememberMe else 1 * 24 * 60 * 60 + # Use secure cookies in production (HTTPS required) + response.set_cookie( + key='refreshToken', + value=result['refreshToken'], + httponly=True, + secure=settings.is_production, # Secure flag enabled in production + samesite='strict', + max_age=max_age, + path='/' + ) + + # Log successful login + await audit_service.log_action( + db=db, + action='login_success', + resource_type='authentication', + user_id=result['user']['id'], + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'email': login_request.email, 'remember_me': login_request.rememberMe}, + status='success' + ) + return {'status': 'success', 'data': {'token': result['token'], 'user': result['user']}} except ValueError as e: error_message = str(e) status_code = status.HTTP_401_UNAUTHORIZED if 'Invalid email or password' in error_message or 'Invalid MFA token' in error_message else status.HTTP_400_BAD_REQUEST + + # Log failed login attempt + await audit_service.log_action( + db=db, + action='login_failed', + resource_type='authentication', + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'email': login_request.email}, + status='failed', + error_message=error_message + ) + return JSONResponse(status_code=status_code, content={'status': 'error', 'message': error_message}) @router.post('/refresh-token', response_model=TokenResponse) @@ -59,10 +186,34 @@ async def refresh_token(refreshToken: str=Cookie(None), db: Session=Depends(get_ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e)) @router.post('/logout', response_model=MessageResponse) -async def logout(response: Response, refreshToken: str=Cookie(None), db: Session=Depends(get_db)): +async def logout( + request: Request, + response: Response, + refreshToken: str=Cookie(None), + current_user: User=Depends(get_current_user), + db: Session=Depends(get_db) +): + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + if refreshToken: await auth_service.logout(db, refreshToken) response.delete_cookie(key='refreshToken', path='/') + + # Log logout + await audit_service.log_action( + db=db, + action='logout', + resource_type='authentication', + user_id=current_user.id, + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'email': current_user.email}, + status='success' + ) + return {'status': 'success', 'message': 'Logout successful'} @router.get('/profile') @@ -164,11 +315,12 @@ async def regenerate_backup_codes(current_user: User=Depends(get_current_user), @router.post('/avatar/upload') async def upload_avatar(request: Request, image: UploadFile=File(...), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - if not image.content_type or not image.content_type.startswith('image/'): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='File must be an image') - content = await image.read() - if len(content) > 2 * 1024 * 1024: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Avatar file size must be less than 2MB') + # Use comprehensive file validation (magic bytes + size) + from ..utils.file_validation import validate_uploaded_image + max_avatar_size = 2 * 1024 * 1024 # 2MB for avatars + + # Validate file completely (MIME type, size, magic bytes, integrity) + content = await validate_uploaded_image(image, max_avatar_size) upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'avatars' upload_dir.mkdir(parents=True, exist_ok=True) if current_user.avatar: diff --git a/Backend/src/routes/banner_routes.py b/Backend/src/routes/banner_routes.py index 400940e2..92fb4d9d 100644 --- a/Backend/src/routes/banner_routes.py +++ b/Backend/src/routes/banner_routes.py @@ -135,10 +135,15 @@ async def upload_banner_image(request: Request, image: UploadFile=File(...), cur ext = Path(image.filename).suffix or '.jpg' filename = f'banner-{uuid.uuid4()}{ext}' file_path = upload_dir / filename + # Use comprehensive file validation (magic bytes + size) + from ..config.settings import settings + from ..utils.file_validation import validate_uploaded_image + max_size = settings.MAX_UPLOAD_SIZE + + # Validate file completely (MIME type, size, magic bytes, integrity) + content = await validate_uploaded_image(image, max_size) + async with aiofiles.open(file_path, 'wb') as f: - content = await image.read() - if not content: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='File is empty') await f.write(content) image_url = f'/uploads/banners/{filename}' base_url = get_base_url(request) diff --git a/Backend/src/routes/booking_routes.py b/Backend/src/routes/booking_routes.py index 32593540..c2d00b62 100644 --- a/Backend/src/routes/booking_routes.py +++ b/Backend/src/routes/booking_routes.py @@ -24,6 +24,7 @@ from ..utils.email_templates import booking_confirmation_email_template, booking from ..services.loyalty_service import LoyaltyService from ..utils.currency_helpers import get_currency_symbol from ..utils.response_helpers import success_response +from ..schemas.booking import CreateBookingRequest, UpdateBookingRequest router = APIRouter(prefix='/bookings', tags=['bookings']) def _generate_invoice_email_html(invoice: dict, is_proforma: bool=False) -> str: @@ -159,47 +160,45 @@ async def get_my_bookings(request: Request, current_user: User=Depends(get_curre booking_dict['payments'] = [] result.append(booking_dict) return success_response(data={'bookings': result}) + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + db.rollback() + import logging + logger = logging.getLogger(__name__) + request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None + logger.error(f'Error in get_my_bookings: {str(e)}', extra={'request_id': request_id, 'user_id': current_user.id}, exc_info=True) + raise HTTPException(status_code=500, detail='An error occurred while fetching bookings') @router.post('/') -async def create_booking(booking_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def create_booking(booking_data: CreateBookingRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): + """Create a new booking with validated input using Pydantic schema.""" role = db.query(Role).filter(Role.id == current_user.role_id).first() if role and role.name in ['admin', 'staff', 'accountant']: raise HTTPException(status_code=403, detail='Admin, staff, and accountant users cannot create bookings') try: import logging logger = logging.getLogger(__name__) - if not isinstance(booking_data, dict): - logger.error(f'Invalid booking_data type: {type(booking_data)}, value: {booking_data}') - raise HTTPException(status_code=400, detail='Invalid request body. Expected JSON object.') - logger.info(f'Received booking request from user {current_user.id}: {booking_data}') - room_id = booking_data.get('room_id') - check_in_date = booking_data.get('check_in_date') - check_out_date = booking_data.get('check_out_date') - total_price = booking_data.get('total_price') - guest_count = booking_data.get('guest_count', 1) - notes = booking_data.get('notes') - payment_method = booking_data.get('payment_method', 'cash') - promotion_code = booking_data.get('promotion_code') - referral_code = booking_data.get('referral_code') - invoice_info = booking_data.get('invoice_info', {}) - missing_fields = [] - if not room_id: - missing_fields.append('room_id') - if not check_in_date: - missing_fields.append('check_in_date') - if not check_out_date: - missing_fields.append('check_out_date') - if total_price is None: - missing_fields.append('total_price') - if missing_fields: - error_msg = f'Missing required booking fields: {', '.join(missing_fields)}' - logger.error(error_msg) - raise HTTPException(status_code=400, detail=error_msg) + logger.info(f'Received booking request from user {current_user.id}: {booking_data.dict()}') + + # Extract validated data from Pydantic model + room_id = booking_data.room_id + check_in_date = booking_data.check_in_date + check_out_date = booking_data.check_out_date + total_price = booking_data.total_price + guest_count = booking_data.guest_count + notes = booking_data.notes + payment_method = booking_data.payment_method + promotion_code = booking_data.promotion_code + referral_code = booking_data.referral_code + services = booking_data.services or [] + invoice_info = booking_data.invoice_info.dict() if booking_data.invoice_info else {} + room = db.query(Room).filter(Room.id == room_id).first() if not room: raise HTTPException(status_code=404, detail='Room not found') + + # Parse dates (schema validation already ensures format is valid) if 'T' in check_in_date or 'Z' in check_in_date or '+' in check_in_date: check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00')) else: @@ -208,6 +207,8 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00')) else: check_out = datetime.strptime(check_out_date, '%Y-%m-%d') + + # Date validation already done in schema, but keeping as safety check if check_in >= check_out: raise HTTPException(status_code=400, detail='Check-out date must be after check-in date') overlapping = db.query(Booking).filter(and_(Booking.room_id == room_id, Booking.status != BookingStatus.cancelled, Booking.check_in_date < check_out, Booking.check_out_date > check_in)).first() @@ -247,18 +248,16 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr number_of_nights = 1 # Minimum 1 night room_total = room_price * number_of_nights - # Calculate services total if any - services = booking_data.get('services', []) + # Calculate services total if any (using Pydantic model) services_total = 0.0 if services: from ..models.service import Service for service_item in services: - service_id = service_item.get('service_id') - quantity = service_item.get('quantity', 1) - if service_id: - service = db.query(Service).filter(Service.id == service_id).first() - if service and service.is_active: - services_total += float(service.price) * quantity + service_id = service_item.service_id + quantity = service_item.quantity + service = db.query(Service).filter(Service.id == service_id).first() + if service and service.is_active: + services_total += float(service.price) * quantity original_price = room_total + services_total @@ -358,14 +357,12 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr db.add(deposit_payment) db.flush() logger.info(f'Deposit payment created: ID={deposit_payment.id}, amount={deposit_amount}, percentage={deposit_percentage}%') - services = booking_data.get('services', []) + # Add service usages (services already extracted from Pydantic model) if services: from ..models.service import Service for service_item in services: - service_id = service_item.get('service_id') - quantity = service_item.get('quantity', 1) - if not service_id: - continue + service_id = service_item.service_id + quantity = service_item.quantity service = db.query(Service).filter(Service.id == service_id).first() if not service or not service.is_active: continue @@ -559,7 +556,12 @@ async def get_booking_by_id(id: int, request: Request, current_user: User=Depend except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + db.rollback() + import logging + logger = logging.getLogger(__name__) + request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None + logger.error(f'Error in get_booking_by_id: {str(e)}', extra={'request_id': request_id, 'booking_id': id, 'user_id': current_user.id}, exc_info=True) + raise HTTPException(status_code=500, detail='An error occurred while fetching booking') @router.patch('/{id}/cancel') async def cancel_booking(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): @@ -645,7 +647,7 @@ async def cancel_booking(id: int, current_user: User=Depends(get_current_user), raise HTTPException(status_code=500, detail=str(e)) @router.put('/{id}', dependencies=[Depends(authorize_roles('admin', 'staff'))]) -async def update_booking(id: int, booking_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def update_booking(id: int, booking_data: UpdateBookingRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: booking = db.query(Booking).options( selectinload(Booking.payments), @@ -654,7 +656,7 @@ async def update_booking(id: int, booking_data: dict, current_user: User=Depends if not booking: raise HTTPException(status_code=404, detail='Booking not found') old_status = booking.status - status_value = booking_data.get('status') + status_value = booking_data.status room = booking.room new_status = None if status_value: @@ -723,6 +725,29 @@ async def update_booking(id: int, booking_data: dict, current_user: User=Depends room.status = RoomStatus.available except ValueError: raise HTTPException(status_code=400, detail='Invalid status') + + # Update other fields from schema if provided + if booking_data.check_in_date is not None: + if 'T' in booking_data.check_in_date or 'Z' in booking_data.check_in_date or '+' in booking_data.check_in_date: + booking.check_in_date = datetime.fromisoformat(booking_data.check_in_date.replace('Z', '+00:00')) + else: + booking.check_in_date = datetime.strptime(booking_data.check_in_date, '%Y-%m-%d') + + if booking_data.check_out_date is not None: + if 'T' in booking_data.check_out_date or 'Z' in booking_data.check_out_date or '+' in booking_data.check_out_date: + booking.check_out_date = datetime.fromisoformat(booking_data.check_out_date.replace('Z', '+00:00')) + else: + booking.check_out_date = datetime.strptime(booking_data.check_out_date, '%Y-%m-%d') + + if booking_data.guest_count is not None: + booking.num_guests = booking_data.guest_count + + if booking_data.notes is not None: + booking.special_requests = booking_data.notes + + if booking_data.total_price is not None: + booking.total_price = booking_data.total_price + db.commit() # Send booking confirmation notification if status changed to confirmed @@ -834,10 +859,11 @@ async def update_booking(id: int, booking_data: dict, current_user: User=Depends import logging import traceback logger = logging.getLogger(__name__) - logger.error(f'Error updating booking {id}: {str(e)}') + request_id = getattr(current_user, 'request_id', None) if hasattr(current_user, 'request_id') else None + logger.error(f'Error updating booking {id}: {str(e)}', extra={'request_id': request_id, 'booking_id': id, 'user_id': current_user.id}, exc_info=True) logger.error(traceback.format_exc()) db.rollback() - raise HTTPException(status_code=500, detail=f'Failed to update booking: {str(e)}') + raise HTTPException(status_code=500, detail='An error occurred while updating booking') @router.get('/check/{booking_number}') async def check_booking_by_number(booking_number: str, db: Session=Depends(get_db)): diff --git a/Backend/src/routes/chat_routes.py b/Backend/src/routes/chat_routes.py index 57dd4433..e4b33a87 100644 --- a/Backend/src/routes/chat_routes.py +++ b/Backend/src/routes/chat_routes.py @@ -5,10 +5,13 @@ from typing import List, Optional from datetime import datetime import json from ..config.database import get_db +from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, get_current_user_optional from ..models.user import User from ..models.chat import Chat, ChatMessage, ChatStatus from ..models.role import Role + +logger = get_logger(__name__) router = APIRouter(prefix='/chat', tags=['chat']) class ConnectionManager: @@ -41,7 +44,7 @@ class ConnectionManager: try: await websocket.send_json(message) except Exception as e: - print(f'Error sending message: {e}') + logger.error(f'Error sending message: {str(e)}', exc_info=True) async def broadcast_to_chat(self, message: dict, chat_id: int): if chat_id in self.active_connections: @@ -50,7 +53,7 @@ class ConnectionManager: try: await connection.send_json(message) except Exception as e: - print(f'Error broadcasting to connection: {e}') + logger.error(f'Error broadcasting to connection: {str(e)}', exc_info=True, extra={'chat_id': chat_id}) disconnected.append(connection) for conn in disconnected: self.active_connections[chat_id].remove(conn) @@ -61,7 +64,7 @@ class ConnectionManager: try: await websocket.send_json({'type': 'new_chat', 'data': chat_data}) except Exception as e: - print(f'Error notifying staff {user_id}: {e}') + logger.error(f'Error notifying staff {user_id}: {str(e)}', exc_info=True, extra={'staff_id': user_id}) disconnected.append(user_id) for user_id in disconnected: del self.staff_connections[user_id] @@ -74,7 +77,7 @@ class ConnectionManager: try: await websocket.send_json(notification_data) except Exception as e: - print(f'Error notifying staff {user_id}: {e}') + logger.error(f'Error notifying staff {user_id}: {str(e)}', exc_info=True, extra={'staff_id': user_id, 'chat_id': chat_id}) disconnected.append(user_id) for user_id in disconnected: del self.staff_connections[user_id] @@ -296,16 +299,14 @@ async def websocket_staff_notifications(websocket: WebSocket): finally: db.close() except Exception as e: - print(f'WebSocket token verification error: {e}') - import traceback - traceback.print_exc() + logger.error(f'WebSocket token verification error: {str(e)}', exc_info=True) await websocket.close(code=1008, reason=f'Token verification failed: {str(e)}') return manager.connect_staff(current_user.id, websocket) try: await websocket.send_json({'type': 'connected', 'data': {'message': 'WebSocket connected'}}) except Exception as e: - print(f'Error sending initial message: {e}') + logger.error(f'Error sending initial message: {str(e)}', exc_info=True, extra={'user_id': current_user.id}) while True: try: data = await websocket.receive_text() @@ -316,17 +317,15 @@ async def websocket_staff_notifications(websocket: WebSocket): except json.JSONDecodeError: await websocket.send_json({'type': 'pong', 'data': 'pong'}) except WebSocketDisconnect: - print('WebSocket disconnected normally') + logger.info('WebSocket disconnected normally', extra={'user_id': current_user.id}) break except Exception as e: - print(f'WebSocket receive error: {e}') + logger.error(f'WebSocket receive error: {str(e)}', exc_info=True, extra={'user_id': current_user.id}) break except WebSocketDisconnect: - print('WebSocket disconnected') + logger.info('WebSocket disconnected') except Exception as e: - print(f'WebSocket error: {e}') - import traceback - traceback.print_exc() + logger.error(f'WebSocket error: {str(e)}', exc_info=True) finally: if current_user: try: diff --git a/Backend/src/routes/contact_routes.py b/Backend/src/routes/contact_routes.py index e03448d4..a4c64057 100644 --- a/Backend/src/routes/contact_routes.py +++ b/Backend/src/routes/contact_routes.py @@ -8,6 +8,7 @@ from ..models.user import User from ..models.role import Role from ..models.system_settings import SystemSettings from ..utils.mailer import send_email +from ..utils.html_sanitizer import sanitize_text_for_html logger = logging.getLogger(__name__) router = APIRouter(prefix='/contact', tags=['contact']) diff --git a/Backend/src/routes/invoice_routes.py b/Backend/src/routes/invoice_routes.py index 5e108db7..2300a5e1 100644 --- a/Backend/src/routes/invoice_routes.py +++ b/Backend/src/routes/invoice_routes.py @@ -1,8 +1,9 @@ -from fastapi import APIRouter, Depends, HTTPException, status, Query +from fastapi import APIRouter, Depends, HTTPException, status, Query, Request from sqlalchemy.orm import Session from typing import Optional from datetime import datetime from ..config.database import get_db +from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.invoice import Invoice, InvoiceStatus @@ -10,15 +11,25 @@ from ..models.booking import Booking from ..services.invoice_service import InvoiceService from ..utils.role_helpers import can_access_all_invoices, can_create_invoices from ..utils.response_helpers import success_response +from ..utils.request_helpers import get_request_id +from ..schemas.invoice import ( + CreateInvoiceRequest, + UpdateInvoiceRequest, + MarkInvoicePaidRequest +) + +logger = get_logger(__name__) router = APIRouter(prefix='/invoices', tags=['invoices']) @router.get('/') -async def get_invoices(booking_id: Optional[int]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def get_invoices(request: Request, booking_id: Optional[int]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: user_id = None if can_access_all_invoices(current_user, db) else current_user.id result = InvoiceService.get_invoices(db=db, user_id=user_id, booking_id=booking_id, status=status_filter, page=page, limit=limit) return success_response(data=result) except Exception as e: + db.rollback() + logger.error(f'Error fetching invoices: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get('/{id}') @@ -33,64 +44,96 @@ async def get_invoice_by_id(id: int, current_user: User=Depends(get_current_user except HTTPException: raise except Exception as e: + db.rollback() + logger.error(f'Error fetching invoice by id: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post('/') -async def create_invoice(invoice_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def create_invoice(request: Request, invoice_data: CreateInvoiceRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: if not can_create_invoices(current_user, db): raise HTTPException(status_code=403, detail='Forbidden') - booking_id = invoice_data.get('booking_id') - if not booking_id: - raise HTTPException(status_code=400, detail='booking_id is required') - try: - booking_id = int(booking_id) - except (ValueError, TypeError): - raise HTTPException(status_code=400, detail='booking_id must be a valid integer') + booking_id = invoice_data.booking_id booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: raise HTTPException(status_code=404, detail='Booking not found') - invoice_kwargs = {'company_name': invoice_data.get('company_name'), 'company_address': invoice_data.get('company_address'), 'company_phone': invoice_data.get('company_phone'), 'company_email': invoice_data.get('company_email'), 'company_tax_id': invoice_data.get('company_tax_id'), 'company_logo_url': invoice_data.get('company_logo_url'), 'customer_tax_id': invoice_data.get('customer_tax_id'), 'notes': invoice_data.get('notes'), 'terms_and_conditions': invoice_data.get('terms_and_conditions'), 'payment_instructions': invoice_data.get('payment_instructions')} + invoice_kwargs = { + 'company_name': invoice_data.company_name, + 'company_address': invoice_data.company_address, + 'company_phone': invoice_data.company_phone, + 'company_email': invoice_data.company_email, + 'company_tax_id': invoice_data.company_tax_id, + 'company_logo_url': invoice_data.company_logo_url, + 'customer_tax_id': invoice_data.customer_tax_id, + 'notes': invoice_data.notes, + 'terms_and_conditions': invoice_data.terms_and_conditions, + 'payment_instructions': invoice_data.payment_instructions + } invoice_notes = invoice_kwargs.get('notes', '') if booking.promotion_code: promotion_note = f'Promotion Code: {booking.promotion_code}' invoice_notes = f'{promotion_note}\n{invoice_notes}'.strip() if invoice_notes else promotion_note invoice_kwargs['notes'] = invoice_notes - invoice = InvoiceService.create_invoice_from_booking(booking_id=booking_id, db=db, created_by_id=current_user.id, tax_rate=invoice_data.get('tax_rate', 0.0), discount_amount=invoice_data.get('discount_amount', 0.0), due_days=invoice_data.get('due_days', 30), **invoice_kwargs) + request_id = get_request_id(request) + invoice = InvoiceService.create_invoice_from_booking( + booking_id=booking_id, + db=db, + created_by_id=current_user.id, + tax_rate=invoice_data.tax_rate, + discount_amount=invoice_data.discount_amount, + due_days=invoice_data.due_days, + request_id=request_id, + **invoice_kwargs + ) return success_response(data={'invoice': invoice}, message='Invoice created successfully') except HTTPException: raise except ValueError as e: + db.rollback() + logger.error(f'Error creating invoice: {str(e)}', exc_info=True) raise HTTPException(status_code=400, detail=str(e)) except Exception as e: + db.rollback() + logger.error(f'Error creating invoice: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.put('/{id}') -async def update_invoice(id: int, invoice_data: dict, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)): +async def update_invoice(request: Request, id: int, invoice_data: UpdateInvoiceRequest, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)): try: invoice = db.query(Invoice).filter(Invoice.id == id).first() if not invoice: raise HTTPException(status_code=404, detail='Invoice not found') - updated_invoice = InvoiceService.update_invoice(invoice_id=id, db=db, updated_by_id=current_user.id, **invoice_data) + request_id = get_request_id(request) + invoice_dict = invoice_data.model_dump(exclude_unset=True) + updated_invoice = InvoiceService.update_invoice(invoice_id=id, db=db, updated_by_id=current_user.id, request_id=request_id, **invoice_dict) return success_response(data={'invoice': updated_invoice}, message='Invoice updated successfully') except HTTPException: raise except ValueError as e: + db.rollback() + logger.error(f'Error updating invoice: {str(e)}', exc_info=True) raise HTTPException(status_code=400, detail=str(e)) except Exception as e: + db.rollback() + logger.error(f'Error updating invoice: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post('/{id}/mark-paid') -async def mark_invoice_as_paid(id: int, payment_data: dict, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)): +async def mark_invoice_as_paid(request: Request, id: int, payment_data: MarkInvoicePaidRequest, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)): try: - amount = payment_data.get('amount') - updated_invoice = InvoiceService.mark_invoice_as_paid(invoice_id=id, db=db, amount=amount, updated_by_id=current_user.id) + request_id = get_request_id(request) + amount = payment_data.amount + updated_invoice = InvoiceService.mark_invoice_as_paid(invoice_id=id, db=db, amount=amount, updated_by_id=current_user.id, request_id=request_id) return success_response(data={'invoice': updated_invoice}, message='Invoice marked as paid successfully') except HTTPException: raise except ValueError as e: + db.rollback() + logger.error(f'Error marking invoice as paid: {str(e)}', exc_info=True) raise HTTPException(status_code=400, detail=str(e)) except Exception as e: + db.rollback() + logger.error(f'Error marking invoice as paid: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.delete('/{id}') @@ -121,4 +164,6 @@ async def get_invoices_by_booking(booking_id: int, current_user: User=Depends(ge except HTTPException: raise except Exception as e: + db.rollback() + logger.error(f'Error fetching invoices by booking: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/page_content_routes.py b/Backend/src/routes/page_content_routes.py index 90136046..01a3fa73 100644 --- a/Backend/src/routes/page_content_routes.py +++ b/Backend/src/routes/page_content_routes.py @@ -12,6 +12,8 @@ from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.page_content import PageContent, PageType +from ..schemas.page_content import PageContentUpdateRequest +from ..utils.html_sanitizer import sanitize_html logger = get_logger(__name__) router = APIRouter(prefix='/page-content', tags=['page-content']) @@ -25,6 +27,8 @@ async def get_all_page_contents(db: Session=Depends(get_db)): result.append(content_dict) return {'status': 'success', 'data': {'page_contents': result}} except Exception as e: + db.rollback() + logger.error(f'Error fetching page contents: {str(e)}', exc_info=True) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching page contents: {str(e)}') def get_base_url(request: Request) -> str: @@ -58,11 +62,15 @@ async def upload_page_content_image(request: Request, image: UploadFile=File(... ext = Path(image.filename).suffix or '.jpg' filename = f'page-content-{uuid.uuid4()}{ext}' file_path = upload_dir / filename + # Use comprehensive file validation (magic bytes + size) + from ..config.settings import settings + from ..utils.file_validation import validate_uploaded_image + max_size = settings.MAX_UPLOAD_SIZE + + # Validate file completely (MIME type, size, magic bytes, integrity) + content = await validate_uploaded_image(image, max_size) + async with aiofiles.open(file_path, 'wb') as f: - content = await image.read() - if not content: - logger.error('Empty file uploaded') - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='File is empty') await f.write(content) logger.info(f'File saved successfully: {file_path}, size: {len(content)} bytes') image_url = f'/uploads/page-content/{filename}' @@ -86,12 +94,46 @@ async def get_page_content(page_type: PageType, db: Session=Depends(get_db)): content_dict = {'id': content.id, 'page_type': content.page_type.value, 'title': content.title, 'subtitle': content.subtitle, 'description': content.description, 'content': content.content, 'meta_title': content.meta_title, 'meta_description': content.meta_description, 'meta_keywords': content.meta_keywords, 'og_title': content.og_title, 'og_description': content.og_description, 'og_image': content.og_image, 'canonical_url': content.canonical_url, 'contact_info': json.loads(content.contact_info) if content.contact_info else None, 'map_url': content.map_url, 'social_links': json.loads(content.social_links) if content.social_links else None, 'footer_links': json.loads(content.footer_links) if content.footer_links else None, 'badges': json.loads(content.badges) if content.badges else None, 'copyright_text': content.copyright_text, 'hero_title': content.hero_title, 'hero_subtitle': content.hero_subtitle, 'hero_image': content.hero_image, 'story_content': content.story_content, 'values': json.loads(content.values) if content.values else None, 'features': json.loads(content.features) if content.features else None, 'about_hero_image': content.about_hero_image, 'mission': content.mission, 'vision': content.vision, 'team': json.loads(content.team) if content.team else None, 'timeline': json.loads(content.timeline) if content.timeline else None, 'achievements': json.loads(content.achievements) if content.achievements else None, 'amenities_section_title': content.amenities_section_title, 'amenities_section_subtitle': content.amenities_section_subtitle, 'amenities': json.loads(content.amenities) if content.amenities else None, 'testimonials_section_title': content.testimonials_section_title, 'testimonials_section_subtitle': content.testimonials_section_subtitle, 'testimonials': json.loads(content.testimonials) if content.testimonials else None, 'gallery_section_title': content.gallery_section_title, 'gallery_section_subtitle': content.gallery_section_subtitle, 'gallery_images': json.loads(content.gallery_images) if content.gallery_images else None, 'luxury_section_title': content.luxury_section_title, 'luxury_section_subtitle': content.luxury_section_subtitle, 'luxury_section_image': content.luxury_section_image, 'luxury_features': json.loads(content.luxury_features) if content.luxury_features else None, 'luxury_gallery_section_title': content.luxury_gallery_section_title, 'luxury_gallery_section_subtitle': content.luxury_gallery_section_subtitle, 'luxury_gallery': json.loads(content.luxury_gallery) if content.luxury_gallery else None, 'luxury_testimonials_section_title': content.luxury_testimonials_section_title, 'luxury_testimonials_section_subtitle': content.luxury_testimonials_section_subtitle, 'luxury_testimonials': json.loads(content.luxury_testimonials) if content.luxury_testimonials else None, 'about_preview_title': content.about_preview_title, 'about_preview_subtitle': content.about_preview_subtitle, 'about_preview_content': content.about_preview_content, 'about_preview_image': content.about_preview_image, 'stats': json.loads(content.stats) if content.stats else None, 'luxury_services_section_title': content.luxury_services_section_title, 'luxury_services_section_subtitle': content.luxury_services_section_subtitle, 'luxury_services': json.loads(content.luxury_services) if content.luxury_services else None, 'luxury_experiences_section_title': content.luxury_experiences_section_title, 'luxury_experiences_section_subtitle': content.luxury_experiences_section_subtitle, 'luxury_experiences': json.loads(content.luxury_experiences) if content.luxury_experiences else None, 'awards_section_title': content.awards_section_title, 'awards_section_subtitle': content.awards_section_subtitle, 'awards': json.loads(content.awards) if content.awards else None, 'cta_title': content.cta_title, 'cta_subtitle': content.cta_subtitle, 'cta_button_text': content.cta_button_text, 'cta_button_link': content.cta_button_link, 'cta_image': content.cta_image, 'partners_section_title': content.partners_section_title, 'partners_section_subtitle': content.partners_section_subtitle, 'partners': json.loads(content.partners) if content.partners else None, 'is_active': content.is_active, 'created_at': content.created_at.isoformat() if content.created_at else None, 'updated_at': content.updated_at.isoformat() if content.updated_at else None} return {'status': 'success', 'data': {'page_content': content_dict}} except Exception as e: + db.rollback() + logger.error(f'Error fetching page content: {str(e)}', exc_info=True) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching page content: {str(e)}') -@router.post('/{page_type}') -async def create_or_update_page_content(page_type: PageType, title: Optional[str]=None, subtitle: Optional[str]=None, description: Optional[str]=None, content: Optional[str]=None, meta_title: Optional[str]=None, meta_description: Optional[str]=None, meta_keywords: Optional[str]=None, og_title: Optional[str]=None, og_description: Optional[str]=None, og_image: Optional[str]=None, canonical_url: Optional[str]=None, contact_info: Optional[str]=None, map_url: Optional[str]=None, social_links: Optional[str]=None, footer_links: Optional[str]=None, badges: Optional[str]=None, hero_title: Optional[str]=None, hero_subtitle: Optional[str]=None, hero_image: Optional[str]=None, story_content: Optional[str]=None, values: Optional[str]=None, features: Optional[str]=None, about_hero_image: Optional[str]=None, mission: Optional[str]=None, vision: Optional[str]=None, team: Optional[str]=None, timeline: Optional[str]=None, achievements: Optional[str]=None, is_active: bool=True, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +@router.post('/{page_type}', dependencies=[Depends(authorize_roles('admin'))]) +async def create_or_update_page_content( + page_type: PageType, + title: Optional[str] = None, + subtitle: Optional[str] = None, + description: Optional[str] = None, + content: Optional[str] = None, + meta_title: Optional[str] = None, + meta_description: Optional[str] = None, + meta_keywords: Optional[str] = None, + og_title: Optional[str] = None, + og_description: Optional[str] = None, + og_image: Optional[str] = None, + canonical_url: Optional[str] = None, + contact_info: Optional[str] = None, + map_url: Optional[str] = None, + social_links: Optional[str] = None, + footer_links: Optional[str] = None, + badges: Optional[str] = None, + hero_title: Optional[str] = None, + hero_subtitle: Optional[str] = None, + hero_image: Optional[str] = None, + story_content: Optional[str] = None, + values: Optional[str] = None, + features: Optional[str] = None, + about_hero_image: Optional[str] = None, + mission: Optional[str] = None, + vision: Optional[str] = None, + team: Optional[str] = None, + timeline: Optional[str] = None, + achievements: Optional[str] = None, + is_active: bool = True, + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): try: - authorize_roles(current_user, ['admin']) if contact_info: try: json.loads(contact_info) @@ -125,13 +167,14 @@ async def create_or_update_page_content(page_type: PageType, title: Optional[str existing_content = db.query(PageContent).filter(PageContent.page_type == page_type).first() if existing_content: if title is not None: - existing_content.title = title + existing_content.title = sanitize_html(title) if subtitle is not None: - existing_content.subtitle = subtitle + existing_content.subtitle = sanitize_html(subtitle) if description is not None: - existing_content.description = description + existing_content.description = sanitize_html(description) if content is not None: - existing_content.content = content + # Sanitize HTML content to prevent XSS + existing_content.content = sanitize_html(content) if meta_title is not None: existing_content.meta_title = meta_title if meta_description is not None: @@ -157,29 +200,30 @@ async def create_or_update_page_content(page_type: PageType, title: Optional[str if badges is not None: existing_content.badges = badges if hero_title is not None: - existing_content.hero_title = hero_title + existing_content.hero_title = sanitize_html(hero_title) if hero_subtitle is not None: - existing_content.hero_subtitle = hero_subtitle + existing_content.hero_subtitle = sanitize_html(hero_subtitle) if hero_image is not None: existing_content.hero_image = hero_image if story_content is not None: - existing_content.story_content = story_content + # Sanitize HTML content to prevent XSS + existing_content.story_content = sanitize_html(story_content) if values is not None: - existing_content.values = values + existing_content.values = sanitize_html(values) if features is not None: - existing_content.features = features + existing_content.features = sanitize_html(features) if about_hero_image is not None: existing_content.about_hero_image = about_hero_image if mission is not None: - existing_content.mission = mission + existing_content.mission = sanitize_html(mission) if vision is not None: - existing_content.vision = vision + existing_content.vision = sanitize_html(vision) if team is not None: - existing_content.team = team + existing_content.team = sanitize_html(team) if timeline is not None: - existing_content.timeline = timeline + existing_content.timeline = sanitize_html(timeline) if achievements is not None: - existing_content.achievements = achievements + existing_content.achievements = sanitize_html(achievements) if is_active is not None: existing_content.is_active = is_active existing_content.updated_at = datetime.utcnow() @@ -187,7 +231,39 @@ async def create_or_update_page_content(page_type: PageType, title: Optional[str db.refresh(existing_content) return {'status': 'success', 'message': 'Page content updated successfully', 'data': {'page_content': {'id': existing_content.id, 'page_type': existing_content.page_type.value, 'title': existing_content.title, 'updated_at': existing_content.updated_at.isoformat() if existing_content.updated_at else None}}} else: - new_content = PageContent(page_type=page_type, title=title, subtitle=subtitle, description=description, content=content, meta_title=meta_title, meta_description=meta_description, meta_keywords=meta_keywords, og_title=og_title, og_description=og_description, og_image=og_image, canonical_url=canonical_url, contact_info=contact_info, map_url=map_url, social_links=social_links, footer_links=footer_links, badges=badges, hero_title=hero_title, hero_subtitle=hero_subtitle, hero_image=hero_image, story_content=story_content, values=values, features=features, about_hero_image=about_hero_image, mission=mission, vision=vision, team=team, timeline=timeline, achievements=achievements, is_active=is_active) + # Sanitize all HTML content fields before creating new content + new_content = PageContent( + page_type=page_type, + title=sanitize_html(title) if title else None, + subtitle=sanitize_html(subtitle) if subtitle else None, + description=sanitize_html(description) if description else None, + content=sanitize_html(content) if content else None, + meta_title=meta_title, + meta_description=meta_description, + meta_keywords=meta_keywords, + og_title=og_title, + og_description=og_description, + og_image=og_image, + canonical_url=canonical_url, + contact_info=contact_info, + map_url=map_url, + social_links=social_links, + footer_links=footer_links, + badges=badges, + hero_title=sanitize_html(hero_title) if hero_title else None, + hero_subtitle=sanitize_html(hero_subtitle) if hero_subtitle else None, + hero_image=hero_image, + story_content=sanitize_html(story_content) if story_content else None, + values=sanitize_html(values) if values else None, + features=sanitize_html(features) if features else None, + about_hero_image=about_hero_image, + mission=sanitize_html(mission) if mission else None, + vision=sanitize_html(vision) if vision else None, + team=sanitize_html(team) if team else None, + timeline=sanitize_html(timeline) if timeline else None, + achievements=sanitize_html(achievements) if achievements else None, + is_active=is_active + ) db.add(new_content) db.commit() db.refresh(new_content) @@ -199,22 +275,22 @@ async def create_or_update_page_content(page_type: PageType, title: Optional[str raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error saving page content: {str(e)}') @router.put('/{page_type}') -async def update_page_content(page_type: PageType, page_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def update_page_content(page_type: PageType, page_data: PageContentUpdateRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: authorize_roles(current_user, ['admin']) existing_content = db.query(PageContent).filter(PageContent.page_type == page_type).first() if not existing_content: existing_content = PageContent(page_type=page_type, is_active=True) db.add(existing_content) - for key, value in page_data.items(): + + # Convert Pydantic model to dict, excluding None values + update_dict = page_data.model_dump(exclude_unset=True, exclude_none=False) + + for key, value in update_dict.items(): if hasattr(existing_content, key): + # Convert dict/list to JSON string for JSON fields if key in ['contact_info', 'social_links', 'footer_links', 'badges', 'values', 'features', 'amenities', 'testimonials', 'gallery_images', 'stats', 'luxury_features', 'luxury_gallery', 'luxury_testimonials', 'luxury_services', 'luxury_experiences', 'awards', 'partners', 'team', 'timeline', 'achievements'] and value is not None: - if isinstance(value, str): - try: - json.loads(value) - except json.JSONDecodeError: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'Invalid JSON in {key}') - elif isinstance(value, (dict, list)): + if isinstance(value, (dict, list)): value = json.dumps(value) if value is not None: setattr(existing_content, key, value) @@ -227,4 +303,5 @@ async def update_page_content(page_type: PageType, page_data: dict, current_user raise except Exception as e: db.rollback() + logger.error(f'Error updating page content: {str(e)}', exc_info=True) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error updating page content: {str(e)}') \ No newline at end of file diff --git a/Backend/src/routes/payment_routes.py b/Backend/src/routes/payment_routes.py index daa28189..8099a888 100644 --- a/Backend/src/routes/payment_routes.py +++ b/Backend/src/routes/payment_routes.py @@ -5,6 +5,7 @@ from datetime import datetime import os from ..config.database import get_db from ..config.settings import settings +from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus @@ -18,6 +19,10 @@ from ..services.stripe_service import StripeService from ..services.paypal_service import PayPalService from ..services.borica_service import BoricaService from ..services.loyalty_service import LoyaltyService +from ..services.audit_service import audit_service +from ..schemas.payment import CreatePaymentRequest, UpdatePaymentStatusRequest, CreateStripePaymentIntentRequest + +logger = get_logger(__name__) router = APIRouter(prefix='/payments', tags=['payments']) async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reason: str='Payment failed or canceled'): @@ -141,7 +146,11 @@ async def get_payments_by_booking_id(booking_id: int, current_user: User=Depends except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + db.rollback() + import logging + logger = logging.getLogger(__name__) + logger.error(f'Error in get_payments_by_booking_id: {str(e)}', extra={'booking_id': booking_id}, exc_info=True) + raise HTTPException(status_code=500, detail='An error occurred while fetching payments') @router.get('/{id}') async def get_payment_by_id(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): @@ -159,23 +168,40 @@ async def get_payment_by_id(id: int, current_user: User=Depends(get_current_user except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + db.rollback() + import logging + logger = logging.getLogger(__name__) + logger.error(f'Error in get_payment_by_id: {str(e)}', extra={'payment_id': id}, exc_info=True) + raise HTTPException(status_code=500, detail='An error occurred while fetching payment') @router.post('/') -async def create_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def create_payment( + request: Request, + payment_data: CreatePaymentRequest, + current_user: User=Depends(get_current_user), + db: Session=Depends(get_db) +): + """Create a payment with validated input using Pydantic schema.""" + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + try: - booking_id = payment_data.get('booking_id') - amount = float(payment_data.get('amount', 0)) - payment_method = payment_data.get('payment_method', 'cash') - payment_type = payment_data.get('payment_type', 'full') + booking_id = payment_data.booking_id + amount = payment_data.amount + payment_method = payment_data.payment_method + payment_type = payment_data.payment_type + mark_as_paid = payment_data.mark_as_paid + notes = payment_data.notes + booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: raise HTTPException(status_code=404, detail='Booking not found') from ..utils.role_helpers import is_admin if not is_admin(current_user, db) and booking.user_id != current_user.id: raise HTTPException(status_code=403, detail='Forbidden') - payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod(payment_method), payment_type=PaymentType(payment_type), payment_status=PaymentStatus.pending, payment_date=datetime.utcnow() if payment_data.get('mark_as_paid') else None, notes=payment_data.get('notes')) - if payment_data.get('mark_as_paid'): + payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod(payment_method), payment_type=PaymentType(payment_type), payment_status=PaymentStatus.pending, payment_date=datetime.utcnow() if mark_as_paid else None, notes=notes) + if mark_as_paid: payment.payment_status = PaymentStatus.completed payment.payment_date = datetime.utcnow() db.add(payment) @@ -228,20 +254,67 @@ async def create_payment(payment_data: dict, current_user: User=Depends(get_curr import logging logger = logging.getLogger(__name__) logger.error(f'Failed to send payment confirmation email: {e}') + + # Log payment transaction + await audit_service.log_action( + db=db, + action='payment_created', + resource_type='payment', + user_id=current_user.id, + resource_id=payment.id, + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={ + 'booking_id': booking_id, + 'amount': float(amount), + 'payment_method': payment_method, + 'payment_type': payment_type, + 'payment_status': payment.payment_status.value if hasattr(payment.payment_status, 'value') else str(payment.payment_status), + 'transaction_id': payment.transaction_id + }, + status='success' + ) + return success_response(data={'payment': payment}, message='Payment created successfully') except HTTPException: raise except Exception as e: db.rollback() + # Log failed payment creation + await audit_service.log_action( + db=db, + action='payment_creation_failed', + resource_type='payment', + user_id=current_user.id if current_user else None, + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'booking_id': payment_data.booking_id, 'amount': payment_data.amount}, + status='failed', + error_message=str(e) + ) raise HTTPException(status_code=500, detail=str(e)) @router.put('/{id}/status', dependencies=[Depends(authorize_roles('admin', 'staff', 'accountant'))]) -async def update_payment_status(id: int, status_data: dict, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)): +async def update_payment_status( + request: Request, + id: int, + status_data: UpdatePaymentStatusRequest, + current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), + db: Session=Depends(get_db) +): + """Update payment status with validated input using Pydantic schema.""" + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + try: payment = db.query(Payment).filter(Payment.id == id).first() if not payment: raise HTTPException(status_code=404, detail='Payment not found') - status_value = status_data.get('status') + status_value = status_data.status + notes = status_data.notes old_status = payment.payment_status if status_value: try: @@ -273,13 +346,35 @@ async def update_payment_status(id: int, status_data: dict, current_user: User=D await cancel_booking_on_payment_failure(booking, db, reason=f'Payment {new_status.value}') except ValueError: raise HTTPException(status_code=400, detail='Invalid payment status') - if status_data.get('transaction_id'): - payment.transaction_id = status_data['transaction_id'] - if status_data.get('mark_as_paid'): - payment.payment_status = PaymentStatus.completed - payment.payment_date = datetime.utcnow() + + # Update notes if provided + if notes: + existing_notes = payment.notes or '' + payment.notes = f'{existing_notes}\n{notes}'.strip() if existing_notes else notes db.commit() db.refresh(payment) + + # Log payment status update (admin action) + await audit_service.log_action( + db=db, + action='payment_status_updated', + resource_type='payment', + user_id=current_user.id, + resource_id=payment.id, + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={ + 'payment_id': id, + 'old_status': old_status.value if hasattr(old_status, 'value') else str(old_status), + 'new_status': payment.payment_status.value if hasattr(payment.payment_status, 'value') else str(payment.payment_status), + 'booking_id': payment.booking_id, + 'amount': float(payment.amount) if payment.amount else 0.0, + 'notes': notes + }, + status='success' + ) + if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed: # Send payment receipt notification try: @@ -318,7 +413,7 @@ async def update_payment_status(id: int, status_data: dict, current_user: User=D payment.booking.status = BookingStatus.confirmed db.commit() except Exception as e: - print(f'Failed to send payment confirmation email: {e}') + logger.error(f'Failed to send payment confirmation email: {str(e)}', exc_info=True, extra={'payment_id': payment.id if hasattr(payment, 'id') else None}) return success_response(data={'payment': payment}, message='Payment status updated successfully') except HTTPException: raise @@ -327,7 +422,7 @@ async def update_payment_status(id: int, status_data: dict, current_user: User=D raise HTTPException(status_code=500, detail=str(e)) @router.post('/stripe/create-intent') -async def create_stripe_payment_intent(intent_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def create_stripe_payment_intent(intent_data: CreateStripePaymentIntentRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: from ..services.stripe_service import get_stripe_secret_key secret_key = get_stripe_secret_key(db) @@ -335,14 +430,12 @@ async def create_stripe_payment_intent(intent_data: dict, current_user: User=Dep secret_key = settings.STRIPE_SECRET_KEY if not secret_key: raise HTTPException(status_code=500, detail='Stripe is not configured. Please configure Stripe settings in Admin Panel or set STRIPE_SECRET_KEY environment variable.') - booking_id = intent_data.get('booking_id') - amount = float(intent_data.get('amount', 0)) - currency = intent_data.get('currency', 'usd') + booking_id = intent_data.booking_id + amount = intent_data.amount + currency = intent_data.currency or 'usd' import logging logger = logging.getLogger(__name__) logger.info(f'Creating Stripe payment intent - Booking ID: {booking_id}, Amount: ${amount:,.2f}, Currency: {currency}') - if not booking_id or amount <= 0: - raise HTTPException(status_code=400, detail='booking_id and amount are required') if amount > 999999.99: logger.error(f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99") raise HTTPException(status_code=400, detail=f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99. Please contact support for large payments.") diff --git a/Backend/src/routes/promotion_routes.py b/Backend/src/routes/promotion_routes.py index a921e165..e4fd5bba 100644 --- a/Backend/src/routes/promotion_routes.py +++ b/Backend/src/routes/promotion_routes.py @@ -4,9 +4,17 @@ from sqlalchemy import or_ from typing import Optional from datetime import datetime from ..config.database import get_db +from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.promotion import Promotion, DiscountType +from ..schemas.promotion import ( + ValidatePromotionRequest, + CreatePromotionRequest, + UpdatePromotionRequest +) + +logger = get_logger(__name__) router = APIRouter(prefix='/promotions', tags=['promotions']) @router.get('/') @@ -32,6 +40,8 @@ async def get_promotions(search: Optional[str]=Query(None), status_filter: Optio result.append(promo_dict) return {'status': 'success', 'data': {'promotions': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}} except Exception as e: + db.rollback() + logger.error(f'Error fetching promotions: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get('/{code}') @@ -45,13 +55,15 @@ async def get_promotion_by_code(code: str, db: Session=Depends(get_db)): except HTTPException: raise except Exception as e: + db.rollback() + logger.error(f'Error fetching promotion by code: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post('/validate') -async def validate_promotion(validation_data: dict, db: Session=Depends(get_db)): +async def validate_promotion(validation_data: ValidatePromotionRequest, db: Session=Depends(get_db)): try: - code = validation_data.get('code') - booking_amount = float(validation_data.get('booking_value') or validation_data.get('booking_amount', 0)) + code = validation_data.code + booking_amount = float(validation_data.booking_value or validation_data.booking_amount or 0) promotion = db.query(Promotion).filter(Promotion.code == code).first() if not promotion: raise HTTPException(status_code=404, detail='Promotion code not found') @@ -72,20 +84,33 @@ async def validate_promotion(validation_data: dict, db: Session=Depends(get_db)) except HTTPException: raise except Exception as e: + db.rollback() + logger.error(f'Error validating promotion: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post('/', dependencies=[Depends(authorize_roles('admin'))]) -async def create_promotion(promotion_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): +async def create_promotion(promotion_data: CreatePromotionRequest, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: - code = promotion_data.get('code') + code = promotion_data.code existing = db.query(Promotion).filter(Promotion.code == code).first() if existing: raise HTTPException(status_code=400, detail='Promotion code already exists') - discount_type = promotion_data.get('discount_type') - discount_value = float(promotion_data.get('discount_value', 0)) - if discount_type == 'percentage' and discount_value > 100: - raise HTTPException(status_code=400, detail='Percentage discount cannot exceed 100%') - promotion = Promotion(code=code, name=promotion_data.get('name'), description=promotion_data.get('description'), discount_type=DiscountType(discount_type), discount_value=discount_value, min_booking_amount=float(promotion_data['min_booking_amount']) if promotion_data.get('min_booking_amount') else None, max_discount_amount=float(promotion_data['max_discount_amount']) if promotion_data.get('max_discount_amount') else None, start_date=datetime.fromisoformat(promotion_data['start_date'].replace('Z', '+00:00')) if promotion_data.get('start_date') else None, end_date=datetime.fromisoformat(promotion_data['end_date'].replace('Z', '+00:00')) if promotion_data.get('end_date') else None, usage_limit=promotion_data.get('usage_limit'), used_count=0, is_active=promotion_data.get('status') == 'active' if promotion_data.get('status') else True) + discount_type = promotion_data.discount_type + discount_value = promotion_data.discount_value + promotion = Promotion( + code=code, + name=promotion_data.name, + description=promotion_data.description, + discount_type=DiscountType(discount_type), + discount_value=discount_value, + min_booking_amount=promotion_data.min_booking_amount, + max_discount_amount=promotion_data.max_discount_amount, + start_date=datetime.fromisoformat(promotion_data.start_date.replace('Z', '+00:00')) if promotion_data.start_date else None, + end_date=datetime.fromisoformat(promotion_data.end_date.replace('Z', '+00:00')) if promotion_data.end_date else None, + usage_limit=promotion_data.usage_limit, + used_count=0, + is_active=promotion_data.status == 'active' if promotion_data.status else True + ) db.add(promotion) db.commit() db.refresh(promotion) @@ -94,47 +119,46 @@ async def create_promotion(promotion_data: dict, current_user: User=Depends(auth raise except Exception as e: db.rollback() + logger.error(f'Error creating promotion: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))]) -async def update_promotion(id: int, promotion_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): +async def update_promotion(id: int, promotion_data: UpdatePromotionRequest, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: promotion = db.query(Promotion).filter(Promotion.id == id).first() if not promotion: raise HTTPException(status_code=404, detail='Promotion not found') - code = promotion_data.get('code') + code = promotion_data.code if code and code != promotion.code: existing = db.query(Promotion).filter(Promotion.code == code, Promotion.id != id).first() if existing: raise HTTPException(status_code=400, detail='Promotion code already exists') - discount_type = promotion_data.get('discount_type', promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type) - discount_value = promotion_data.get('discount_value') - if discount_value is not None: - discount_value = float(discount_value) - if discount_type == 'percentage' and discount_value > 100: - raise HTTPException(status_code=400, detail='Percentage discount cannot exceed 100%') - if 'code' in promotion_data: - promotion.code = promotion_data['code'] - if 'name' in promotion_data: - promotion.name = promotion_data['name'] - if 'description' in promotion_data: - promotion.description = promotion_data['description'] - if 'discount_type' in promotion_data: - promotion.discount_type = DiscountType(promotion_data['discount_type']) - if 'discount_value' in promotion_data: - promotion.discount_value = discount_value - if 'min_booking_amount' in promotion_data: - promotion.min_booking_amount = float(promotion_data['min_booking_amount']) if promotion_data['min_booking_amount'] else None - if 'max_discount_amount' in promotion_data: - promotion.max_discount_amount = float(promotion_data['max_discount_amount']) if promotion_data['max_discount_amount'] else None - if 'start_date' in promotion_data: - promotion.start_date = datetime.fromisoformat(promotion_data['start_date'].replace('Z', '+00:00')) if promotion_data['start_date'] else None - if 'end_date' in promotion_data: - promotion.end_date = datetime.fromisoformat(promotion_data['end_date'].replace('Z', '+00:00')) if promotion_data['end_date'] else None - if 'usage_limit' in promotion_data: - promotion.usage_limit = promotion_data['usage_limit'] - if 'status' in promotion_data: - promotion.is_active = promotion_data['status'] == 'active' + discount_type = promotion_data.discount_type or (promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type) + discount_value = promotion_data.discount_value + if discount_value is not None and discount_type == 'percentage' and discount_value > 100: + raise HTTPException(status_code=400, detail='Percentage discount cannot exceed 100%') + if promotion_data.code is not None: + promotion.code = promotion_data.code + if promotion_data.name is not None: + promotion.name = promotion_data.name + if promotion_data.description is not None: + promotion.description = promotion_data.description + if promotion_data.discount_type is not None: + promotion.discount_type = DiscountType(promotion_data.discount_type) + if promotion_data.discount_value is not None: + promotion.discount_value = promotion_data.discount_value + if promotion_data.min_booking_amount is not None: + promotion.min_booking_amount = promotion_data.min_booking_amount + if promotion_data.max_discount_amount is not None: + promotion.max_discount_amount = promotion_data.max_discount_amount + if promotion_data.start_date is not None: + promotion.start_date = datetime.fromisoformat(promotion_data.start_date.replace('Z', '+00:00')) if promotion_data.start_date else None + if promotion_data.end_date is not None: + promotion.end_date = datetime.fromisoformat(promotion_data.end_date.replace('Z', '+00:00')) if promotion_data.end_date else None + if promotion_data.usage_limit is not None: + promotion.usage_limit = promotion_data.usage_limit + if promotion_data.status is not None: + promotion.is_active = promotion_data.status == 'active' db.commit() db.refresh(promotion) return {'status': 'success', 'message': 'Promotion updated successfully', 'data': {'promotion': promotion}} @@ -142,6 +166,7 @@ async def update_promotion(id: int, promotion_data: dict, current_user: User=Dep raise except Exception as e: db.rollback() + logger.error(f'Error updating promotion: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))]) @@ -157,4 +182,5 @@ async def delete_promotion(id: int, current_user: User=Depends(authorize_roles(' raise except Exception as e: db.rollback() + logger.error(f'Error deleting promotion: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/review_routes.py b/Backend/src/routes/review_routes.py index 95c1ee51..dcc9bc74 100644 --- a/Backend/src/routes/review_routes.py +++ b/Backend/src/routes/review_routes.py @@ -1,25 +1,51 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query +from ..utils.response_helpers import success_response from sqlalchemy.orm import Session from typing import Optional from ..config.database import get_db +from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.review import Review, ReviewStatus from ..models.room import Room +from ..schemas.review import CreateReviewRequest + +logger = get_logger(__name__) router = APIRouter(prefix='/reviews', tags=['reviews']) @router.get('/room/{room_id}') -async def get_room_reviews(room_id: int, db: Session=Depends(get_db)): +async def get_room_reviews( + room_id: int, + page: int = Query(1, ge=1), + limit: int = Query(10, ge=1, le=100), + db: Session = Depends(get_db) +): try: - reviews = db.query(Review).filter(Review.room_id == room_id, Review.status == ReviewStatus.approved).order_by(Review.created_at.desc()).all() + query = db.query(Review).filter(Review.room_id == room_id, Review.status == ReviewStatus.approved) + total = query.count() + offset = (page - 1) * limit + reviews = query.order_by(Review.created_at.desc()).offset(offset).limit(limit).all() result = [] for review in reviews: review_dict = {'id': review.id, 'user_id': review.user_id, 'room_id': review.room_id, 'rating': review.rating, 'comment': review.comment, 'status': review.status.value if isinstance(review.status, ReviewStatus) else review.status, 'created_at': review.created_at.isoformat() if review.created_at else None} if review.user: review_dict['user'] = {'id': review.user.id, 'full_name': review.user.full_name, 'email': review.user.email} result.append(review_dict) - return {'status': 'success', 'data': {'reviews': result}} + return { + 'status': 'success', + 'data': { + 'reviews': result, + 'pagination': { + 'total': total, + 'page': page, + 'limit': limit, + 'totalPages': (total + limit - 1) // limit + } + } + } except Exception as e: + db.rollback() + logger.error(f'Error fetching room reviews: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get('/', dependencies=[Depends(authorize_roles('admin'))]) @@ -44,14 +70,16 @@ async def get_all_reviews(status_filter: Optional[str]=Query(None, alias='status result.append(review_dict) return {'status': 'success', 'data': {'reviews': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}} except Exception as e: + db.rollback() + logger.error(f'Error fetching all reviews: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post('/') -async def create_review(review_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def create_review(review_data: CreateReviewRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - room_id = review_data.get('room_id') - rating = review_data.get('rating') - comment = review_data.get('comment') + room_id = review_data.room_id + rating = review_data.rating + comment = review_data.comment room = db.query(Room).filter(Room.id == room_id).first() if not room: raise HTTPException(status_code=404, detail='Room not found') @@ -67,6 +95,7 @@ async def create_review(review_data: dict, current_user: User=Depends(get_curren raise except Exception as e: db.rollback() + logger.error(f'Error creating review: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.put('/{id}/approve', dependencies=[Depends(authorize_roles('admin'))]) @@ -83,6 +112,7 @@ async def approve_review(id: int, current_user: User=Depends(authorize_roles('ad raise except Exception as e: db.rollback() + logger.error(f'Error approving review: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.put('/{id}/reject', dependencies=[Depends(authorize_roles('admin'))]) @@ -99,6 +129,7 @@ async def reject_review(id: int, current_user: User=Depends(authorize_roles('adm raise except Exception as e: db.rollback() + logger.error(f'Error rejecting review: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))]) @@ -114,4 +145,5 @@ async def delete_review(id: int, current_user: User=Depends(authorize_roles('adm raise except Exception as e: db.rollback() + logger.error(f'Error deleting review: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/room_routes.py b/Backend/src/routes/room_routes.py index c891029f..890e6e59 100644 --- a/Backend/src/routes/room_routes.py +++ b/Backend/src/routes/room_routes.py @@ -4,6 +4,7 @@ from sqlalchemy import and_, or_, func from typing import List, Optional from datetime import datetime from ..config.database import get_db +from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.room import Room, RoomStatus @@ -14,6 +15,8 @@ from ..services.room_service import get_rooms_with_ratings, get_amenities_list, import os import aiofiles from pathlib import Path + +logger = get_logger(__name__) router = APIRouter(prefix='/rooms', tags=['rooms']) @router.get('/') @@ -54,6 +57,7 @@ async def get_rooms(request: Request, type: Optional[str]=Query(None), minPrice: rooms_with_ratings = await get_rooms_with_ratings(db, rooms, base_url) return {'status': 'success', 'data': {'rooms': rooms_with_ratings, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}} except Exception as e: + logger.error(f'Error fetching rooms: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get('/amenities') @@ -62,6 +66,7 @@ async def get_amenities(db: Session=Depends(get_db)): amenities = await get_amenities_list(db) return {'status': 'success', 'data': {'amenities': amenities}} except Exception as e: + logger.error(f'Error fetching amenities: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get('/available') @@ -159,6 +164,7 @@ async def search_available_rooms(request: Request, from_date: str=Query(..., ali except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: + logger.error(f'Error searching available rooms: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get('/id/{id}') @@ -364,6 +370,8 @@ async def upload_room_images(id: int, images: List[UploadFile]=File(...), curren except HTTPException: raise except Exception as e: + db.rollback() + logger.error(f'Error uploading room images: {str(e)}', exc_info=True, extra={'room_id': id}) raise HTTPException(status_code=500, detail=str(e)) @router.delete('/{id}/images', dependencies=[Depends(authorize_roles('admin', 'staff'))]) @@ -421,6 +429,7 @@ async def get_room_booked_dates(id: int, db: Session=Depends(get_db)): except HTTPException: raise except Exception as e: + logger.error(f'Error fetching booked dates: {str(e)}', exc_info=True, extra={'room_id': id}) raise HTTPException(status_code=500, detail=str(e)) @router.get('/{id}/reviews') @@ -441,4 +450,5 @@ async def get_room_reviews_route(id: int, db: Session=Depends(get_db)): except HTTPException: raise except Exception as e: + logger.error(f'Error fetching room reviews: {str(e)}', exc_info=True, extra={'room_id': id}) raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/service_booking_routes.py b/Backend/src/routes/service_booking_routes.py index 937ddcf2..52785be5 100644 --- a/Backend/src/routes/service_booking_routes.py +++ b/Backend/src/routes/service_booking_routes.py @@ -5,6 +5,7 @@ from datetime import datetime import random from ..config.database import get_db +from ..config.logging_config import get_logger from ..middleware.auth import get_current_user from ..models.user import User from ..utils.role_helpers import is_admin @@ -19,6 +20,13 @@ from ..models.service_booking import ( ) from ..services.stripe_service import StripeService, get_stripe_secret_key, get_stripe_publishable_key from ..config.settings import settings +from ..schemas.service_booking import ( + CreateServiceBookingRequest, + CreateServicePaymentIntentRequest, + ConfirmServicePaymentRequest +) + +logger = get_logger(__name__) router = APIRouter(prefix="/service-bookings", tags=["service-bookings"]) @@ -30,14 +38,14 @@ def generate_service_booking_number() -> str: @router.post("/") async def create_service_booking( - booking_data: dict, + booking_data: CreateServiceBookingRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): try: - services = booking_data.get("services", []) - total_amount = float(booking_data.get("total_amount", 0)) - notes = booking_data.get("notes") + services = booking_data.services + total_amount = booking_data.total_amount + notes = booking_data.notes if not services or len(services) == 0: raise HTTPException(status_code=400, detail="At least one service is required") @@ -50,8 +58,8 @@ async def create_service_booking( service_items_data = [] for service_item in services: - service_id = service_item.get("service_id") - quantity = service_item.get("quantity", 1) + service_id = service_item.service_id + quantity = service_item.quantity if not service_id: raise HTTPException(status_code=400, detail="Service ID is required for each item") @@ -197,6 +205,8 @@ async def get_my_service_bookings( "data": {"service_bookings": result} } except Exception as e: + db.rollback() + logger.error(f'Error fetching service bookings: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/{id}") @@ -249,12 +259,14 @@ async def get_service_booking_by_id( except HTTPException: raise except Exception as e: + db.rollback() + logger.error(f'Error fetching service booking by id: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/{id}/payment/stripe/create-intent") async def create_service_stripe_payment_intent( id: int, - intent_data: dict, + intent_data: CreateServicePaymentIntentRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): @@ -270,8 +282,8 @@ async def create_service_stripe_payment_intent( detail="Stripe is not configured. Please configure Stripe settings in Admin Panel." ) - amount = float(intent_data.get("amount", 0)) - currency = intent_data.get("currency", "usd") + amount = intent_data.amount + currency = intent_data.currency if amount <= 0: raise HTTPException(status_code=400, detail="Amount must be greater than 0") @@ -320,17 +332,19 @@ async def create_service_stripe_payment_intent( except HTTPException: raise except Exception as e: + db.rollback() + logger.error(f'Error creating service payment intent: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/{id}/payment/stripe/confirm") async def confirm_service_stripe_payment( id: int, - payment_data: dict, + payment_data: ConfirmServicePaymentRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): try: - payment_intent_id = payment_data.get("payment_intent_id") + payment_intent_id = payment_data.payment_intent_id if not payment_intent_id: raise HTTPException(status_code=400, detail="payment_intent_id is required") diff --git a/Backend/src/routes/user_routes.py b/Backend/src/routes/user_routes.py index a0300fd1..43b52cfe 100644 --- a/Backend/src/routes/user_routes.py +++ b/Backend/src/routes/user_routes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status, Query +from fastapi import APIRouter, Depends, HTTPException, status, Query, Request from sqlalchemy.orm import Session from sqlalchemy import or_ from typing import Optional @@ -10,6 +10,8 @@ from ..models.role import Role from ..models.booking import Booking, BookingStatus from ..utils.role_helpers import can_manage_users from ..utils.response_helpers import success_response +from ..services.audit_service import audit_service +from ..schemas.user import CreateUserRequest, UpdateUserRequest router = APIRouter(prefix='/users', tags=['users']) @router.get('/', dependencies=[Depends(authorize_roles('admin'))]) @@ -51,26 +53,53 @@ async def get_user_by_id(id: int, current_user: User=Depends(authorize_roles('ad raise HTTPException(status_code=500, detail=str(e)) @router.post('/', dependencies=[Depends(authorize_roles('admin'))]) -async def create_user(user_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): +async def create_user( + request: Request, + user_data: CreateUserRequest, + current_user: User=Depends(authorize_roles('admin')), + db: Session=Depends(get_db) +): + """Create a user with validated input using Pydantic schema.""" + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + try: - email = user_data.get('email') - password = user_data.get('password') - full_name = user_data.get('full_name') - phone_number = user_data.get('phone_number') - role = user_data.get('role', 'customer') - status = user_data.get('status', 'active') - role_map = {'admin': 1, 'staff': 2, 'customer': 3, 'accountant': 4} - role_id = role_map.get(role, 3) + email = user_data.email + password = user_data.password + full_name = user_data.full_name + phone_number = user_data.phone_number + role_id = user_data.role_id or 3 # Default to customer role existing = db.query(User).filter(User.email == email).first() if existing: raise HTTPException(status_code=400, detail='Email already exists') password_bytes = password.encode('utf-8') salt = bcrypt.gensalt() hashed_password = bcrypt.hashpw(password_bytes, salt).decode('utf-8') - user = User(email=email, password=hashed_password, full_name=full_name, phone=phone_number, role_id=role_id, is_active=status == 'active') + user = User(email=email, password=hashed_password, full_name=full_name, phone=phone_number, role_id=role_id, is_active=True) db.add(user) db.commit() db.refresh(user) + + # Log admin action - user creation + await audit_service.log_action( + db=db, + action='admin_user_created', + resource_type='user', + user_id=current_user.id, + resource_id=user.id, + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={ + 'created_user_email': user.email, + 'created_user_name': user.full_name, + 'role_id': user.role_id, + 'is_active': user.is_active + }, + status='success' + ) + user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'currency': getattr(user, 'currency', 'VND'), 'role_id': user.role_id, 'is_active': user.is_active} return success_response(data={'user': user_dict}, message='User created successfully') except HTTPException: @@ -80,37 +109,32 @@ async def create_user(user_data: dict, current_user: User=Depends(authorize_role raise HTTPException(status_code=500, detail=str(e)) @router.put('/{id}') -async def update_user(id: int, user_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def update_user(id: int, user_data: UpdateUserRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): + """Update a user with validated input using Pydantic schema.""" try: if not can_manage_users(current_user, db) and current_user.id != id: raise HTTPException(status_code=403, detail='Forbidden') user = db.query(User).filter(User.id == id).first() if not user: raise HTTPException(status_code=404, detail='User not found') - email = user_data.get('email') - if email and email != user.email: - existing = db.query(User).filter(User.email == email).first() + + # Check email uniqueness if being updated + if user_data.email and user_data.email != user.email: + existing = db.query(User).filter(User.email == user_data.email).first() if existing: raise HTTPException(status_code=400, detail='Email already exists') - role_map = {'admin': 1, 'staff': 2, 'customer': 3, 'accountant': 4} - if 'full_name' in user_data: - user.full_name = user_data['full_name'] - if 'email' in user_data and can_manage_users(current_user, db): - user.email = user_data['email'] - if 'phone_number' in user_data: - user.phone = user_data['phone_number'] - if 'role' in user_data and can_manage_users(current_user, db): - user.role_id = role_map.get(user_data['role'], 3) - if 'status' in user_data and can_manage_users(current_user, db): - user.is_active = user_data['status'] == 'active' - if 'currency' in user_data: - currency = user_data['currency'] - if len(currency) == 3 and currency.isalpha(): - user.currency = currency.upper() - if 'password' in user_data: - password_bytes = user_data['password'].encode('utf-8') - salt = bcrypt.gensalt() - user.password = bcrypt.hashpw(password_bytes, salt).decode('utf-8') + + # Update fields if provided + if user_data.full_name is not None: + user.full_name = user_data.full_name + if user_data.email is not None and can_manage_users(current_user, db): + user.email = user_data.email + if user_data.phone_number is not None: + user.phone = user_data.phone_number + if user_data.role_id is not None and can_manage_users(current_user, db): + user.role_id = user_data.role_id + if user_data.is_active is not None and can_manage_users(current_user, db): + user.is_active = user_data.is_active db.commit() db.refresh(user) user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'currency': getattr(user, 'currency', 'VND'), 'role_id': user.role_id, 'is_active': user.is_active} diff --git a/Backend/src/schemas/__pycache__/booking.cpython-312.pyc b/Backend/src/schemas/__pycache__/booking.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd8307922f75dafa4732907409e54e9d06b6413b GIT binary patch literal 8684 zcmeHNYfM|&c0R{Gej99K^CrPyUMArY;7%Yjd6LW|m&Z&(G83GnuH$_OPW)i@Ib^^k zy);cFMru`%Duq#RE2kZ`pdgj|qbl?3&ei;h)IWA@6*x-Ux$0Hb{HaJn)e-&Ewf3=% z$%V|7s;f$Ex5PSouf6v2K6|gd*82A0Z;Oh|9F(>7me9>=j{9e9c z#}S@r!hA&I(?qmBZA9=15uH!RBVQZVM+`ng#OO1!wh%T&OgaH zwlRG8?+3ym5|Cq5u|=?Zm*LeY#t$dtP%Ih4gcV(Wd32&)G!XrJAkX>@&~GHB74uU;)#WoX2@6V0 zTfIe!V??C)LqYM9EJjAyO2nJgCtDCn?RAHsTXKh@?m%vh+S{oHl*G+h1@24dmob;Q zcG5mEO)a2=5ffCTb|AmUZ3>;6Wz~!QiLWQ8 zSzBP0A+;Cm5e{vHZEO4Ri)S!;nODq_z?45MM(@buiZvLEOa!9S%yvTCV1SA(#|jVv zCt7;Z1;do$99M?HAvngtNl6B~pnjF{;$Oi7DLODBQBqv8NFx&16!8aRQ4(TirMM;n z(-AQ$`$JJlrjtR&_1b7D=%-~!%8^tcVFv2KngNKSg#ok*n+-^skTfIlB56f(1PORr zPPLaqSgXwuGrh)~1qF<$i0A zeB(ayH6VY8G_5q}Mv#_AyV?#DFKH#k`xVwepVHB1b{%$S-RT+#zrj(DBIiuu3c7!X?mdm#a#?A0Vmpdk^yoZyGIRQE6 z%(yX414peqCu0`C3eo3&=OODlrKI6THcy5=r^_uO9f<4Z|f(<_b##m`G> zl7}v*ORglXSGKgs*^%&=J?uJL#Qk+Ske?M*pKa0ZZ-O6zP;`MYNUR`(6A&^5^QRn?=6%Dz z0QWIB!`%huo8-3oFvd!uCo{p? zkFY=RgBW31krA)4!u;Uj2RdSq9XZ~NE_W#4&G?60fo5Z_=OIr_4|Kn+`)t=&n&Ykc z<06j-WH$?|Z5V+=0Lal+vaC?Fot}YTqi2zvL(-4rJdmB%Hv)I==sA5uU@Ft2S$Mh2! z(8_M&AS=8mp((;Sg`&FO;`~uXCnCi?Z$F_-=nUwPG;nOds`>a@_t)Oky93WXN7g-k zDNo;;cm2db>cqga{^aG+^a)?ub7Nuf7uegA^7O2Zer-;B2IdF9bskO}{X1v-^Qwkq z_4LY=7gOMT|jTyWO5}Em-ZK*bh$`svLI2MpqonGuhV5w^F z#abT<31PTyqVHlGa~SpR0ug}MB-uBRDMzjoxhg(Q zp1+x?zO@TcZ=p_ovgbyo>ZYpm=Kh%1)3W4RtIv2&!d!Y6`xgM-p4{v@{!GYp;YH2( zyqv6QOO>~$iaXN6&_-tuLhEGp(NqZ_-=3fAT=V{9(>rP1@eQ4QOJmWE@QINv4xdXS zuh{domeV_s^lk~re_6%pOOvIIXLwiri>YS%8pB;S#J$ab2505B?SuJc$B(QSflQI6DBaT|l0cXeZ1K z6H9Ewi~0*;uy~?;yK2^5ttnUQ z(y_nlTkcyOS!+oj>Cd>%LyAyQId24vReEUM)tGWMCIU-U8CTCndBwaDdhL!m<9FM- z-N?(sFR)m9EnW6;(*7}kE#RnN>rvNP2lqGSKz`<^K6~^4uyqwC%nYRQ*9f?>H}=^> z(eBkLh6%XUhrULdkDHZJ7G8{hL7EL@_LjjCgTqb7A)Vxr_57pTlRQ3!5yZKArIl?55NMEcs5pH<)P{ z+Wq1*v{~1bkXQND+nKsP$mc5-Jqw4@Lf^jMoS53xx{RlXNj5Ij?wfx*64#f4Pi|of z?pU4rTKkg+n1%1=)GP^iupE5;dRljwrQmv zG+)|xQ}K#yD*jRfH8r`$6&m&yvoC#$aU%PF1{B@C`r(f)I zskn@N-NQtkE%_UJv15Es{Rn=F{s=@c1(k3IA^P(COO1vX{zK347x^^z!9Q_z|G*vE u)@%7;e$lke;d9$+?xSFxeh9YH> zYHeTY0xgi->{C@>4?Zf04>|fzDB6nw=^zp9sh9fZATA2zvisgplE>Mk=(z&)$a(Xd zH#2YE`@P}cnwz5n{67Etg!W~VAp8>>^bEG;C^5MNe_!l;}nlRGY4tgz9eK z3yta|<#WRibB<;js-E#y=Sk)OczyHMhufq=+6P+|pTOqQeo>$j5s9BjM}fws0yIcN zBzPAFvHlSBhe_lp`cm>tro%J>h^9-3*im!CLX`ElH2UMDm9&v7oP{PfM|%U2ARR}Y zFU6Yu7;UC4J1u5g$^4#Tgfox==q z7~W2T!^j-wBW^t%9A+3XB$4jqbjg~oi_?uT-6$CYKaZ0TiIWMEB3H@B--k4x=1X_) zBvO-ZV$q@yTwi&i_O%ybyF+U%3|SOr5TF?w5Owv4oSL%mRCc2aruh`RzFK-Ay5ff8 zHW6xPERDsS8~x7fRy4BV9ikwd5**n6jk+ycFt41WR)!jmxuufKFqOQegXz5Om|1Em9^~|2z$R|MFdb^U z35Qx)TY;=sGNwT^OlZ*N)SWE2PSFh8vGN&D+xBbQG!&0T6uRwLsvDx)YBs0S+8K<@ z??#uwt}d^_&UTw_+?o=x{x|~=8WTqsoRform(s6rv_}<=w{fWZN!L^cf zuR40QchvM z^2prY>i%+J?O>%O%~i)fE-oFfmB;R%NQwQ;f_;!JN%y^(7RsZaUCi`ZwNE}wyc#L@ z-QN5B+3fz}bC~INaq6AsSx|`dS7IHN!J$gOTuF{q`UflWWF>jM z((}pdc(}Xe%pV<$p7~>m;O`$;H|qYqRKyy!`mf;d4;8T^sZl^!31bB$Oacv}6-ki8 zM6d+aFd+^T<}gtX6X7sX4#P@W&CW$h6bWx9#@UH+cKG!)bC_lh!`o@$Fc;-jByny% zaSqdpc8Hj6<#cT|T^pyn!p+xC5=R}4Qho(c?X}%?@~ES(hPM+Orn_dngR{=cGwCIL zq@N6s!S6*@lsb3B)R3Eak|Y1UDCH6GPElI55*Q~PC>Sku;~Os1>P5jQq95Ph#}gl6 z$7Ql$Mb3&rN4?^-AP7k*Yx|8-*^|2%YzSHd=mAuzQyzdBUuH7EH>7uB&++ zzA7A3UeD`#{gP#2F>{07fQ#7nZV~gpn@>eQ9K&~DsKsKK#UYE~_l4jr;!rHW-~JQ? z7S8L{!Q|mm@$t#sTzPPdoy?Qx4@=Tk<78U93u9+~5f4Vfg9%<73|d=FMOZ?);au&z z%7W{ZmtSts*~x*kyydaV%@Tnv<#L9+4d3-HFmo0X7$k^b-v)8!_xXIUMbRhyCn)&F k{vq_82M2t?!ku#g9_RDEF0TQP^IK7P|K-00c(By_5yms3lmGw# literal 0 HcmV?d00001 diff --git a/Backend/src/schemas/__pycache__/page_content.cpython-312.pyc b/Backend/src/schemas/__pycache__/page_content.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a7d0bdb54c9107abde9da44e3ecda653b427222 GIT binary patch literal 7906 zcmb7JOKjUnnx-ULmfub!KN7!WOMWPc?Ie=lwj5is<2ZgKk)6(HF%cM%vDwkXP11>N z^bEX@! zo8y+SB~FG(6E3sFO5)bAHC`GnjoZSucv-kCULG#j>d06{xWY`_CFqi05ww*tzqjbR z;mU2&Q@RrRw$9%QP@B;6o z68M7ONG5oO?<@Z^R`giZvT2HE7Lzm+Q%h!9CPu3@&+#Ghl8Ui3B_ugdiE3Nk5ZGjb zim7CfjS8w|DzUAS3oI|FC94TkvlP_>)DdMx`+XuSa26OMY(kkZucCg3EoOotX)|s4 zmV9s4Y2gya%9LI~7mSTA8AnHhV+EWtz$pbB8{m`!P8sIV=5PflD+lc=K)Xu7sRSGt zV-?_3ox`aHoNB;;W9; zdINAghHLMIT(1E|=-LMquLBNr?FXD2hU?9ut2WDi_PqB39q4rn==1>&^tuf={pWDJ zfOFG`$5)I8%tG2+0iWSpKoLeZ02FTn4vcINaJ+`=(1l!mKoPnQ1H}QrfvzKfGibPu zUdVL_C_>jUpg0UT&~+SeMhw>pP&Rr_J3ruz8SN%f*-y_`<3JHcdj}{^01k|H3UK^} z>-2?OCxIe#4FJVEfCF8FfHP&d&RocK8Yn{7S)dpI9O!x%aDs;G+=X0cfFg9g2NY)k z2fEG!&RxKPtC0JEGY2?OX8~~Tox@oKoO!^3F)snmeZYZU%Yd_hIcTNw0G0jpSX=~( z(DfluT+$Sm!;er|$XFpfFxsuqcDjzPryJ;Ix`pnhuhG5qIDL!u(L?kkJx$Nhi*yI= zqTTevZy&v{L!qG`vr314MZDK9ipTI{dWBx4AJbpZPv~X(XTK#`6Ke{8v2FH*)y4;C zbso?c=c^lNVa+_!SLQqIA#Dw>E#WtD4Vzf|Y%Sln#QtDHi*=J)9;db6d$UbXh?&e3WCgHM?4Bd=dQl0}2-@Faj0PxN5C-4!KoY$qQ!t zRg$B5)s|c{sMY8U5^)V1M?F%jqEsT8V53wlvdP8NO6*2Og$SE?o>WWY)P`QnC!;K) z#Ms0OUafqdObQHVTv?w{^cusf55{UWyL!6s(`+_*f>fJ6+0=>a%Npor4m-Fb!${C z#-JX59!BHI;$rjaLXFmWUUkGaw>D3;Z#Yp}Y<`liXT8Rt7qi@K6co%pkFM>U+M-MK z=LEIgxQ1On(PQ^tqZEbhXRuVeJ|s3c<|WI#GHzu3=Y>Uuy3flC1MP%{o|Y0`pwM7x zF-&=ed&x#m4H@To)kV)jdJ6(-p$W6K!Eh{-I4$ZjM9*RrbuXVLsSfHDg`Vyc*=;yc zT9mNr6>3?uIB`_s(;}e;Up?Ct1pJ6FTY?G;@B^S%Ifn`7731c6i|LXlnAli=#h2%)f>Lwsp1aF35)o8B7=A*2{0T!cUtYQMA<&W?|6 zf~zqR0@@MLf3*2)({G61nF}iaV0uMtS$;;mGI=aJU61wM7e?E*K?&d)=X_{+$;k^G zem!VgG<@IEPTjqPwo#+kq71|-Vbtlds^w^sOQ6w-MviJhtyJ<$9!-8Fu_T3N_8Ke} zWTGfjGaQ%X)N*9Fsh@kyswEoZRntozr^V^~G0?xBj5GafiLL%1!@m%c8~t-=0+01h zCzCHwIQ`R9^aYck`*|+fZ|q3*DnmOcraYUa2>-9480ioF!W=%iQyK8+ZenUd>95W3}P6Q^JvYro}{^FxTF%#T`~OFJZIP55uZ>Q!BH403xG4s|1-+{KN5NJ zdcMgiIwY6UG`KsnH@qKsJ#j=1=38B2P+C!1M|bD;*7y0>@gs6H-`*t(8B%GV*uB5s zm~y>oIU*-=E$zE^_nz!WU;q4wY|lG8MOxw&=h*K2zAa^cQ*}g+<-4w@gPD-hHI*eD z`|BzG4N{xRyL!@&j7xF(cNh1EQ-L?g*RT7Al-7}B-;uoAlXhpkihFW*X@4#idb4;$ zPM#g1rs-B(lgFBqxsi$8h5eq?fM^%#v{$mEhYu3jcAwldAYUFtUC6g|h(0N(w2U10 zWF)s7I^9a5GrmL1Vd&8IFMfG&KHEMoH{F*nFC3AfznPDsn%$e*Url*MQd~*9(tNt- z;Hm8NWt)6*-GE%Fg?45>&+4U~c|mcGA7_3%=juLAL^n>vPg~~3Cbiserb41y6w(37 zo}N4SrQ9)?Z5fmshUDsDoPO<|t|(p8;GRyOnaO-}yXcmDO7pPh9(s*#-*9edY&W>q zvpk=khlPB>GTLZZ2fWR?(KWAD|JtkQ=|Vdv9+m zRg#LL*vNE9a;2ZVtCn3u+14SsaagVyIU?`qZF%M9ebDxP-aDLed~hq?z+2RIHs9x!f`=}6eYcpB0G1Vh_-}WAnGkOMsO7A_8fqOdFp$Fe zjAncvg37?dEa?)Tq@xEY(+_h~Ga%j-$tCep&%38`&v@1~F1Jj`4nK}@Xgss>A*u{L z%95SpmSj2j#S!@^KRS6xW*@F9qmQ#>Pr66)zC%WjbF(NT4XH-46OEr0$u04jfVB08 zKDl=)+cPD1Ov_gSC{uY)Us^b{D4w9scD-#mB7^xGw{YMq@W~YeN94FZ5L}9P z2@Hg#{NPw7_+dpEL>+XC>uLTVjyib3bZc!x${GZho!8R7OhD^gzfW2@q?La3RJNqH(w4W#|Ng~hk~7~lIj37QC13yY zh;;vFiDUn%_*8m&_~gS@uC7gNN++`ZrR;+*a&=wVp5g51vUW0_S^rR;t7}Sy)2*5H zAIeDUoatjZVZHRdxvb>UCks0JRG}4CqU(>1L|JpLvMzU}Ki52!YxASm>i1hZ^TelLaNkrW@eWSHEN|k zbfi6V=bUrz+;h+QzB&8Hcs#Cx+N4P9UcoNWfJ&+CP zLRQeltjMEI(1N;@m3S_|O>sng#E~EkYzf}{Y?G^%K-<)y4Y}G7Xu}QKa4C|GmPaEq zC88L1UX?9%f$EAS&zVdvC?%a5_9aF&#im4N^rIrRY)e)Qe6R|pVNoj+`5IrG7M$?7 zV$pX^LNzCNBTqHrB$m3C!D0lu%1$c{c@4aocikMlSi4cqh|k2vZ9+s!+s*L zC@kd!G|FN)kUV|4?x$0G!6Z+3&)n=6P0mw0;#^VF%3ZZZ?NWit1E?F62`!jb-cCEQ z+8ngrC|`J2VT-j;WT-{e)C(HL3e4*|C3!dwt>naXaLXxHuAt<}^w)ob@-N40?=ei9 zS`c$YSC(=bHRkOFZ+}78;_we0iBY@A4An0P_w=z05k?0yARcq`P=3GUe;iommje%Z z)@}Q#<2WqG2k3d?S`O6G`x*Cnef$%CnV;p-V!7je4+*p$o_9={wCIScVl6n5WkY(X zBf*&z6wQ(Dn=G#?<=$Fk-UL}MTDE+j%BBIoxgW?!fk7GLg%=d*Bx5FX#0O^H09XpD zX0a~VsFP4M&HR`WKRK3#5y-N9G&;0k>U3z{SQ@%ctwq}`47~*>s9o|4XlPtf7pXyp zET#_m6fxvyEmJ5tXMKcINQTQ9{u|1JFRI?mDc-HH8|yU?E8O!C*EzI@e^gE0tHka-OSG(p4qzUaPC21m&QNrk%Q=x; zPB%$W!*MK^`>3dBzNIObBc__mF{BC>2jK{L!*&w6oMJ$M0Gxo@z@9`?a85H7o26Vg zNDphrS!YpTy*MJkNMoJ&j1=xfRZX!hFHh`c&^+PTchQhs(5R!Z(#FG<*K^dM?f>M{@O1xRgvs<${VSj{@>r+ z?YzA*x%&Fr#HZJ);_a`3smhrP)!<9df-U=jV6b&l*yrH3BRt33fxtNp(7px+EutIpq+h+V<+^4v<>WN|_=J|VL5PS`Ty2_l zz*D)+TU%Ba6l0#sF2^w3zE>6qKGqH5|4cT1Sm~hCQ~)$X#7?+Qu8uUPtF+38#*3uMjfKS;xNbWe^?j}fnme#ay)CsQ36E^3YXl4qD9K_i6Udv zP)pEi5b6Our20Cyp#L4cNcG582d{v5%q{bTU%ZY>H#P~N6+RP*__fc#zkAEODcs{#}7=)>v=olZoM2Db6YaGzbp=BF3*(*d|%6VzG%tN?&S5N z4lRM)KQb~h2*1q8NSbFCV3+B@F+weDdt~_}9R23*mH(8EV^DfC@DMD7{|dJs;$rdD z($B8}7T1?H`OW@~rOMC;mF^F#$-9-<-D40=r&~@(A%2w^F6*HXlf^Ehm_*^?(Zwj{ z!2{(Kj!uAb0(B_xJ8Ts|ASj3b`>$2$-xwc2e-qQVtnhyxkC literal 0 HcmV?d00001 diff --git a/Backend/src/schemas/__pycache__/promotion.cpython-312.pyc b/Backend/src/schemas/__pycache__/promotion.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..da73baaa13c48041b0924ce5ffa1f454ef60962a GIT binary patch literal 5966 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 NHaz*a00(XX{tKx%tE>P3 literal 0 HcmV?d00001 diff --git a/Backend/src/schemas/__pycache__/review.cpython-312.pyc b/Backend/src/schemas/__pycache__/review.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fc2f5e49928a2b45326f24993dcbf1d6b2e2c763 GIT binary patch literal 1083 zcmX|AJ8#rL5MDpdzPnr!k^}L`Lk4E}6qGHxQvc-7DGxP1tH?v>+`>O=c)o-W5k2)bg zl=1(Rddl7uDjx_V9$^~OBQ4fFJvKZ;Q??PAamg!bL??5EnXd>df&SSjay_eKxpwzb z^=^mxNfri98Z1KWr_KTwj(}$&JU=5K@-tv)K3#@1OC3K^M%v;@3h88ZM`5|TtW5hU z+~N!(S(*(YVy-63cUoD<6F&-y3kzxWjJ}wP4XfAhW4)fC6EK<13}&vC_UkNx z1vaxTVtmhUvofo!RX>{rmRHUr`t~Dgte*|64eldUpbC9QF&J?Us3Ks$g4 zAu=Zg@hl8r!Zn22Ix(ok5DK?nmMKmA7$}uhN@LEJBc<0UeY)&NJ&#Q(;{l~Y%_N7A zMwn!BkWxQMc!sUu%AkKASSTK(k!C{H5ELRB@MIxuejHnKHx;iqjL8Q3+f@#?NoK4`tXDaQU^ecQFgVKij9)q7(? zJ-(bHX=R-jwvp3NR6DACdGa`Ff6EsOt}lxTG}Y>)>i@2xY1&U+*UUc_(N26PBfqQx N&02AP6a3`){{ZE?DW3oU literal 0 HcmV?d00001 diff --git a/Backend/src/schemas/__pycache__/service_booking.cpython-312.pyc b/Backend/src/schemas/__pycache__/service_booking.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..238851cebc82601f4ab5aabd9c1a71b90013fdc8 GIT binary patch literal 3074 zcmb7G&2JmW6`%d!l3Y@vJ}6h8?kcilGmS~fc4DV>9b1yExOUW_N*t7^vDoa4sI|#m zW_Fp1L<*>Z3#dR3Mh~?M7^sd4tN=bbr~U!GNRf(4ixf@KL*ttasc7p{-`ibMlA#vO z0(?93=FQC8-|v0!4{dEKf#=GXC#_#62>BaMnolem>@;9-pHNaHRG?y6sE9?eA{C{I zT$BaWN#$5YDJqqCF%EN?#>$CGvX~TzNTvu??h+bj;v*?Muc!vv1Zb1{w5dR=f;P2J zo35vGZH=qyJ9TQJ zIB~0v?J_s7K1By|lAjzmT=up@S=oRFC$lzsVz z<$6y=+$;Elj$1}>BQE_Uv7u@U3wRnef#FED|VCbhkG}J@GY3plg#!aE$qed4v&sBo8jOm3s2FFcbi$eT3$OaMzf z>4k@iclrt8DKFk!pi*ut6~lvQbT!i`o3$_vg7y4? zZ$SUgz;EZfAg&VuzT;zZtD6w;OSs*TDVr$SCFg*Mx7 zP6Jkn4{Mm5ULDd#%#ZrA1n=>qp$ z%$TpqzZS?By;F~~53;`-`t8ti_aFOSUF)6tw~IV|(l;Q4F*x$>0laGv)E_Xd6yym< zMr>^~C;O6HtMCrk$pKd4tH4=}UEw?^DzeMUv%b1VQiKveincw6`ra7(#-EF+;GP4q zKz0>!qz}I4N%r?cpAT*HPObM$EvjFoGJi@9uB8T-gfCLVUv>?y4vnvOO{}ITHZ$Fe zsU6r#{Lb61W9uO$I-B>n;m6s$QK^<0hh9N|ZM8|1Mfm`z{*D_7`SGPjW~Rza3vxCp z#yM#Z6n+G3a-Duc*KMQ1blq2V9rCSK#&KHLA)d<7N?g~eW9m9bGQuM;MR*c~pV4*0 zhQx&m1SKAf0?1>tWykP>OE}hJpv2=GyBR-*({xzuO~; zEoC1cTb;bQG4TGvmBsV-CVu(-b@~0j#ucJy63Br zV~>Z|vS+s?aj0usA;Tv&`(AmLQhM9BrNmKnTT-*i&u@gzqMs)GT2HH0Q&9c?ZD(J@ zfp$ZaD{8BuBv_KgX$o5jwi?<_<23V73|fR*?*R6O4}|4AM!mvp@0#s_Y&MEZhc}8D z56V2#s^J#R3ice<(ewE*_1dG;qXxh`!7E=0i@Yz@T>2CXTcd5F#yPXix@JQ0=2^5# zn30aZ3{!prMGizxD*WxBN#qrpf1-9G}qN$Sb{ zq=tqE`+vkMke3FJ8oh_peQlDr!Lr{Ond@N~=uk;oO!;k9OCLLZ=Im>)pF97?=yWdm z-+B5T92OE2@N^hE*z`CG#Lz`1k)6|9LoY3zc|5i{{*#TNTdQ)$Z7v_Rx1gAX#U}`$ zz|Vt`^*nJQERYxZB{%~Ad$^dJ+mav%&qPs>|EUn++!}fPFJ$=7q_C^>3CdD#m%wZH Yd|FVJle+|7yGK>fzwr+OuOM#!1CQq;V*mgE literal 0 HcmV?d00001 diff --git a/Backend/src/schemas/__pycache__/user.cpython-312.pyc b/Backend/src/schemas/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ea65b8aed838d6881163df1115fab5572c650741 GIT binary patch literal 3211 zcmcguU2GIp6ux(6_P5(^yOb&|m7y)_V%Qb&52-|;#S#?>6+$*KlVR?q9hse3?#u$+ zplJ*yMSZ9*w!ZOL(wg|lqmL%Opt}!rq7TNz*cSs9qw?fAGqc+^N>YtF$(*_O{LMY* zeD~h-WqW&?z|*t*xVbY<$oDvDKCx)9dIkn_gpm?qlt~sXOC_l+m*lciQp&MXj3QsQ z;^jmsL5W0OCQP|XSd2@Ha_FO!47hRNCf0FNQ^|a)b|8IXis`mz8mend^0Mx#=NzF{ zT`u+rZs{Ips^Ax@-1S^lw{hZD9NXn?A-#hB^NOE3th@X*hjGh~9W}Yd{N!j^H?46` z_}O#F&@Sqh$#l;VdFm%$uXv_o>y{C&FQkxw;930%K68YV5{00pQxINBmIzmv#AK$h z*iw8^2~|rm9_I;`7=~%2kz7hGN|7|lQy@*RleV$UQhO^c>A*0q#t$=i5_ z$G3qq@Umr{RyW(a)YH;x2d$1s>!C<1%Q_ZmxC-9M9_DejjmOU^toN$27_v$8Tk4w1 z=KK7vBZ5P&PeIyGhS{$5jR*Az%3+|?v?o+Os7#@-fwoGv%RI6sh~s)B?6aSOPq8pt{xl7OTIMW zg$@$}c$&$4deM&y?p1|tM1=$sLa^kB=hYv8%n>h2FCkZGGu4cq2Mf{WMYtz~Nln?i z)NAf2!sM%9HPR|UQ^#FzsWiD8EMuFnjQ^!&ViT6hHOnh>oWSnvJ00#Dt~T?nx}FHi zTM%84iQ1#h@~Gvi>#1kCs(Y%%br?Ud8k4%v4UY@gXyq%)<37wK!u1tGA)i;sn^4EM z=@t6cdQFhy(**0sbdFN;VUI$l$fxopdV?N^{hTn}YNf)(4Jc>fNuL@n7EV>~A1F>b zWnP@HFBeC+d){*@#WBa@)}F(Ta~{xKJggh%xy_2MFpAOXQ^b>}P?@@)8lK=bzg!Uq zYtQ~^a}=WUglo;b6oxIw&@K01p=H+tt5`+yBl&Hbe4X2Ueedkvx&0sQpF8kXZujr2 z2&s2_x6|6bjX5+N+tW>ZEs==qAVLGqIY_Pt8U1e(Dz?L#!jxLhB`(AxFpttHcEVT; z0Qsj0c^DlGA{jz2n+ZEu#s3*LGbc>%-wnHWW7zpkR69DyWM77})K9}MtnyJI9O28l zJq72MY*?Vu)Yko%4E>v|3ZwZ(sG>tiirU~3MA`8Tq;Ux02nx@;f$a`d2*|og2cYfc( z-kV##c%rfO^z^YAx2~LC?&+I9vY<73UYs7CdA_c^xZJ&MesE#u&E7`$$n@BZQCCKm z2Oe8cZsxvtq%m-2`qkOT=l6ZIr>>k??jN}R^unQ=`y2hE(=X4QnKiDxSyx8Ci*?VI z>M`|ptn-ePj&)rhyhGr#F!(cmR%H?o$j-Y8Ov6>#dD+nJ?UtC0wr znV^wKvN)d1Oe&?IMM+1z2emnE*v3RU8=9S^Osf@wMuxXXI_;6~4&Y@YUeE+hN%^eb zb*ch=+5;PrDoWQiBF6=`u%Zn}vp)!~end!!1#AzXuL!sS8t`%e1|G~IC{m`Y!7XK8 zo}Hp4(Dsg+K*EtJK%M( zWFfM43|)WYGyU@;jU8ivtIPB9wYTfa*ll$PK{Y$}Ra<{u8H!-k7#Lmy(QpK!Mt@-q zLIpHLH_Ekl8p=@kfBft5d^(89PgbI~TVS^mh8S#{V0Xm5sS0E{j5pW}!MZobaz8m7 z?$(3iAo!s0x)|sknNoUJlBn`)oX|tGPG0(!^xcbh)2{i{Jp!M5g*e?cpZbNsCs^Ab D3WE_p literal 0 HcmV?d00001 diff --git a/Backend/src/schemas/booking.py b/Backend/src/schemas/booking.py new file mode 100644 index 00000000..b42c25e2 --- /dev/null +++ b/Backend/src/schemas/booking.py @@ -0,0 +1,167 @@ +""" +Pydantic schemas for booking-related requests and responses. +""" +from pydantic import BaseModel, Field, field_validator, model_validator +from typing import Optional, List +from datetime import datetime + + +class ServiceItemSchema(BaseModel): + """Schema for service items in a booking.""" + service_id: int = Field(..., gt=0, description="Service ID") + quantity: int = Field(1, gt=0, le=100, description="Quantity of service") + + +class InvoiceInfoSchema(BaseModel): + """Schema for invoice information.""" + company_name: Optional[str] = Field(None, max_length=200) + company_address: Optional[str] = Field(None, max_length=500) + company_tax_id: Optional[str] = Field(None, max_length=50) + customer_tax_id: Optional[str] = Field(None, max_length=50) + notes: Optional[str] = Field(None, max_length=1000) + terms_and_conditions: Optional[str] = None + payment_instructions: Optional[str] = None + + +class CreateBookingRequest(BaseModel): + """Schema for creating a booking.""" + room_id: int = Field(..., gt=0, description="Room ID") + check_in_date: str = Field(..., description="Check-in date (YYYY-MM-DD or ISO format)") + check_out_date: str = Field(..., description="Check-out date (YYYY-MM-DD or ISO format)") + total_price: float = Field(..., gt=0, description="Total booking price") + guest_count: int = Field(1, gt=0, le=20, description="Number of guests") + notes: Optional[str] = Field(None, max_length=1000, description="Special requests/notes") + payment_method: str = Field("cash", description="Payment method (cash, stripe, paypal)") + promotion_code: Optional[str] = Field(None, max_length=50) + referral_code: Optional[str] = Field(None, max_length=50) + services: Optional[List[ServiceItemSchema]] = Field(default_factory=list) + invoice_info: Optional[InvoiceInfoSchema] = None + + @field_validator('check_in_date', 'check_out_date') + @classmethod + def validate_date_format(cls, v: str) -> str: + """Validate date format.""" + try: + # Try ISO format first + if 'T' in v or 'Z' in v or '+' in v: + datetime.fromisoformat(v.replace('Z', '+00:00')) + else: + # Try simple date format + datetime.strptime(v, '%Y-%m-%d') + return v + except (ValueError, TypeError): + raise ValueError('Invalid date format. Use YYYY-MM-DD or ISO format.') + + @field_validator('payment_method') + @classmethod + def validate_payment_method(cls, v: str) -> str: + """Validate payment method.""" + allowed_methods = ['cash', 'stripe', 'paypal', 'borica'] + if v not in allowed_methods: + raise ValueError(f'Payment method must be one of: {", ".join(allowed_methods)}') + return v + + @model_validator(mode='after') + def validate_dates(self): + """Validate that check-out is after check-in.""" + check_in = self.check_in_date + check_out = self.check_out_date + + if check_in and check_out: + try: + # Parse dates + if 'T' in check_in or 'Z' in check_in or '+' in check_in: + check_in_dt = datetime.fromisoformat(check_in.replace('Z', '+00:00')) + else: + check_in_dt = datetime.strptime(check_in, '%Y-%m-%d') + + if 'T' in check_out or 'Z' in check_out or '+' in check_out: + check_out_dt = datetime.fromisoformat(check_out.replace('Z', '+00:00')) + else: + check_out_dt = datetime.strptime(check_out, '%Y-%m-%d') + + if check_in_dt >= check_out_dt: + raise ValueError('Check-out date must be after check-in date') + except (ValueError, TypeError) as e: + if 'Check-out date' in str(e): + raise + raise ValueError('Invalid date format') + + return self + + model_config = { + "json_schema_extra": { + "example": { + "room_id": 1, + "check_in_date": "2024-12-25", + "check_out_date": "2024-12-30", + "total_price": 500.00, + "guest_count": 2, + "payment_method": "cash", + "notes": "Late check-in requested" + } + } + } + + +class UpdateBookingRequest(BaseModel): + """Schema for updating a booking.""" + status: Optional[str] = Field(None, description="Booking status") + check_in_date: Optional[str] = Field(None, description="Check-in date") + check_out_date: Optional[str] = Field(None, description="Check-out date") + guest_count: Optional[int] = Field(None, gt=0, le=20) + notes: Optional[str] = Field(None, max_length=1000) + total_price: Optional[float] = Field(None, gt=0) + + @field_validator('check_in_date', 'check_out_date') + @classmethod + def validate_date_format(cls, v: Optional[str]) -> Optional[str]: + """Validate date format if provided.""" + if v: + try: + if 'T' in v or 'Z' in v or '+' in v: + datetime.fromisoformat(v.replace('Z', '+00:00')) + else: + datetime.strptime(v, '%Y-%m-%d') + return v + except (ValueError, TypeError): + raise ValueError('Invalid date format. Use YYYY-MM-DD or ISO format.') + return v + + @field_validator('status') + @classmethod + def validate_status(cls, v: Optional[str]) -> Optional[str]: + """Validate booking status.""" + if v: + allowed_statuses = ['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled'] + if v not in allowed_statuses: + raise ValueError(f'Status must be one of: {", ".join(allowed_statuses)}') + return v + + @model_validator(mode='after') + def validate_dates(self): + """Validate dates if both are provided.""" + check_in = self.check_in_date + check_out = self.check_out_date + + if check_in and check_out: + try: + if 'T' in check_in or 'Z' in check_in or '+' in check_in: + check_in_dt = datetime.fromisoformat(check_in.replace('Z', '+00:00')) + else: + check_in_dt = datetime.strptime(check_in, '%Y-%m-%d') + + if 'T' in check_out or 'Z' in check_out or '+' in check_out: + check_out_dt = datetime.fromisoformat(check_out.replace('Z', '+00:00')) + else: + check_out_dt = datetime.strptime(check_out, '%Y-%m-%d') + + if check_in_dt >= check_out_dt: + raise ValueError('Check-out date must be after check-in date') + except (ValueError, TypeError) as e: + if 'Check-out date' in str(e): + raise + raise ValueError('Invalid date format') + + return self + diff --git a/Backend/src/schemas/invoice.py b/Backend/src/schemas/invoice.py new file mode 100644 index 00000000..eff7a436 --- /dev/null +++ b/Backend/src/schemas/invoice.py @@ -0,0 +1,77 @@ +""" +Pydantic schemas for invoice-related requests and responses. +""" +from pydantic import BaseModel, Field +from typing import Optional + + +class CreateInvoiceRequest(BaseModel): + """Schema for creating an invoice.""" + booking_id: int = Field(..., gt=0, description="Booking ID") + tax_rate: float = Field(0.0, ge=0, le=100, description="Tax rate percentage") + discount_amount: float = Field(0.0, ge=0, description="Discount amount") + due_days: int = Field(30, ge=1, le=365, description="Number of days until due") + company_name: Optional[str] = Field(None, max_length=200) + company_address: Optional[str] = Field(None, max_length=500) + company_phone: Optional[str] = Field(None, max_length=50) + company_email: Optional[str] = Field(None, max_length=255) + company_tax_id: Optional[str] = Field(None, max_length=50) + company_logo_url: Optional[str] = Field(None, max_length=500) + customer_tax_id: Optional[str] = Field(None, max_length=50) + notes: Optional[str] = Field(None, max_length=1000) + terms_and_conditions: Optional[str] = None + payment_instructions: Optional[str] = None + + model_config = { + "json_schema_extra": { + "example": { + "booking_id": 1, + "tax_rate": 10.0, + "discount_amount": 0.0, + "due_days": 30, + "company_name": "Hotel Name", + "company_address": "123 Main St", + "notes": "Payment due within 30 days" + } + } + } + + +class UpdateInvoiceRequest(BaseModel): + """Schema for updating an invoice.""" + company_name: Optional[str] = Field(None, max_length=200) + company_address: Optional[str] = Field(None, max_length=500) + company_phone: Optional[str] = Field(None, max_length=50) + company_email: Optional[str] = Field(None, max_length=255) + company_tax_id: Optional[str] = Field(None, max_length=50) + company_logo_url: Optional[str] = Field(None, max_length=500) + customer_tax_id: Optional[str] = Field(None, max_length=50) + notes: Optional[str] = Field(None, max_length=1000) + terms_and_conditions: Optional[str] = None + payment_instructions: Optional[str] = None + tax_rate: Optional[float] = Field(None, ge=0, le=100) + discount_amount: Optional[float] = Field(None, ge=0) + status: Optional[str] = None + + model_config = { + "json_schema_extra": { + "example": { + "notes": "Updated notes", + "status": "paid" + } + } + } + + +class MarkInvoicePaidRequest(BaseModel): + """Schema for marking an invoice as paid.""" + amount: Optional[float] = Field(None, gt=0, description="Payment amount (optional, defaults to full amount)") + + model_config = { + "json_schema_extra": { + "example": { + "amount": 500.00 + } + } + } + diff --git a/Backend/src/schemas/page_content.py b/Backend/src/schemas/page_content.py new file mode 100644 index 00000000..c8f46f29 --- /dev/null +++ b/Backend/src/schemas/page_content.py @@ -0,0 +1,110 @@ +""" +Pydantic schemas for page content-related requests and responses. +""" +from pydantic import BaseModel, Field, field_validator +from typing import Optional, Dict, Any, List, Union +import json + + +class PageContentUpdateRequest(BaseModel): + """Schema for updating page content.""" + title: Optional[str] = Field(None, max_length=500) + subtitle: Optional[str] = Field(None, max_length=1000) + description: Optional[str] = Field(None, max_length=5000) + content: Optional[str] = None + meta_title: Optional[str] = Field(None, max_length=200) + meta_description: Optional[str] = Field(None, max_length=500) + meta_keywords: Optional[str] = Field(None, max_length=500) + og_title: Optional[str] = Field(None, max_length=200) + og_description: Optional[str] = Field(None, max_length=500) + og_image: Optional[str] = Field(None, max_length=1000) + canonical_url: Optional[str] = Field(None, max_length=1000) + contact_info: Optional[Union[str, Dict[str, Any]]] = None + map_url: Optional[str] = Field(None, max_length=1000) + social_links: Optional[Union[str, Dict[str, Any], List[Dict[str, Any]]]] = None + footer_links: Optional[Union[str, Dict[str, Any], List[Dict[str, Any]]]] = None + badges: Optional[Union[str, List[Dict[str, Any]]]] = None + hero_title: Optional[str] = Field(None, max_length=500) + hero_subtitle: Optional[str] = Field(None, max_length=1000) + hero_image: Optional[str] = Field(None, max_length=1000) + story_content: Optional[str] = None + values: Optional[Union[str, List[Dict[str, Any]]]] = None + features: Optional[Union[str, List[Dict[str, Any]]]] = None + about_hero_image: Optional[str] = Field(None, max_length=1000) + mission: Optional[str] = Field(None, max_length=2000) + vision: Optional[str] = Field(None, max_length=2000) + team: Optional[Union[str, List[Dict[str, Any]]]] = None + timeline: Optional[Union[str, List[Dict[str, Any]]]] = None + achievements: Optional[Union[str, List[Dict[str, Any]]]] = None + amenities_section_title: Optional[str] = Field(None, max_length=500) + amenities_section_subtitle: Optional[str] = Field(None, max_length=1000) + amenities: Optional[Union[str, List[Dict[str, Any]]]] = None + testimonials_section_title: Optional[str] = Field(None, max_length=500) + testimonials_section_subtitle: Optional[str] = Field(None, max_length=1000) + testimonials: Optional[Union[str, List[Dict[str, Any]]]] = None + gallery_section_title: Optional[str] = Field(None, max_length=500) + gallery_section_subtitle: Optional[str] = Field(None, max_length=1000) + gallery_images: Optional[Union[str, List[str]]] = None + luxury_section_title: Optional[str] = Field(None, max_length=500) + luxury_section_subtitle: Optional[str] = Field(None, max_length=1000) + luxury_section_image: Optional[str] = Field(None, max_length=1000) + luxury_features: Optional[Union[str, List[Dict[str, Any]]]] = None + luxury_gallery_section_title: Optional[str] = Field(None, max_length=500) + luxury_gallery_section_subtitle: Optional[str] = Field(None, max_length=1000) + luxury_gallery: Optional[Union[str, List[Dict[str, Any]]]] = None + luxury_testimonials_section_title: Optional[str] = Field(None, max_length=500) + luxury_testimonials_section_subtitle: Optional[str] = Field(None, max_length=1000) + luxury_testimonials: Optional[Union[str, List[Dict[str, Any]]]] = None + about_preview_title: Optional[str] = Field(None, max_length=500) + about_preview_subtitle: Optional[str] = Field(None, max_length=1000) + about_preview_content: Optional[str] = None + about_preview_image: Optional[str] = Field(None, max_length=1000) + stats: Optional[Union[str, List[Dict[str, Any]]]] = None + luxury_services_section_title: Optional[str] = Field(None, max_length=500) + luxury_services_section_subtitle: Optional[str] = Field(None, max_length=1000) + luxury_services: Optional[Union[str, List[Dict[str, Any]]]] = None + luxury_experiences_section_title: Optional[str] = Field(None, max_length=500) + luxury_experiences_section_subtitle: Optional[str] = Field(None, max_length=1000) + luxury_experiences: Optional[Union[str, List[Dict[str, Any]]]] = None + awards_section_title: Optional[str] = Field(None, max_length=500) + awards_section_subtitle: Optional[str] = Field(None, max_length=1000) + awards: Optional[Union[str, List[Dict[str, Any]]]] = None + cta_title: Optional[str] = Field(None, max_length=500) + cta_subtitle: Optional[str] = Field(None, max_length=1000) + cta_button_text: Optional[str] = Field(None, max_length=200) + cta_button_link: Optional[str] = Field(None, max_length=1000) + cta_image: Optional[str] = Field(None, max_length=1000) + partners_section_title: Optional[str] = Field(None, max_length=500) + partners_section_subtitle: Optional[str] = Field(None, max_length=1000) + partners: Optional[Union[str, List[Dict[str, Any]]]] = None + copyright_text: Optional[str] = Field(None, max_length=500) + is_active: Optional[bool] = True + + @field_validator('contact_info', 'social_links', 'footer_links', 'badges', 'values', + 'features', 'amenities', 'testimonials', 'gallery_images', 'stats', + 'luxury_features', 'luxury_gallery', 'luxury_testimonials', + 'luxury_services', 'luxury_experiences', 'awards', 'partners', + 'team', 'timeline', 'achievements', mode='before') + @classmethod + def validate_json_fields(cls, v): + """Validate and parse JSON string fields.""" + if v is None: + return None + if isinstance(v, str): + try: + return json.loads(v) + except json.JSONDecodeError: + raise ValueError(f'Invalid JSON format: {v}') + return v + + model_config = { + "json_schema_extra": { + "example": { + "title": "Welcome to Our Hotel", + "subtitle": "Experience luxury like never before", + "description": "A beautiful hotel description", + "is_active": True + } + } + } + diff --git a/Backend/src/schemas/payment.py b/Backend/src/schemas/payment.py new file mode 100644 index 00000000..b7051e0f --- /dev/null +++ b/Backend/src/schemas/payment.py @@ -0,0 +1,55 @@ +""" +Pydantic schemas for payment-related requests and responses. +""" +from pydantic import BaseModel, Field, field_validator +from typing import Optional + + +class CreatePaymentRequest(BaseModel): + """Schema for creating a payment.""" + booking_id: int = Field(..., gt=0, description="Booking ID") + amount: float = Field(..., gt=0, le=999999.99, description="Payment amount") + payment_method: str = Field(..., description="Payment method") + payment_type: str = Field("full", description="Payment type (full, deposit)") + mark_as_paid: Optional[bool] = Field(False, description="Mark payment as completed immediately") + notes: Optional[str] = Field(None, max_length=1000, description="Payment notes") + + @field_validator('payment_method') + @classmethod + def validate_payment_method(cls, v: str) -> str: + """Validate payment method.""" + allowed_methods = ['cash', 'stripe', 'paypal', 'borica'] + if v not in allowed_methods: + raise ValueError(f'Payment method must be one of: {", ".join(allowed_methods)}') + return v + + +class UpdatePaymentStatusRequest(BaseModel): + """Schema for updating payment status.""" + status: str = Field(..., description="New payment status") + notes: Optional[str] = Field(None, max_length=1000, description="Status change notes") + + @field_validator('status') + @classmethod + def validate_status(cls, v: str) -> str: + """Validate payment status.""" + allowed_statuses = ['pending', 'completed', 'failed', 'refunded', 'cancelled'] + if v not in allowed_statuses: + raise ValueError(f'Status must be one of: {", ".join(allowed_statuses)}') + return v + + +class CreateStripePaymentIntentRequest(BaseModel): + """Schema for creating a Stripe payment intent.""" + booking_id: int = Field(..., gt=0, description="Booking ID") + amount: float = Field(..., gt=0, le=999999.99, description="Payment amount") + currency: Optional[str] = Field("usd", description="Currency code") + + @field_validator('amount') + @classmethod + def validate_amount(cls, v: float) -> float: + """Validate amount doesn't exceed Stripe limit.""" + if v > 999999.99: + raise ValueError(f"Amount ${v:,.2f} exceeds Stripe's maximum of $999,999.99") + return v + diff --git a/Backend/src/schemas/promotion.py b/Backend/src/schemas/promotion.py new file mode 100644 index 00000000..6156cead --- /dev/null +++ b/Backend/src/schemas/promotion.py @@ -0,0 +1,122 @@ +""" +Pydantic schemas for promotion-related requests and responses. +""" +from pydantic import BaseModel, Field, field_validator +from typing import Optional +from datetime import datetime + + +class ValidatePromotionRequest(BaseModel): + """Schema for validating a promotion code.""" + code: str = Field(..., min_length=1, max_length=50, description="Promotion code") + booking_value: Optional[float] = Field(None, ge=0, description="Booking value/amount") + booking_amount: Optional[float] = Field(None, ge=0, description="Booking amount (alias for booking_value)") + + @field_validator('code') + @classmethod + def validate_code(cls, v: str) -> str: + """Validate promotion code format.""" + if not v or not v.strip(): + raise ValueError("Promotion code cannot be empty") + return v.strip().upper() + + model_config = { + "json_schema_extra": { + "example": { + "code": "SUMMER2024", + "booking_value": 500.00 + } + } + } + + +class CreatePromotionRequest(BaseModel): + """Schema for creating a promotion.""" + code: str = Field(..., min_length=1, max_length=50, description="Promotion code") + name: str = Field(..., min_length=1, max_length=200, description="Promotion name") + description: Optional[str] = Field(None, max_length=1000) + discount_type: str = Field(..., description="Discount type: 'percentage' or 'fixed'") + discount_value: float = Field(..., gt=0, description="Discount value") + min_booking_amount: Optional[float] = Field(None, ge=0) + max_discount_amount: Optional[float] = Field(None, ge=0) + start_date: Optional[str] = Field(None, description="Start date (ISO format)") + end_date: Optional[str] = Field(None, description="End date (ISO format)") + usage_limit: Optional[int] = Field(None, ge=1) + status: Optional[str] = Field("active", description="Status: 'active' or 'inactive'") + + @field_validator('discount_type') + @classmethod + def validate_discount_type(cls, v: str) -> str: + """Validate discount type.""" + if v not in ['percentage', 'fixed']: + raise ValueError("Discount type must be 'percentage' or 'fixed'") + return v + + @field_validator('discount_value') + @classmethod + def validate_discount_value(cls, v: float, info) -> float: + """Validate discount value based on type.""" + if 'discount_type' in info.data and info.data['discount_type'] == 'percentage': + if v > 100: + raise ValueError("Percentage discount cannot exceed 100%") + return v + + @field_validator('code') + @classmethod + def validate_code(cls, v: str) -> str: + """Validate promotion code format.""" + if not v or not v.strip(): + raise ValueError("Promotion code cannot be empty") + return v.strip().upper() + + model_config = { + "json_schema_extra": { + "example": { + "code": "SUMMER2024", + "name": "Summer Sale", + "description": "20% off all bookings", + "discount_type": "percentage", + "discount_value": 20.0, + "min_booking_amount": 100.0, + "max_discount_amount": 500.0, + "start_date": "2024-06-01T00:00:00Z", + "end_date": "2024-08-31T23:59:59Z", + "usage_limit": 100, + "status": "active" + } + } + } + + +class UpdatePromotionRequest(BaseModel): + """Schema for updating a promotion.""" + code: Optional[str] = Field(None, min_length=1, max_length=50) + name: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = Field(None, max_length=1000) + discount_type: Optional[str] = None + discount_value: Optional[float] = Field(None, gt=0) + min_booking_amount: Optional[float] = Field(None, ge=0) + max_discount_amount: Optional[float] = Field(None, ge=0) + start_date: Optional[str] = None + end_date: Optional[str] = None + usage_limit: Optional[int] = Field(None, ge=1) + status: Optional[str] = None + + @field_validator('discount_type') + @classmethod + def validate_discount_type(cls, v: Optional[str]) -> Optional[str]: + """Validate discount type if provided.""" + if v and v not in ['percentage', 'fixed']: + raise ValueError("Discount type must be 'percentage' or 'fixed'") + return v + + model_config = { + "json_schema_extra": { + "example": { + "name": "Updated Summer Sale", + "discount_value": 25.0, + "status": "active" + } + } + } + diff --git a/Backend/src/schemas/review.py b/Backend/src/schemas/review.py new file mode 100644 index 00000000..3604d123 --- /dev/null +++ b/Backend/src/schemas/review.py @@ -0,0 +1,23 @@ +""" +Pydantic schemas for review-related requests and responses. +""" +from pydantic import BaseModel, Field +from typing import Optional + + +class CreateReviewRequest(BaseModel): + """Schema for creating a review.""" + room_id: int = Field(..., gt=0, description="Room ID") + rating: int = Field(..., ge=1, le=5, description="Rating from 1 to 5") + comment: Optional[str] = Field(None, max_length=2000, description="Review comment") + + model_config = { + "json_schema_extra": { + "example": { + "room_id": 1, + "rating": 5, + "comment": "Great room, excellent service!" + } + } + } + diff --git a/Backend/src/schemas/service_booking.py b/Backend/src/schemas/service_booking.py new file mode 100644 index 00000000..e960f6e2 --- /dev/null +++ b/Backend/src/schemas/service_booking.py @@ -0,0 +1,63 @@ +""" +Pydantic schemas for service booking-related requests and responses. +""" +from pydantic import BaseModel, Field, model_validator +from typing import Optional, List +from ..schemas.booking import ServiceItemSchema + + +class CreateServiceBookingRequest(BaseModel): + """Schema for creating a service booking.""" + services: List[ServiceItemSchema] = Field(..., min_length=1, description="List of services to book") + total_amount: float = Field(..., gt=0, description="Total amount for the booking") + notes: Optional[str] = Field(None, max_length=1000, description="Additional notes") + + @model_validator(mode='after') + def validate_total_amount(self): + """Validate that total amount matches calculated total.""" + calculated = sum(item.quantity * 1.0 for item in self.services) # Will be validated against service prices in route + # Note: We can't validate exact amount here without service prices, but we validate structure + if self.total_amount <= 0: + raise ValueError("Total amount must be greater than 0") + return self + + model_config = { + "json_schema_extra": { + "example": { + "services": [ + {"service_id": 1, "quantity": 2} + ], + "total_amount": 100.00, + "notes": "Special request" + } + } + } + + +class CreateServicePaymentIntentRequest(BaseModel): + """Schema for creating a Stripe payment intent for service booking.""" + amount: float = Field(..., gt=0, description="Payment amount") + currency: str = Field("usd", max_length=3, description="Currency code") + + model_config = { + "json_schema_extra": { + "example": { + "amount": 100.00, + "currency": "usd" + } + } + } + + +class ConfirmServicePaymentRequest(BaseModel): + """Schema for confirming a service payment.""" + payment_intent_id: str = Field(..., min_length=1, description="Stripe payment intent ID") + + model_config = { + "json_schema_extra": { + "example": { + "payment_intent_id": "pi_1234567890" + } + } + } + diff --git a/Backend/src/schemas/user.py b/Backend/src/schemas/user.py new file mode 100644 index 00000000..b0b55f2e --- /dev/null +++ b/Backend/src/schemas/user.py @@ -0,0 +1,38 @@ +""" +Pydantic schemas for user-related requests and responses. +""" +from pydantic import BaseModel, Field, EmailStr, field_validator +from typing import Optional + + +class CreateUserRequest(BaseModel): + """Schema for creating a user.""" + full_name: str = Field(..., min_length=2, max_length=100, description="Full name") + email: EmailStr = Field(..., description="Email address") + password: str = Field(..., min_length=8, description="Password") + phone_number: Optional[str] = Field(None, max_length=20, description="Phone number") + role_id: Optional[int] = Field(None, gt=0, description="Role ID") + + @field_validator('password') + @classmethod + def validate_password(cls, v: str) -> str: + """Validate password strength.""" + if len(v) < 8: + raise ValueError('Password must be at least 8 characters') + if not any(c.isupper() for c in v): + raise ValueError('Password must contain at least one uppercase letter') + if not any(c.islower() for c in v): + raise ValueError('Password must contain at least one lowercase letter') + if not any(c.isdigit() for c in v): + raise ValueError('Password must contain at least one number') + return v + + +class UpdateUserRequest(BaseModel): + """Schema for updating a user.""" + full_name: Optional[str] = Field(None, min_length=2, max_length=100) + email: Optional[EmailStr] = None + phone_number: Optional[str] = Field(None, max_length=20) + role_id: Optional[int] = Field(None, gt=0) + is_active: Optional[bool] = None + diff --git a/Backend/src/services/__pycache__/audit_service.cpython-312.pyc b/Backend/src/services/__pycache__/audit_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eb68629c79483f201570941fb7f22093a34b1d2b GIT binary patch literal 2231 zcmah~Uu+ab7@ytQ-MhWs6(|CwSUE+IduZ=L5+hMV3oSG)l|WT`AnUO^J(lj?wzGTm zu51Cd4>U1>#0N;w_nKnDV-tN4o=AK-A`d#DkwhQl$wD;}^uh1j`_syclkE5XzL{@+ zGxN>&&3>OsB?z=%kEiW6m5^W1Xf&k`R;B@5BNnkFhqzKsk`T*|?9v={m7L#+*yaWep# z-b4vNai%hTOh%u`uLYQr4Ue(SR%%vlo;FdDGV4S#+Ve5S&)Fs$7P56(Xn7NlD^i^K zUWuE`2r3gy#7aKnhHZ(KWSu^?|dzR>(2 zAC!ENVw`*2aA6X7n=P>2MoR+t6uy-axVJ$fL5y?~)bEgxkcLtyhf1i1S{MuCp&lk~ zN()h55i-*flC!iywW`Br7DxmCZB^#)qimK;hqh($yB2KiE{?}frH zVwUoSc2jL+mP9IK2MOx?BIH%V-=0aX;ZCh`Hjt$S>l!#rHu4tMk*(OxX3j<@*wFA> z)vG5Mg^HO;UPxBzdPUeMBu75phGHX%7f@_Mu^Gh{ z5Yro@L`g$Rr%lcv^R2zeDD~Od%!!C|_8J%PF1%=!an(4W&9SE&fPeMTtq4$+d78NGYV?GJ$2VBIt=QyYH z=6EK>G01E77H#YQk1F(NS6ID5Vm^|j*S2SU*)yKYvV~$fdw}`lfj5yo>h-*F zyI9Ef!t$(WWqodDp=hwQ{A`W1Zgx*pL;@BtYPB}KC5mpn47*ncFoFDsAbulHFOg*> zrguDOPtR{z+gdUPM?S1sla=c9%)QAAkp-bVD(5hwC*)HyhB68>a6lKAr*Y)G zd;v$ed=aeL$Wizi;v9M^3O_Z#s*QMb3P;K`L^VVADWH0lK2$TbPXVGb?MLO&gV0%< z^}aIN#DNk<&dNR&yaJ_1eS%&qlxS_yA$fNr0>khFY4s2!Ib&u>?BArX*wI$O1lfLolp&#_lOWI#bi`4O$ QplBx>IzMmyI2Ez}3*BHGd;kCd literal 0 HcmV?d00001 diff --git a/Backend/src/services/__pycache__/auth_service.cpython-312.pyc b/Backend/src/services/__pycache__/auth_service.cpython-312.pyc index febaf1cd7399cca56bd8b2b8fb3f0bf67e0e65a3..bc3035a0650d816c8352d54c6c0dc262d311ec21 100644 GIT binary patch delta 6901 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 Iw(#x$2K`QJ{{R30 diff --git a/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc b/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc index eae712e16b66b99b692a78e3eed63481e7d4a8ec..9dc912e08f8daca8b381301e0c835046561f9b28 100644 GIT binary patch delta 6070 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 delta 5345 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 diff --git a/Backend/src/services/__pycache__/paypal_service.cpython-312.pyc b/Backend/src/services/__pycache__/paypal_service.cpython-312.pyc index 18ebf6f07844954709e315cef1be409329a45c1c..c61e52cbafb0aeace3f4be83ddb1220462eb0685 100644 GIT binary patch delta 2982 zcmZ`*eN0=|6@S;yHW=G{*#?XWI1q>*31oqUqBWs5Uj~PeG+WhTl2{MFhcUe8=iK)k zK1NFwWm3CEYSY`MO`EFuYf6=BL-Mw6TGA%c{;1SRrJ6KpPg(a>ZPlbj(6wq6RnzXd zj|8G>R`lb1-E;0a_nh-`_a^$~P2~O1>n-ExZ|F`Wd*h-vhv(4&g%%`aa^)lDwi9j3fUvK1SvmlmnzTOle?(&dk>4NsJM00xP_WkQ@%~Ap%RbW z2uGP#r0= z51x`DylqsVgV%5vN60tMMn@-Qg7n0* zsJ(c#WMcc7zFlylz@vh52Xg^82k2S#!F$85s%sXxcwgaGxeC&?6LqPTbJVNZAojW3wG^@4+Nh?`7 zXW&CrzNgslSw~KGq@mbbuGvuwxmMldjKcXi`Lz0sa}3C%WYRZ)PLQ{J2Q6KUn7)V; zeBfSE>K`9|63C|jAaRq|4Q!Uoh`MMP7$-qK33B_auH{UJR8aG};Kfl;!~psMLgb3S z3r&!Z{Ou@0?)f{=Krs?{#8J8B-8w6>>(3 zmH_8LdWM4TrV!rx(GEHi+Kt4b9_qDE4}w_%;4A<%nc!d%&9bzZHwsuzWhJY=QLtb& z+bV$HDnKcyP62FHfxQWo0xy!PaNP>aItu)Qg5V`!|8MFQ-%1_Kw=#bQ*eC$I6$`jR zS+ivG>i7qsv`PU3tHW4qm4Z>U9=F5$P@%XHoCyo5#XcbzkOeaegveSg6T?Y zYF?2IS+YtPB~?*B?cBC#6#a%|qHm&F;)tcuS(1%CLGk_Aqrz1<$d4xn@oPXi;sseZ zmiX~~u_ae)f4EiJ8*ZHt%JKI}OaBj@&r=)xI{9h;ztPL&%0L3WLjF0h@ZA@vSa6tS zSv{N64KbINaWz=_0N`ruaRp?S%DIA?(xzs0M5VB|E)OP94f%BNz;_OV9W##a&m^2M#R}@)-Hpoq9u}a(E=b-Wwj4XApidjX*_!6m%htR8J zZ@eK=2Wt0bc&urv)dc1G8AVjmGqSqasbLjgCi!@ydnYxvhV%_`IUe$?!eM((RAt&6 zx8ecxGxCRc$L=DC9Wq2Soz}O_w8N8Fo7h3GpkX{s+D97E4Kg|s^4;8ii-JxK-Xrr= z`#!leBJ|je#k_2mrcw|ol``#E&SRl_+Y*3hGClYA5-p5~72+GET~8I((7+R!KKgVj zEg0Q|qEXOO(2Lf)z#E%ohGx)@9#nVp3CQATZ2A&7c@+Sv#R`Nk1NAom_C>9>zG0~X zR#Lib7};C~zfQGgWx9Z|oJ%i05W`B^E?!ipG)2e1BH=`B%|Ad{4)2hHq*=!JU2-(B zukr>2gF@l=06rrZ5}lR51=8AMl+wG&Cy5>#ovFGb-BlMsa;==>KI6WAmRq+!UOahZ zXSv<(iX-yUL`Qq|3uDiZt%VM*W>+WIg0T+<*JO2_F!NyY0z8~b^qjoUZ?l5@#3-A?|CeN_dH|B~~}JNYj?v4iu7 z{|Y%M^OcjIcOijiUzG82%vMGPzDL?7_a0>npMB=aASE||2LRSH#sF+^VT4USOMAYG zUgT{Kvz-wEJsWX=7Xfo94-+Yp2AuDS0h3yEZlZ z@u|7bE$M%F&RM--V-}Ww=9=ACAb&ae(#qc<9((uzo3u`J|LF3)IP!bf4s!N%rYg;&IQpDp If0&d10Z$t7MgRZ+ delta 3028 zcmai0eQaA-6@S-XiDNrXVmonTCv}pxiN})WtLZVqY!73OUj1RS2M>pUP?zzuN zT1|+jm-jHmP%!}T)&Q~#%)Et&Hr^-&n8q!K(Uk?U zFnz@ddwi2c7!V}i2|@C|U@0VOoF&mpkjS}wyXAL;R6&`omsG<(8?`sFDc<~f+uw+* zmWi|qVLdE4f?zm8l^fYm!!VHuZDc=2a6;A-aN6N_&}KFc!g<>#;NGJBwkR9rMf*Ae zKP~S1Sgd+yh3A%7(Bll)+nG-tj5-&%H;DGi{B@^JNGqgV_pHaI@gx-IQ7^&PV00Bn{r!z(0!FSxJxMCU7E3xdU&y7 zt79kn_rtpt(~c1o?*h@+OZLLId|OOg46gW^92kRcgHr#V{`*lpfY89euxdIrijIn! zsOyv-M136fwn;6MHtbSP&1z8(<#}Rg?Lnx9Z~EKG1Mr6bHq!V+pqcE-w*gmgUYw>Qv(urnbDJW`Vv+jiI+@P0 zVkg2)wt=}0(+010byx@II^lX(0~vx~cQZKv{oTVZP8;9uBk)}JP6w`>?u0AdAGUrT zMIQshnarqJMb>4>oq z9P6I=Zu&I5(^Et41N*MeIZk4VXW-PXzY~Dty~E^5_*L(*?O(?DcB3SvrP7)%rjs%) z$C(ufxFU*$juN9-PUqA_W^7W!*33E`JiL3D1mG9DJHvP4JUo=qV)L$;TSM)mP~KMs z&3zHiL#};POv+k;_ft3&B90#=vu@Q=BtOlM~37f^#N8@BPBqR<~e zZ+|m64%7Yq0HEzjv{^-FI*Y?uc(#8dtLVA@z(@d1&Lpy?D6)jbOOL0h%8HjqlRKxU z6s<)SQ;JOKS5Xx(RWj-ly3A_Lx|Bwrg2+I4ZxzmPsToOTUNnC>nw)ut6uNgkEe z*_I4d=^`pE8O`wWd=o92tm~=tgl6{t2s87l(P!aH1EKntaJnrmsxrNgi#biEh7)(2 z;My1A*8|Os-$$FBFU)W#nTZKD$(BlwXUzU>XFJGHDnspHEqMjH21C9dqsPi}F(_Sv zDQ0~Y9vh7AbLuPQHcD6!Rn!ymv7}+sXS1?VoZzVw3ByL^EH&&|no8@@EjKj_EVAKV z>1nK)i*yS9GPo&*#iINkVSx=_p--_cD(X2cf#=k$DxMIdM9=8#9hVhZS2XJh_YCz^ zV=?Gc2uM2Tne$5Dg6|B~1fN0IvSf~JIGvnL6bPm80m?blzue9 zi}gp;m!g=!O`7DMg@)nHWv^foX*Mc&x{$t1(W8BR#D&d!Erz6-X% z>EW$5{IGz_!yV~A?UduD36V*Bgv9dEkJPmP@REG{fpY<}I{3X2D8yj&;#MKeC@4WFG2ZUn6}AriIn z7t)?Rly?*_{RsXLzx{6hZs0FT{E(#hYNKw1Ai^{Pzxmh$3@bYVw1O?<+XCe+r4jAC zMja9;KKK+3c|Ugztn!B76!|Q#N{!0d17*TZ1TdPsrSse?^^{& z#Wf4}Gvo8ne7#NZwFjSYT)})^xc88Mf%lnPJE2ipxS55@Q{JSo2hcYW_(A$TN_=zJ zW#|M0!^yt2?&24UeuT=uB5+cgP{MCS^Yd*7N_a2KFat tKZ1WBYJj@KfwIfa>XTKMoYfbc)r)srbZ&s|!xQD>Rx&`|6Zj7g^k4ml162S3 diff --git a/Backend/src/services/__pycache__/stripe_service.cpython-312.pyc b/Backend/src/services/__pycache__/stripe_service.cpython-312.pyc index 2f24cb64db4e415b0e6275e971252b74e201990b..801f8a300cc9cec348945f21bb55b7f3ca9a79e4 100644 GIT binary patch delta 6727 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 delta 6449 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 G dict: + # Validate password strength + from ..utils.password_validation import validate_password_strength + is_valid, errors = validate_password_strength(password) + if not is_valid: + error_message = 'Password does not meet requirements: ' + '; '.join(errors) + raise ValueError(error_message) existing_user = db.query(User).filter(User.email == email).first() if existing_user: @@ -146,11 +152,39 @@ class AuthService: logger.warning(f"Login attempt for inactive user: {email}") raise ValueError("Account is disabled. Please contact support.") + # Check if account is locked (reset if lockout expired) + if user.locked_until: + if user.locked_until > datetime.utcnow(): + remaining_minutes = int((user.locked_until - datetime.utcnow()).total_seconds() / 60) + logger.warning(f"Login attempt for locked account: {email} (locked until {user.locked_until})") + raise ValueError(f"Account is temporarily locked due to multiple failed login attempts. Please try again in {remaining_minutes} minute(s).") + else: + # Lockout expired, reset it + user.locked_until = None + user.failed_login_attempts = 0 + db.commit() + user.role = db.query(Role).filter(Role.id == user.role_id).first() - if not self.verify_password(password, user.password): - logger.warning(f"Login attempt with invalid password for user: {email}") - raise ValueError("Invalid email or password") + password_valid = self.verify_password(password, user.password) + + # Handle failed login attempt + if not password_valid: + user.failed_login_attempts = (user.failed_login_attempts or 0) + 1 + max_attempts = settings.MAX_LOGIN_ATTEMPTS + lockout_duration = settings.ACCOUNT_LOCKOUT_DURATION_MINUTES + + # Lock account if max attempts reached + if user.failed_login_attempts >= max_attempts: + user.locked_until = datetime.utcnow() + timedelta(minutes=lockout_duration) + logger.warning(f"Account locked due to {user.failed_login_attempts} failed login attempts: {email}") + db.commit() + raise ValueError(f"Account has been temporarily locked due to {max_attempts} failed login attempts. Please try again in {lockout_duration} minute(s).") + else: + remaining_attempts = max_attempts - user.failed_login_attempts + logger.warning(f"Login attempt with invalid password for user: {email} ({user.failed_login_attempts}/{max_attempts} failed attempts)") + db.commit() + raise ValueError(f"Invalid email or password. {remaining_attempts} attempt(s) remaining before account lockout.") if user.mfa_enabled: if not mfa_token: @@ -164,7 +198,26 @@ class AuthService: from ..services.mfa_service import mfa_service is_backup_code = len(mfa_token) == 8 if not mfa_service.verify_mfa(db, user.id, mfa_token, is_backup_code): - raise ValueError("Invalid MFA token") + # Increment failed attempts on MFA failure + user.failed_login_attempts = (user.failed_login_attempts or 0) + 1 + max_attempts = settings.MAX_LOGIN_ATTEMPTS + lockout_duration = settings.ACCOUNT_LOCKOUT_DURATION_MINUTES + + if user.failed_login_attempts >= max_attempts: + user.locked_until = datetime.utcnow() + timedelta(minutes=lockout_duration) + logger.warning(f"Account locked due to {user.failed_login_attempts} failed attempts (MFA failure): {email}") + db.commit() + raise ValueError(f"Account has been temporarily locked due to {max_attempts} failed login attempts. Please try again in {lockout_duration} minute(s).") + else: + remaining_attempts = max_attempts - user.failed_login_attempts + db.commit() + raise ValueError(f"Invalid MFA token. {remaining_attempts} attempt(s) remaining before account lockout.") + + # Reset failed login attempts and unlock account on successful login + if user.failed_login_attempts > 0 or user.locked_until: + user.failed_login_attempts = 0 + user.locked_until = None + db.commit() tokens = self.generate_tokens(user.id) @@ -272,6 +325,13 @@ class AuthService: raise ValueError("Current password is required to change password") if not self.verify_password(current_password, user.password): raise ValueError("Current password is incorrect") + + # Validate new password strength + from ..utils.password_validation import validate_password_strength + is_valid, errors = validate_password_strength(password) + if not is_valid: + error_message = 'New password does not meet requirements: ' + '; '.join(errors) + raise ValueError(error_message) user.password = self.hash_password(password) diff --git a/Backend/src/services/invoice_service.py b/Backend/src/services/invoice_service.py index 735cf69e..5119e961 100644 --- a/Backend/src/services/invoice_service.py +++ b/Backend/src/services/invoice_service.py @@ -6,6 +6,9 @@ from ..models.invoice import Invoice, InvoiceItem, InvoiceStatus from ..models.booking import Booking from ..models.payment import Payment, PaymentStatus from ..models.user import User +from ..config.logging_config import get_logger + +logger = get_logger(__name__) def generate_invoice_number(db: Session, is_proforma: bool=False) -> str: prefix = 'PRO' if is_proforma else 'INV' @@ -24,10 +27,12 @@ def generate_invoice_number(db: Session, is_proforma: bool=False) -> str: class InvoiceService: @staticmethod - def create_invoice_from_booking(booking_id: int, db: Session, created_by_id: Optional[int]=None, tax_rate: float=0.0, discount_amount: float=0.0, due_days: int=30, is_proforma: bool=False, invoice_amount: Optional[float]=None, **kwargs) -> Dict[str, Any]: + def create_invoice_from_booking(booking_id: int, db: Session, created_by_id: Optional[int]=None, tax_rate: float=0.0, discount_amount: float=0.0, due_days: int=30, is_proforma: bool=False, invoice_amount: Optional[float]=None, request_id: Optional[str]=None, **kwargs) -> Dict[str, Any]: from sqlalchemy.orm import selectinload + logger.info(f'Creating invoice from booking {booking_id}', extra={'booking_id': booking_id, 'request_id': request_id}) booking = db.query(Booking).options(selectinload(Booking.service_usages).selectinload('service'), selectinload(Booking.room).selectinload('room_type'), selectinload(Booking.payments)).filter(Booking.id == booking_id).first() if not booking: + logger.error(f'Booking {booking_id} not found', extra={'booking_id': booking_id, 'request_id': request_id}) raise ValueError('Booking not found') user = db.query(User).filter(User.id == booking.user_id).first() if not user: @@ -94,7 +99,7 @@ class InvoiceService: return InvoiceService.invoice_to_dict(invoice) @staticmethod - def update_invoice(invoice_id: int, db: Session, updated_by_id: Optional[int]=None, **kwargs) -> Dict[str, Any]: + def update_invoice(invoice_id: int, db: Session, updated_by_id: Optional[int]=None, request_id: Optional[str]=None, **kwargs) -> Dict[str, Any]: invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first() if not invoice: raise ValueError('Invoice not found') @@ -121,7 +126,7 @@ class InvoiceService: return InvoiceService.invoice_to_dict(invoice) @staticmethod - def mark_invoice_as_paid(invoice_id: int, db: Session, amount: Optional[float]=None, updated_by_id: Optional[int]=None) -> Dict[str, Any]: + def mark_invoice_as_paid(invoice_id: int, db: Session, amount: Optional[float]=None, updated_by_id: Optional[int]=None, request_id: Optional[str]=None) -> Dict[str, Any]: invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first() if not invoice: raise ValueError('Invoice not found') diff --git a/Backend/src/services/paypal_service.py b/Backend/src/services/paypal_service.py index f494c60f..d3029750 100644 --- a/Backend/src/services/paypal_service.py +++ b/Backend/src/services/paypal_service.py @@ -4,13 +4,14 @@ from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersGetRequest, Orde from paypalcheckoutsdk.payments import CapturesRefundRequest from typing import Optional, Dict, Any from ..config.settings import settings +from ..config.logging_config import get_logger from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus from ..models.booking import Booking, BookingStatus from ..models.system_settings import SystemSettings from sqlalchemy.orm import Session from datetime import datetime import json -logger = logging.getLogger(__name__) +logger = get_logger(__name__) def get_paypal_client_id(db: Session) -> Optional[str]: try: @@ -285,10 +286,7 @@ class PayPalService: db.rollback() raise except Exception as e: - import traceback - error_details = traceback.format_exc() error_msg = str(e) if str(e) else f'{type(e).__name__}: {repr(e)}' - print(f'Error in confirm_payment: {error_msg}') - print(f'Traceback: {error_details}') + logger.error(f'Error in confirm_payment: {error_msg}', exc_info=True, extra={'order_id': order_id, 'booking_id': booking_id}) db.rollback() raise ValueError(f'Error confirming payment: {error_msg}') \ No newline at end of file diff --git a/Backend/src/services/stripe_service.py b/Backend/src/services/stripe_service.py index a24cde4a..b298acd6 100644 --- a/Backend/src/services/stripe_service.py +++ b/Backend/src/services/stripe_service.py @@ -2,12 +2,13 @@ import logging import stripe from typing import Optional, Dict, Any from ..config.settings import settings +from ..config.logging_config import get_logger from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus from ..models.booking import Booking, BookingStatus from ..models.system_settings import SystemSettings from sqlalchemy.orm import Session from datetime import datetime -logger = logging.getLogger(__name__) +logger = get_logger(__name__) def get_stripe_secret_key(db: Session) -> Optional[str]: try: @@ -98,7 +99,7 @@ class StripeService: if not booking: raise ValueError('Booking not found') payment_status = intent_data.get('status') - print(f'Payment intent status: {payment_status}') + logger.info(f'Payment intent status: {payment_status}', extra={'payment_intent_id': payment_intent_id, 'booking_id': booking_id}) if payment_status not in ['succeeded', 'processing']: raise ValueError(f'Payment intent not in a valid state. Status: {payment_status}. Payment may still be processing or may have failed.') payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.transaction_id == payment_intent_id, Payment.payment_method == PaymentMethod.stripe).first() @@ -207,21 +208,20 @@ class StripeService: try: return {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': get_enum_value(payment.payment_method), 'payment_type': get_enum_value(payment.payment_type), 'payment_status': get_enum_value(payment.payment_status), 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None} except AttributeError as ae: - print(f'AttributeError accessing payment fields: {ae}') - print(f'Payment object: {payment}') - print(f'Payment payment_method: {(payment.payment_method if hasattr(payment, 'payment_method') else 'missing')}') - print(f'Payment payment_type: {(payment.payment_type if hasattr(payment, 'payment_type') else 'missing')}') - print(f'Payment payment_status: {(payment.payment_status if hasattr(payment, 'payment_status') else 'missing')}') + logger.error(f'AttributeError accessing payment fields: {ae}', exc_info=True, extra={ + 'payment_id': payment.id if hasattr(payment, 'id') else None, + 'booking_id': booking_id, + 'payment_method': payment.payment_method if hasattr(payment, 'payment_method') else 'missing', + 'payment_type': payment.payment_type if hasattr(payment, 'payment_type') else 'missing', + 'payment_status': payment.payment_status if hasattr(payment, 'payment_status') else 'missing' + }) raise except ValueError as e: db.rollback() raise except Exception as e: - import traceback - error_details = traceback.format_exc() error_msg = str(e) if str(e) else f'{type(e).__name__}: {repr(e)}' - print(f'Error in confirm_payment: {error_msg}') - print(f'Traceback: {error_details}') + logger.error(f'Error in confirm_payment: {error_msg}', exc_info=True, extra={'payment_intent_id': payment_intent_id, 'booking_id': booking_id}) db.rollback() raise ValueError(f'Error confirming payment: {error_msg}') diff --git a/Backend/src/utils/__pycache__/html_sanitizer.cpython-312.pyc b/Backend/src/utils/__pycache__/html_sanitizer.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fed229df10125459cf775fcf836f132e0f9dda36 GIT binary patch literal 2833 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 J`JLe8$-gR;)d>Iq literal 0 HcmV?d00001 diff --git a/Backend/src/utils/__pycache__/response_helpers.cpython-312.pyc b/Backend/src/utils/__pycache__/response_helpers.cpython-312.pyc index be9648ee1b79a518e81d50948d880bb653f3882e..7e9bf35f532498f97b932a8be3d074584e4df460 100644 GIT binary patch literal 2354 zcmaJ?-D_M$6u;m1X1|*6kH)0aFWXzO`yeQUVolSAS|uqpQi!d~&7Ilgw!3@RxwEt> z8%ael1(67X6$E|o!BYQ#{t1FF3FcuP6jSuYw_Q>Y`sA6pcQ@OjPT09;&YW}ReEiPI zPuXlrf%V~UCj-k-lt09wz0wJ>_XvnD6|5{OSjAdNEo+NfSzpx4#-gDLo?bG`)}mFm z7j0GPfj@izn?%DFF^Dq;+Sn#Kafnvb#Hnau=VNoRCy}&>gL^! z49S4?ZoMq%+3{Q0D5h`@q_eAr+k5Ag)VnMw1#A@+YZadbVI@MvkfMlr73@(Q+zBen zX!`P-h?1xZwb(NZR z6;7r+={HnhKDX-H!-T7X-aCqG-PuSXSTmG(j3BQoMuJgP^L=ov6Xt0tdquh!p{0W3vm~PHedv z&4AMDd!TZwR)vfa*VyNBOW0hFxYH?z0m34zrx)@oVVUHYE4TCKNpzEi z)%?YfkQTOJq_9FtRYIfF)m7e`cvfg%(gAqEhF>%T z+q%*mR0c;I{o^fDN7nkqW=e4~TlPp}{LqH8IlS$RKDKj>$>VSs+jhpAmSScw@fJG$*0d~EkM4!;D4 z7q*?rT_f{sn&}`|;prGy1wX`u+exl{k=*tYrIm^P6bO;%HM6eMlCT3-{`iJ*uy$jA0{#tRaj%<1-Y;VU8~hiY<#WPjqlXY)HRC(vPogExflgbM1?(_pg3){j2NW zzy0Xg&d}MdrQ$~F&+K?pF>_Xg#=9ME(2-7nGyyLA8+-Ht8b`{zQLw0h+Qyc3Icor2ZS|S Z9vvk&yt%xcooq^lW(EvC$%-o(^gqHGRS*CG delta 549 zcmZusze^)Q7@gm{n{1*cL5&*pPBgb%BZq~ESXhW$ks!!%SSaF5Ku{yI33w1O!htz# z61K3hwXoGc!%DCSDUOK#0UHzqE5C`_`N6z--}~Ooo4HJ!M6%bhSOh^0%zoXPU;750^IhtWSLo`0PK_ za~luKS9zSqo_R(~_@#|f+1COZZ2?^1mMHJZjgs=%yBajeKJSB>2;%hjk4*yfqu>W9 z3hQwJ;1o$LNd-xIMh|EOYhJ+>Dp_!jzhfT!r*nrURGS0cFr75a1%NP<{wBl?>jCi1 z^>uCH`Ih@-|f4Nyg40;ST;M7o(A>R4<{RF^s~vg6Inc+IX4 z-q=HJmT`~sNBR2VEgNn}lhwRVz)6qfavsikPH<;^`APkf*5>hL5^HDkRYg}v>j>MQ?pq0vGc TiAZg{=GVR*tvxxQbTeK7`YvzN diff --git a/Backend/src/utils/file_validation.py b/Backend/src/utils/file_validation.py new file mode 100644 index 00000000..e7fcc0db --- /dev/null +++ b/Backend/src/utils/file_validation.py @@ -0,0 +1,148 @@ +""" +File validation utilities for secure file uploads. +Validates file types using magic bytes (file signatures) to prevent spoofing. +""" +from PIL import Image +import io +from typing import Tuple, Optional +from fastapi import UploadFile, HTTPException, status + +# Magic bytes for common image formats +IMAGE_MAGIC_BYTES = { + b'\xFF\xD8\xFF': 'image/jpeg', + b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A': 'image/png', + b'GIF87a': 'image/gif', + b'GIF89a': 'image/gif', + b'RIFF': 'image/webp', # WebP files start with RIFF, need deeper check + b'\x00\x00\x01\x00': 'image/x-icon', + b'\x00\x00\x02\x00': 'image/x-icon', +} + +ALLOWED_IMAGE_TYPES = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'} + +def validate_image_file_signature(file_content: bytes, filename: str) -> Tuple[bool, str]: + """ + Validate file type using magic bytes (file signature). + This prevents MIME type spoofing attacks. + + Args: + file_content: The file content as bytes + filename: The filename (for extension checking) + + Returns: + Tuple of (is_valid, error_message) + """ + if not file_content: + return False, "File is empty" + + # Check magic bytes for image types + file_start = file_content[:16] # Check first 16 bytes + + detected_type = None + + # Check for JPEG + if file_content.startswith(b'\xFF\xD8\xFF'): + detected_type = 'image/jpeg' + # Check for PNG + elif file_content.startswith(b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A'): + detected_type = 'image/png' + # Check for GIF + elif file_content.startswith(b'GIF87a') or file_content.startswith(b'GIF89a'): + detected_type = 'image/gif' + # Check for WebP (RIFF header with WEBP in bytes 8-11) + elif file_content.startswith(b'RIFF') and len(file_content) > 12: + if file_content[8:12] == b'WEBP': + detected_type = 'image/webp' + # Check for ICO + elif file_content.startswith(b'\x00\x00\x01\x00') or file_content.startswith(b'\x00\x00\x02\x00'): + detected_type = 'image/x-icon' + + # If magic bytes don't match known image types, try PIL verification + if not detected_type: + try: + # Try to open with PIL to verify it's a valid image + img = Image.open(io.BytesIO(file_content)) + img.verify() + + # Get format from PIL + img_format = img.format.lower() if img.format else None + if img_format == 'jpeg': + detected_type = 'image/jpeg' + elif img_format == 'png': + detected_type = 'image/png' + elif img_format == 'gif': + detected_type = 'image/gif' + elif img_format == 'webp': + detected_type = 'image/webp' + else: + return False, f"Unsupported image format: {img_format}" + except Exception: + return False, "File is not a valid image or is corrupted" + + # Verify detected type is in allowed list + if detected_type not in ALLOWED_IMAGE_TYPES and detected_type != 'image/x-icon': + return False, f"File type {detected_type} is not allowed. Allowed types: {', '.join(ALLOWED_IMAGE_TYPES)}" + + return True, detected_type + + +async def validate_uploaded_image(file: UploadFile, max_size: int) -> bytes: + """ + Validate an uploaded image file completely. + + Args: + file: FastAPI UploadFile object + max_size: Maximum file size in bytes + + Returns: + File content as bytes + + Raises: + HTTPException if validation fails + """ + # Check MIME type first (quick check) + if not file.content_type or not file.content_type.startswith('image/'): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'File must be an image. Received MIME type: {file.content_type}' + ) + + # Read file content + content = await file.read() + + # Validate file size + if len(content) > max_size: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f'File size ({len(content)} bytes) exceeds maximum allowed size ({max_size} bytes / {max_size // 1024 // 1024}MB)' + ) + + # Validate file signature (magic bytes) + is_valid, result = validate_image_file_signature(content, file.filename or '') + if not is_valid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Invalid file type: {result}. File signature validation failed. Please upload a valid image file.' + ) + + # Additional PIL validation to ensure image is not corrupted + try: + img = Image.open(io.BytesIO(content)) + # Verify image integrity + img.verify() + # Re-open for further processing (verify() closes the image) + img = Image.open(io.BytesIO(content)) + # Check image dimensions to prevent decompression bombs + if img.width > 10000 or img.height > 10000: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Image dimensions too large. Maximum dimensions: 10000x10000 pixels' + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Invalid or corrupted image file: {str(e)}' + ) + + return content + diff --git a/Backend/src/utils/html_sanitizer.py b/Backend/src/utils/html_sanitizer.py new file mode 100644 index 00000000..05ab3bdc --- /dev/null +++ b/Backend/src/utils/html_sanitizer.py @@ -0,0 +1,99 @@ +""" +HTML sanitization utilities for backend content storage. +Prevents XSS attacks by sanitizing HTML before storing in database. +""" +import bleach +from typing import Optional + +# Allowed HTML tags for rich content +ALLOWED_TAGS = [ + 'p', 'br', 'strong', 'em', 'u', 'b', 'i', 'span', 'div', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'ul', 'ol', 'li', + 'a', 'blockquote', 'pre', 'code', + 'table', 'thead', 'tbody', 'tr', 'th', 'td', + 'img', 'hr', 'section', 'article' +] + +# Allowed HTML attributes +ALLOWED_ATTRIBUTES = { + 'a': ['href', 'title', 'target', 'rel'], + 'img': ['src', 'alt', 'title', 'width', 'height', 'class'], + 'div': ['class', 'id', 'style'], + 'span': ['class', 'id', 'style'], + 'p': ['class', 'id', 'style'], + 'h1': ['class', 'id'], + 'h2': ['class', 'id'], + 'h3': ['class', 'id'], + 'h4': ['class', 'id'], + 'h5': ['class', 'id'], + 'h6': ['class', 'id'], + 'table': ['class', 'id'], + 'tr': ['class', 'id'], + 'th': ['class', 'id', 'colspan', 'rowspan'], + 'td': ['class', 'id', 'colspan', 'rowspan'], +} + +# Allowed URL schemes +ALLOWED_SCHEMES = ['http', 'https', 'mailto', 'tel'] + +def sanitize_html(html_content: Optional[str]) -> str: + """ + Sanitize HTML content to prevent XSS attacks. + + Args: + html_content: HTML string to sanitize (can be None) + + Returns: + Sanitized HTML string safe for storage + """ + if not html_content: + return '' + + # Clean HTML content + cleaned = bleach.clean( + html_content, + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES, + protocols=ALLOWED_SCHEMES, + strip=True, # Strip disallowed tags instead of escaping + strip_comments=True, # Remove HTML comments + ) + + # Additional link sanitization - ensure external links have rel="noopener" + if '' + elif 'noopener' not in tag and 'noreferrer' not in tag: + # Add to existing rel attribute + tag = tag.replace('rel="', 'rel="noopener noreferrer ') + tag = tag.replace("rel='", "rel='noopener noreferrer ") + return tag + return tag + + cleaned = re.sub(r']*>', add_rel, cleaned) + + return cleaned + +def sanitize_text_for_html(text: Optional[str]) -> str: + """ + Escape text content to be safely included in HTML. + Use this for plain text that should be displayed as-is. + + Args: + text: Plain text string to escape + + Returns: + HTML-escaped string + """ + if not text: + return '' + + return bleach.clean(text, tags=[], strip=True) + diff --git a/Backend/src/utils/password_validation.py b/Backend/src/utils/password_validation.py new file mode 100644 index 00000000..16104f65 --- /dev/null +++ b/Backend/src/utils/password_validation.py @@ -0,0 +1,59 @@ +""" +Password validation utilities for enforcing password strength requirements. +""" +import re +from typing import Tuple, List + +# Password strength requirements +MIN_PASSWORD_LENGTH = 8 +REQUIRE_UPPERCASE = True +REQUIRE_LOWERCASE = True +REQUIRE_NUMBER = True +REQUIRE_SPECIAL = True + +def validate_password_strength(password: str) -> Tuple[bool, List[str]]: + """ + Validate password meets strength requirements. + + Args: + password: The password to validate + + Returns: + Tuple of (is_valid, list_of_errors) + """ + errors = [] + + if not password: + return False, ['Password is required'] + + # Check minimum length + if len(password) < MIN_PASSWORD_LENGTH: + errors.append(f'Password must be at least {MIN_PASSWORD_LENGTH} characters long') + + # Check for uppercase letter + if REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password): + errors.append('Password must contain at least one uppercase letter') + + # Check for lowercase letter + if REQUIRE_LOWERCASE and not re.search(r'[a-z]', password): + errors.append('Password must contain at least one lowercase letter') + + # Check for number + if REQUIRE_NUMBER and not re.search(r'\d', password): + errors.append('Password must contain at least one number') + + # Check for special character + if REQUIRE_SPECIAL and not re.search(r'[!@#$%^&*(),.?":{}|<>]', password): + errors.append('Password must contain at least one special character (!@#$%^&*(),.?":{}|<>)') + + # Check for common weak passwords + common_passwords = [ + 'password', '12345678', 'qwerty', 'abc123', 'password123', + 'admin', 'letmein', 'welcome', 'monkey', '1234567890' + ] + if password.lower() in common_passwords: + errors.append('Password is too common. Please choose a stronger password') + + is_valid = len(errors) == 0 + return is_valid, errors + diff --git a/Backend/src/utils/request_helpers.py b/Backend/src/utils/request_helpers.py new file mode 100644 index 00000000..9bc59565 --- /dev/null +++ b/Backend/src/utils/request_helpers.py @@ -0,0 +1,21 @@ +""" +Utility functions for request handling +""" +from typing import Optional +from fastapi import Request + + +def get_request_id(request: Optional[Request] = None) -> Optional[str]: + """ + Extract request_id from request state. + + Args: + request: FastAPI Request object + + Returns: + Request ID string or None + """ + if not request: + return None + return getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None + diff --git a/Backend/src/utils/response_helpers.py b/Backend/src/utils/response_helpers.py index b7729c1f..957e493a 100644 --- a/Backend/src/utils/response_helpers.py +++ b/Backend/src/utils/response_helpers.py @@ -2,6 +2,7 @@ Utility functions for standardizing API responses """ from typing import Any, Dict, Optional +from fastapi import HTTPException, Request def success_response( data: Any = None, @@ -31,6 +32,7 @@ def success_response( def error_response( message: str, errors: Optional[list] = None, + request_id: Optional[str] = None, **kwargs ) -> Dict[str, Any]: """ @@ -45,7 +47,40 @@ def error_response( if errors: response['errors'] = errors + if request_id: + response['request_id'] = request_id + response.update(kwargs) return response + +def raise_http_exception( + status_code: int, + message: str, + errors: Optional[list] = None, + request: Optional[Request] = None, + **kwargs +) -> None: + """ + Raise an HTTPException with standardized error response format. + + Args: + status_code: HTTP status code + message: Error message + errors: Optional list of error details + request: Optional Request object to extract request_id + **kwargs: Additional fields to include in response + """ + request_id = None + if request: + request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None + + detail = error_response( + message=message, + errors=errors, + request_id=request_id, + **kwargs + ) + raise HTTPException(status_code=status_code, detail=detail) + diff --git a/Backend/venv/lib/python3.12/site-packages/__pycache__/six.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/__pycache__/six.cpython-312.pyc index ad6169f333089bf0898316a939dc27f51626543a..41d4ad060e09ca50c4f0ff7b85c41f4e1b81d84a 100644 GIT binary patch delta 22 ccmX?lnCa+YChpU`yj%=G@H9eUBloU_09pG7g8%>k delta 22 ccmX?lnCa+YChpU`yj%=GP=3.8 +Description-Content-Type: text/x-rst +License-File: LICENSE +Requires-Dist: six >=1.9.0 +Requires-Dist: webencodings +Provides-Extra: css +Requires-Dist: tinycss2 <1.3,>=1.1.0 ; extra == 'css' + +====== +Bleach +====== + +.. image:: https://github.com/mozilla/bleach/workflows/Test/badge.svg + :target: https://github.com/mozilla/bleach/actions?query=workflow%3ATest + +.. image:: https://github.com/mozilla/bleach/workflows/Lint/badge.svg + :target: https://github.com/mozilla/bleach/actions?query=workflow%3ALint + +.. image:: https://badge.fury.io/py/bleach.svg + :target: http://badge.fury.io/py/bleach + +**NOTE: 2023-01-23: Bleach is deprecated.** See issue: +``__ + +Bleach is an allowed-list-based HTML sanitizing library that escapes or strips +markup and attributes. + +Bleach can also linkify text safely, applying filters that Django's ``urlize`` +filter cannot, and optionally setting ``rel`` attributes, even on links already +in the text. + +Bleach is intended for sanitizing text from *untrusted* sources. If you find +yourself jumping through hoops to allow your site administrators to do lots of +things, you're probably outside the use cases. Either trust those users, or +don't. + +Because it relies on html5lib_, Bleach is as good as modern browsers at dealing +with weird, quirky HTML fragments. And *any* of Bleach's methods will fix +unbalanced or mis-nested tags. + +The version on GitHub_ is the most up-to-date and contains the latest bug +fixes. You can find full documentation on `ReadTheDocs`_. + +:Code: https://github.com/mozilla/bleach +:Documentation: https://bleach.readthedocs.io/ +:Issue tracker: https://github.com/mozilla/bleach/issues +:License: Apache License v2; see LICENSE file + + +Reporting Bugs +============== + +For regular bugs, please report them `in our issue tracker +`_. + +If you believe that you've found a security vulnerability, please `file a secure +bug report in our bug tracker +`_ +or send an email to *security AT mozilla DOT org*. + +For more information on security-related bug disclosure and the PGP key to use +for sending encrypted mail or to verify responses received from that address, +please read our wiki page at +``_. + + +Security +======== + +Bleach is a security-focused library. + +We have a responsible security vulnerability reporting process. Please use +that if you're reporting a security issue. + +Security issues are fixed in private. After we land such a fix, we'll do a +release. + +For every release, we mark security issues we've fixed in the ``CHANGES`` in +the **Security issues** section. We include any relevant CVE links. + + +Installing Bleach +================= + +Bleach is available on PyPI_, so you can install it with ``pip``:: + + $ pip install bleach + + +Upgrading Bleach +================ + +.. warning:: + + Before doing any upgrades, read through `Bleach Changes + `_ for backwards + incompatible changes, newer versions, etc. + + Bleach follows `semver 2`_ versioning. Vendored libraries will not + be changed in patch releases. + + +Basic use +========= + +The simplest way to use Bleach is: + +.. code-block:: python + + >>> import bleach + + >>> bleach.clean('an example') + u'an <script>evil()</script> example' + + >>> bleach.linkify('an http://example.com url') + u'an http://example.com url' + + +Code of Conduct +=============== + +This project and repository is governed by Mozilla's code of conduct and +etiquette guidelines. For more details please see the `CODE_OF_CONDUCT.md +`_ + + +.. _html5lib: https://github.com/html5lib/html5lib-python +.. _GitHub: https://github.com/mozilla/bleach +.. _ReadTheDocs: https://bleach.readthedocs.io/ +.. _PyPI: https://pypi.org/project/bleach/ +.. _semver 2: https://semver.org/ + + +Bleach changes +============== + +Version 6.1.0 (October 6th, 2023) +--------------------------------- + +**Backwards incompatible changes** + +* Dropped support for Python 3.7. (#709) + +**Security fixes** + +None + +**Bug fixes** + +* Add support for Python 3.12. (#710) +* Fix linkify with arrays in querystring (#436) +* Handle more cases with < followed by character data (#705) +* Fix entities inside a tags in linkification (#704) +* Update cap for tinycss2 to <1.3 (#702) +* Updated Sphinx requirement +* Add dependabot for github actions and update github actions + + +Version 6.0.0 (January 23rd, 2023) +---------------------------------- + +**Backwards incompatible changes** + +* ``bleach.clean``, ``bleach.sanitizer.Cleaner``, + ``bleach.html5lib_shim.BleachHTMLParser``: the ``tags`` and ``protocols`` + arguments were changed from lists to sets. + + Old pre-6.0.0: + + .. code-block:: python + + bleach.clean( + "some text", + tags=["a", "p", "img"], + # ^ ^ list + protocols=["http", "https"], + # ^ ^ list + ) + + + New 6.0.0 and later: + + .. code-block:: python + + bleach.clean( + "some text", + tags={"a", "p", "img"}, + # ^ ^ set + protocols={"http", "https"}, + # ^ ^ set + ) + +* ``bleach.linkify``, ``bleach.linkifier.Linker``: the ``skip_tags`` and + ``recognized_tags`` arguments were changed from lists to sets. + + Old pre-6.0.0: + + .. code-block:: python + + bleach.linkify( + "some text", + skip_tags=["pre"], + # ^ ^ list + ) + + linker = Linker( + skip_tags=["pre"], + # ^ ^ list + recognized_tags=html5lib_shim.HTML_TAGS + ["custom-element"], + # ^ ^ ^ list + # | + # | list concatenation + ) + + New 6.0.0 and later: + + .. code-block:: python + + bleach.linkify( + "some text", + skip_tags={"pre"}, + # ^ ^ set + ) + + linker = Linker( + skip_tags={"pre"}, + # ^ ^ set + recognized_tags=html5lib_shim.HTML_TAGS | {"custom-element"}, + # ^ ^ ^ set + # | + # | union operator + ) + +* ``bleach.sanitizer.BleachSanitizerFilter``: ``strip_allowed_elements`` is now + ``strip_allowed_tags``. We now use "tags" everywhere rather than a mishmash + of "tags" in some places and "elements" in others. + + +**Security fixes** + +None + + +**Bug fixes** + +* Add support for Python 3.11. (#675) + +* Fix API weirness in ``BleachSanitizerFilter``. (#649) + + We're using "tags" instead of "elements" everywhere--no more weird + overloading of "elements" anymore. + + Also, it no longer calls the superclass constructor. + +* Add warning when ``css_sanitizer`` isn't set, but the ``style`` + attribute is allowed. (#676) + +* Fix linkify handling of character entities. (#501) + +* Rework dev dependencies to use ``requirements-dev.txt`` and + ``requirements-flake8.txt`` instead of extras. + +* Fix project infrastructure to be tox-based so it's easier to have CI + run the same things we're running in development and with flake8 + in an isolated environment. + +* Update action versions in CI. + +* Switch to f-strings where possible. Make tests parametrized to be + easier to read/maintain. + + +Version 5.0.1 (June 27th, 2022) +------------------------------- + +**Security fixes** + +None + + +**Bug fixes** + +* Add missing comma to tinycss2 require. Thank you, @shadchin! + +* Add url parse tests based on wpt url tests. (#688) + +* Support scheme-less urls if "https" is in allow list. (#662) + +* Handle escaping ``<`` in edge cases where it doesn't start a tag. (#544) + +* Fix reference warnings in docs. (#660) + +* Correctly urlencode email address parts. Thank you, @larseggert! (#659) + + +Version 5.0.0 (April 7th, 2022) +------------------------------- + +**Backwards incompatible changes** + +* ``clean`` and ``linkify`` now preserve the order of HTML attributes. Thank + you, @askoretskly! (#566) + +* Drop support for Python 3.6. Thank you, @hugovk! (#629) + +* CSS sanitization in style tags is completely different now. If you're using + Bleach ``clean`` to sanitize css in style tags, you'll need to update your + code and you'll need to install the ``css`` extras:: + + pip install 'bleach[css]' + + See `the documentation on sanitizing CSS for how to do it + `_. (#633) + +**Security fixes** + +None + +**Bug fixes** + +* Rework dev dependencies. We no longer have + ``requirements-dev.in``/``requirements-dev.txt``. Instead, we're using + ``dev`` extras. + + See `development docs `_ + for more details. (#620) + +* Add newline when dropping block-level tags. Thank you, @jvanasco! (#369) + + +Version 4.1.0 (August 25th, 2021) +--------------------------------- + +**Features** + +* Python 3.9 support + +**Security fixes** + +None + +**Bug fixes** + +* Update sanitizer clean to use vendored 3.6.14 stdlib urllib.parse to + fix test failures on Python 3.9. (#536) + + +Version 4.0.0 (August 3rd, 2021) +-------------------------------- + +**Backwards incompatible changes** + +* Drop support for unsupported Python versions <3.6. (#520) + +**Security fixes** + +None + +**Features** + +* fix attribute name in the linkify docs (thanks @CheesyFeet!) + + +Version 3.3.1 (July 14th, 2021) +------------------------------- + +**Security fixes** + +None + +**Features** + +* add more tests for CVE-2021-23980 / GHSA-vv2x-vrpj-qqpq +* bump python version to 3.8 for tox doc, vendorverify, and lint targets +* update bug report template tag +* update vendorverify script to detect and fail when extra files are vendored +* update release process docs to check vendorverify passes locally + +**Bug fixes** + +* remove extra vendored django present in the v3.3.0 whl (#595) +* duplicate h1 header doc fix (thanks Nguyễn Gia Phong / @McSinyx!) + + +Version 3.3.0 (February 1st, 2021) +---------------------------------- + +**Backwards incompatible changes** + +* clean escapes HTML comments even when strip_comments=False + +**Security fixes** + +* Fix bug 1621692 / GHSA-m6xf-fq7q-8743. See the advisory for details. + +**Features** + +None + +**Bug fixes** + +None + + +Version 3.2.3 (January 26th, 2021) +---------------------------------- + +**Security fixes** + +None + +**Features** + +None + +**Bug fixes** + +* fix clean and linkify raising ValueErrors for certain inputs. Thank you @Google-Autofuzz. + + +Version 3.2.2 (January 20th, 2021) +---------------------------------- + +**Security fixes** + +None + +**Features** + +* Migrate CI to Github Actions. Thank you @hugovk. + +**Bug fixes** + +* fix linkify raising an IndexError on certain inputs. Thank you @Google-Autofuzz. + + +Version 3.2.1 (September 18th, 2020) +------------------------------------ + +**Security fixes** + +None + +**Features** + +None + +**Bug fixes** + +* change linkifier to add rel="nofollow" as documented. Thank you @mitar. +* suppress html5lib sanitizer DeprecationWarnings (#557) + + +Version 3.2.0 (September 16th, 2020) +------------------------------------ + +**Security fixes** + +None + +**Features** + +None + +**Bug fixes** + +* ``html5lib`` dependency to version 1.1.0. Thank you Sam Sneddon. +* update tests_website terminology. Thank you Thomas Grainger. + + +Version 3.1.5 (April 29th, 2020) +-------------------------------- + +**Security fixes** + +None + +**Features** + +None + +**Bug fixes** + +* replace missing ``setuptools`` dependency with ``packaging``. Thank you Benjamin Peterson. + + +Version 3.1.4 (March 24th, 2020) +-------------------------------- + +**Security fixes** + +* ``bleach.clean`` behavior parsing style attributes could result in a + regular expression denial of service (ReDoS). + + Calls to ``bleach.clean`` with an allowed tag with an allowed + ``style`` attribute were vulnerable to ReDoS. For example, + ``bleach.clean(..., attributes={'a': ['style']})``. + + This issue was confirmed in Bleach versions v3.1.3, v3.1.2, v3.1.1, + v3.1.0, v3.0.0, v2.1.4, and v2.1.3. Earlier versions used a similar + regular expression and should be considered vulnerable too. + + Anyone using Bleach <=v3.1.3 is encouraged to upgrade. + + https://bugzilla.mozilla.org/show_bug.cgi?id=1623633 + +**Backwards incompatible changes** + +* Style attributes with dashes, or single or double quoted values are + cleaned instead of passed through. + +**Features** + +None + +**Bug fixes** + +None + + +Version 3.1.3 (March 17th, 2020) +-------------------------------- + +**Security fixes** + +None + +**Backwards incompatible changes** + +* Drop support for Python 3.4. Thank you, @hugovk! + +* Drop deprecated ``setup.py test`` support. Thank you, @jdufresne! (#507) + +**Features** + +* Add support for Python 3.8. Thank you, @jdufresne! + +* Add support for PyPy 7. Thank you, @hugovk! + +* Add pypy3 testing to tox and travis. Thank you, @jdufresne! + +**Bug fixes** + +* Add relative link to code of conduct. (#442) + +* Fix typo: curren -> current in tests/test_clean.py Thank you, timgates42! (#504) + +* Fix handling of non-ascii style attributes. Thank you, @sekineh! (#426) + +* Simplify tox configuration. Thank you, @jdufresne! + +* Make documentation reproducible. Thank you, @lamby! + +* Fix typos in code comments. Thank you, @zborboa-g! + +* Fix exception value testing. Thank you, @mastizada! + +* Fix parser-tags NoneType exception. Thank you, @bope! + +* Improve TLD support in linkify. Thank you, @pc-coholic! + + +Version 3.1.2 (March 11th, 2020) +-------------------------------- + +**Security fixes** + +* ``bleach.clean`` behavior parsing embedded MathML and SVG content + with RCDATA tags did not match browser behavior and could result in + a mutation XSS. + + Calls to ``bleach.clean`` with ``strip=False`` and ``math`` or + ``svg`` tags and one or more of the RCDATA tags ``script``, + ``noscript``, ``style``, ``noframes``, ``iframe``, ``noembed``, or + ``xmp`` in the allowed tags whitelist were vulnerable to a mutation + XSS. + + This security issue was confirmed in Bleach version v3.1.1. Earlier + versions are likely affected too. + + Anyone using Bleach <=v3.1.1 is encouraged to upgrade. + + https://bugzilla.mozilla.org/show_bug.cgi?id=1621692 + +**Backwards incompatible changes** + +None + +**Features** + +None + +**Bug fixes** + +None + + +Version 3.1.1 (February 13th, 2020) +----------------------------------- + +**Security fixes** + +* ``bleach.clean`` behavior parsing ``noscript`` tags did not match + browser behavior. + + Calls to ``bleach.clean`` allowing ``noscript`` and one or more of + the raw text tags (``title``, ``textarea``, ``script``, ``style``, + ``noembed``, ``noframes``, ``iframe``, and ``xmp``) were vulnerable + to a mutation XSS. + + This security issue was confirmed in Bleach versions v2.1.4, v3.0.2, + and v3.1.0. Earlier versions are probably affected too. + + Anyone using Bleach <=v3.1.0 is highly encouraged to upgrade. + + https://bugzilla.mozilla.org/show_bug.cgi?id=1615315 + +**Backwards incompatible changes** + +None + +**Features** + +None + +**Bug fixes** + +None + + +Version 3.1.0 (January 9th, 2019) +--------------------------------- + +**Security fixes** + +None + +**Backwards incompatible changes** + +None + +**Features** + +* Add ``recognized_tags`` argument to the linkify ``Linker`` class. This + fixes issues when linkifying on its own and having some tags get escaped. + It defaults to a list of HTML5 tags. Thank you, Chad Birch! (#409) + +**Bug fixes** + +* Add ``six>=1.9`` to requirements. Thank you, Dave Shawley (#416) + +* Fix cases where attribute names could have invalid characters in them. + (#419) + +* Fix problems with ``LinkifyFilter`` not being able to match links + across ``&``. (#422) + +* Fix ``InputStreamWithMemory`` when the ``BleachHTMLParser`` is + parsing ``meta`` tags. (#431) + +* Fix doctests. (#357) + + +Version 3.0.2 (October 11th, 2018) +---------------------------------- + +**Security fixes** + +None + +**Backwards incompatible changes** + +None + +**Features** + +None + +**Bug fixes** + +* Merge ``Characters`` tokens after sanitizing them. This fixes issues in the + ``LinkifyFilter`` where it was only linkifying parts of urls. (#374) + + +Version 3.0.1 (October 9th, 2018) +--------------------------------- + +**Security fixes** + +None + +**Backwards incompatible changes** + +None + +**Features** + +* Support Python 3.7. It supported Python 3.7 just fine, but we added 3.7 to + the list of Python environments we test so this is now officially supported. + (#377) + +**Bug fixes** + +* Fix ``list`` object has no attribute ``lower`` in ``clean``. (#398) +* Fix ``abbr`` getting escaped in ``linkify``. (#400) + + +Version 3.0.0 (October 3rd, 2018) +--------------------------------- + +**Security fixes** + +None + +**Backwards incompatible changes** + +* A bunch of functions were moved from one module to another. + + These were moved from ``bleach.sanitizer`` to ``bleach.html5lib_shim``: + + * ``convert_entity`` + * ``convert_entities`` + * ``match_entity`` + * ``next_possible_entity`` + * ``BleachHTMLSerializer`` + * ``BleachHTMLTokenizer`` + * ``BleachHTMLParser`` + + These functions and classes weren't documented and aren't part of the + public API, but people read code and might be using them so we're + considering it an incompatible API change. + + If you're using them, you'll need to update your code. + +**Features** + +* Bleach no longer depends on html5lib. html5lib==1.0.1 is now vendored into + Bleach. You can remove it from your requirements file if none of your other + requirements require html5lib. + + This means Bleach will now work fine with other libraries that depend on + html5lib regardless of what version of html5lib they require. (#386) + +**Bug fixes** + +* Fixed tags getting added when using clean or linkify. This was a + long-standing regression from the Bleach 2.0 rewrite. (#280, #392) + +* Fixed ```` getting replaced with a string. Now it gets escaped or + stripped depending on whether it's in the allowed tags or not. (#279) + + +Version 2.1.4 (August 16th, 2018) +--------------------------------- + +**Security fixes** + +None + +**Backwards incompatible changes** + +* Dropped support for Python 3.3. (#328) + +**Features** + +None + +**Bug fixes** + +* Handle ambiguous ampersands in correctly. (#359) + + +Version 2.1.3 (March 5th, 2018) +------------------------------- + +**Security fixes** + +* Attributes that have URI values weren't properly sanitized if the + values contained character entities. Using character entities, it + was possible to construct a URI value with a scheme that was not + allowed that would slide through unsanitized. + + This security issue was introduced in Bleach 2.1. Anyone using + Bleach 2.1 is highly encouraged to upgrade. + + https://bugzilla.mozilla.org/show_bug.cgi?id=1442745 + +**Backwards incompatible changes** + +None + +**Features** + +None + +**Bug fixes** + +* Fixed some other edge cases for attribute URI value sanitizing and + improved testing of this code. + + +Version 2.1.2 (December 7th, 2017) +---------------------------------- + +**Security fixes** + +None + +**Backwards incompatible changes** + +None + +**Features** + +None + +**Bug fixes** + +* Support html5lib-python 1.0.1. (#337) + +* Add deprecation warning for supporting html5lib-python < 1.0. + +* Switch to semver. + + +Version 2.1.1 (October 2nd, 2017) +--------------------------------- + +**Security fixes** + +None + +**Backwards incompatible changes** + +None + +**Features** + +None + +**Bug fixes** + +* Fix ``setup.py`` opening files when ``LANG=``. (#324) + + +Version 2.1 (September 28th, 2017) +---------------------------------- + +**Security fixes** + +* Convert control characters (backspace particularly) to "?" preventing + malicious copy-and-paste situations. (#298) + + See ``_ for more details. + + This affects all previous versions of Bleach. Check the comments on that + issue for ways to alleviate the issue if you can't upgrade to Bleach 2.1. + + +**Backwards incompatible changes** + +* Redid versioning. ``bleach.VERSION`` is no longer available. Use the string + version at ``bleach.__version__`` and parse it with + ``pkg_resources.parse_version``. (#307) + +* clean, linkify: linkify and clean should only accept text types; thank you, + Janusz! (#292) + +* clean, linkify: accept only unicode or utf-8-encoded str (#176) + + +**Features** + + +**Bug fixes** + +* ``bleach.clean()`` no longer unescapes entities including ones that are missing + a ``;`` at the end which can happen in urls and other places. (#143) + +* linkify: fix http links inside of mailto links; thank you, sedrubal! (#300) + +* clarify security policy in docs (#303) + +* fix dependency specification for html5lib 1.0b8, 1.0b9, and 1.0b10; thank you, + Zoltán! (#268) + +* add Bleach vs. html5lib comparison to README; thank you, Stu Cox! (#278) + +* fix KeyError exceptions on tags without href attr; thank you, Alex Defsen! + (#273) + +* add test website and scripts to test ``bleach.clean()`` output in browser; + thank you, Greg Guthe! + + +Version 2.0 (March 8th, 2017) +----------------------------- + +**Security fixes** + +* None + + +**Backwards incompatible changes** + +* Removed support for Python 2.6. (#206) + +* Removed support for Python 3.2. (#224) + +* Bleach no longer supports html5lib < 0.99999999 (8 9s). + + This version is a rewrite to use the new sanitizing API since the old + one was dropped in html5lib 0.99999999 (8 9s). + + If you're using 0.9999999 (7 9s) upgrade to 0.99999999 (8 9s) or higher. + + If you're using 1.0b8 (equivalent to 0.9999999 (7 9s)), upgrade to 1.0b9 + (equivalent to 0.99999999 (8 9s)) or higher. + +* ``bleach.clean`` and friends were rewritten + + ``clean`` was reimplemented as an html5lib filter and happens at a different + step in the HTML parsing -> traversing -> serializing process. Because of + that, there are some differences in clean's output as compared with previous + versions. + + Amongst other things, this version will add end tags even if the tag in + question is to be escaped. + +* ``bleach.clean`` and friends attribute callables now take three arguments: + tag, attribute name and attribute value. Previously they only took attribute + name and attribute value. + + All attribute callables will need to be updated. + +* ``bleach.linkify`` was rewritten + + ``linkify`` was reimplemented as an html5lib Filter. As such, it no longer + accepts a ``tokenizer`` argument. + + The callback functions for adjusting link attributes now takes a namespaced + attribute. + + Previously you'd do something like this:: + + def check_protocol(attrs, is_new): + if not attrs.get('href', '').startswith('http:', 'https:')): + return None + return attrs + + Now it's more like this:: + + def check_protocol(attrs, is_new): + if not attrs.get((None, u'href'), u'').startswith(('http:', 'https:')): + # ^^^^^^^^^^^^^^^ + return None + return attrs + + Further, you need to make sure you're always using unicode values. If you + don't then html5lib will raise an assertion error that the value is not + unicode. + + All linkify filters will need to be updated. + +* ``bleach.linkify`` and friends had a ``skip_pre`` argument--that's been + replaced with a more general ``skip_tags`` argument. + + Before, you might do:: + + bleach.linkify(some_text, skip_pre=True) + + The equivalent with Bleach 2.0 is:: + + bleach.linkify(some_text, skip_tags=['pre']) + + You can skip other tags, too, like ``style`` or ``script`` or other places + where you don't want linkification happening. + + All uses of linkify that use ``skip_pre`` will need to be updated. + + +**Changes** + +* Supports Python 3.6. + +* Supports html5lib >= 0.99999999 (8 9s). + +* There's a ``bleach.sanitizer.Cleaner`` class that you can instantiate with your + favorite clean settings for easy reuse. + +* There's a ``bleach.linkifier.Linker`` class that you can instantiate with your + favorite linkify settings for easy reuse. + +* There's a ``bleach.linkifier.LinkifyFilter`` which is an htm5lib filter that + you can pass as a filter to ``bleach.sanitizer.Cleaner`` allowing you to clean + and linkify in one pass. + +* ``bleach.clean`` and friends can now take a callable as an attributes arg value. + +* Tons of bug fixes. + +* Cleaned up tests. + +* Documentation fixes. + + +Version 1.5 (November 4th, 2016) +-------------------------------- + +**Security fixes** + +* None + +**Backwards incompatible changes** + +* clean: The list of ``ALLOWED_PROTOCOLS`` now defaults to http, https and + mailto. + + Previously it was a long list of protocols something like ed2k, ftp, http, + https, irc, mailto, news, gopher, nntp, telnet, webcal, xmpp, callto, feed, + urn, aim, rsync, tag, ssh, sftp, rtsp, afs, data. (#149) + +**Changes** + +* clean: Added ``protocols`` to arguments list to let you override the list of + allowed protocols. Thank you, Andreas Malecki! (#149) + +* linkify: Fix a bug involving periods at the end of an email address. Thank you, + Lorenz Schori! (#219) + +* linkify: Fix linkification of non-ascii ports. Thank you Alexandre, Macabies! + (#207) + +* linkify: Fix linkify inappropriately removing node tails when dropping nodes. + (#132) + +* Fixed a test that failed periodically. (#161) + +* Switched from nose to py.test. (#204) + +* Add test matrix for all supported Python and html5lib versions. (#230) + +* Limit to html5lib ``>=0.999,!=0.9999,!=0.99999,<0.99999999`` because 0.9999 + and 0.99999 are busted. + +* Add support for ``python setup.py test``. (#97) + + +Version 1.4.3 (May 23rd, 2016) +------------------------------ + +**Security fixes** + +* None + +**Changes** + +* Limit to html5lib ``>=0.999,<0.99999999`` because of impending change to + sanitizer api. #195 + + +Version 1.4.2 (September 11, 2015) +---------------------------------- + +**Changes** + +* linkify: Fix hang in linkify with ``parse_email=True``. (#124) + +* linkify: Fix crash in linkify when removing a link that is a first-child. (#136) + +* Updated TLDs. + +* linkify: Don't remove exterior brackets when linkifying. (#146) + + +Version 1.4.1 (December 15, 2014) +--------------------------------- + +**Changes** + +* Consistent order of attributes in output. + +* Python 3.4 support. + + +Version 1.4 (January 12, 2014) +------------------------------ + +**Changes** + +* linkify: Update linkify to use etree type Treewalker instead of simpletree. + +* Updated html5lib to version ``>=0.999``. + +* Update all code to be compatible with Python 3 and 2 using six. + +* Switch to Apache License. + + +Version 1.3 +----------- + +* Used by Python 3-only fork. + + +Version 1.2.2 (May 18, 2013) +---------------------------- + +* Pin html5lib to version 0.95 for now due to major API break. + + +Version 1.2.1 (February 19, 2013) +--------------------------------- + +* ``clean()`` no longer considers ``feed:`` an acceptable protocol due to + inconsistencies in browser behavior. + + +Version 1.2 (January 28, 2013) +------------------------------ + +* ``linkify()`` has changed considerably. Many keyword arguments have been + replaced with a single callbacks list. Please see the documentation for more + information. + +* Bleach will no longer consider unacceptable protocols when linkifying. + +* ``linkify()`` now takes a tokenizer argument that allows it to skip + sanitization. + +* ``delinkify()`` is gone. + +* Removed exception handling from ``_render``. ``clean()`` and ``linkify()`` may + now throw. + +* ``linkify()`` correctly ignores case for protocols and domain names. + +* ``linkify()`` correctly handles markup within an tag. + + +Version 1.1.5 +------------- + + +Version 1.1.4 +------------- + + +Version 1.1.3 (July 10, 2012) +----------------------------- + +* Fix parsing bare URLs when parse_email=True. + + +Version 1.1.2 (June 1, 2012) +---------------------------- + +* Fix hang in style attribute sanitizer. (#61) + +* Allow ``/`` in style attribute values. + + +Version 1.1.1 (February 17, 2012) +--------------------------------- + +* Fix tokenizer for html5lib 0.9.5. + + +Version 1.1.0 (October 24, 2011) +-------------------------------- + +* ``linkify()`` now understands port numbers. (#38) + +* Documented character encoding behavior. (#41) + +* Add an optional target argument to ``linkify()``. + +* Add ``delinkify()`` method. (#45) + +* Support subdomain whitelist for ``delinkify()``. (#47, #48) + + +Version 1.0.4 (September 2, 2011) +--------------------------------- + +* Switch to SemVer git tags. + +* Make ``linkify()`` smarter about trailing punctuation. (#30) + +* Pass ``exc_info`` to logger during rendering issues. + +* Add wildcard key for attributes. (#19) + +* Make ``linkify()`` use the ``HTMLSanitizer`` tokenizer. (#36) + +* Fix URLs wrapped in parentheses. (#23) + +* Make ``linkify()`` UTF-8 safe. (#33) + + +Version 1.0.3 (June 14, 2011) +----------------------------- + +* ``linkify()`` works with 3rd level domains. (#24) + +* ``clean()`` supports vendor prefixes in style values. (#31, #32) + +* Fix ``linkify()`` email escaping. + + +Version 1.0.2 (June 6, 2011) +---------------------------- + +* ``linkify()`` supports email addresses. + +* ``clean()`` supports callables in attributes filter. + + +Version 1.0.1 (April 12, 2011) +------------------------------ + +* ``linkify()`` doesn't drop trailing slashes. (#21) +* ``linkify()`` won't linkify 'libgl.so.1'. (#22) diff --git a/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/RECORD b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/RECORD new file mode 100644 index 00000000..df992587 --- /dev/null +++ b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/RECORD @@ -0,0 +1,103 @@ +bleach-6.1.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +bleach-6.1.0.dist-info/LICENSE,sha256=vsIjjBSaYyuPsmgT9oes6rq4AyfzJwdpwsFhV4g9MTA,569 +bleach-6.1.0.dist-info/METADATA,sha256=1SuJgikPmVEIDjs_NHu_oLycasw9HiTE19bLhRC8FSw,30425 +bleach-6.1.0.dist-info/RECORD,, +bleach-6.1.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +bleach-6.1.0.dist-info/WHEEL,sha256=yQN5g4mg4AybRjkgi-9yy4iQEFibGQmlz78Pik5Or-A,92 +bleach-6.1.0.dist-info/top_level.txt,sha256=dcv0wKIySB0zMjAEXLwY4V0-3IN9UZQGAT1wDmfQICY,7 +bleach/__init__.py,sha256=bCOdn7NC262aA1v98sl-lklPqeaw_5LiXqYSf-XAwUM,3649 +bleach/__pycache__/__init__.cpython-312.pyc,, +bleach/__pycache__/callbacks.cpython-312.pyc,, +bleach/__pycache__/css_sanitizer.cpython-312.pyc,, +bleach/__pycache__/html5lib_shim.cpython-312.pyc,, +bleach/__pycache__/linkifier.cpython-312.pyc,, +bleach/__pycache__/parse_shim.cpython-312.pyc,, +bleach/__pycache__/sanitizer.cpython-312.pyc,, +bleach/_vendor/README.rst,sha256=eXeKT2JdZB4WX1kuhTa8W9Jp9VXtwIKFxo5RUL5exmM,2160 +bleach/_vendor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +bleach/_vendor/__pycache__/__init__.cpython-312.pyc,, +bleach/_vendor/__pycache__/parse.cpython-312.pyc,, +bleach/_vendor/html5lib-1.1.dist-info/AUTHORS.rst,sha256=DrNAMifoDpuQyJn-KW-H6K8Tt2a5rKnV2UF4-DRrGUI,983 +bleach/_vendor/html5lib-1.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +bleach/_vendor/html5lib-1.1.dist-info/LICENSE,sha256=FqOZkWGekvGGgJMtoqkZn999ld8-yu3FLqBiGKq6_W8,1084 +bleach/_vendor/html5lib-1.1.dist-info/METADATA,sha256=Y3w-nd_22HQnQRy3yypVsV_ke2FF94uUD4-vGpc2DnI,16076 +bleach/_vendor/html5lib-1.1.dist-info/RECORD,sha256=u-y_W5lhdsHC1OSMnA4bCi3-11IgQ_FAIW6viMu8_LA,3486 +bleach/_vendor/html5lib-1.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +bleach/_vendor/html5lib-1.1.dist-info/WHEEL,sha256=kGT74LWyRUZrL4VgLh6_g12IeVl_9u9ZVhadrgXZUEY,110 +bleach/_vendor/html5lib-1.1.dist-info/top_level.txt,sha256=XEX6CHpskSmvjJB4tP6m4Q5NYXhIf_0ceMc0PNbzJPQ,9 +bleach/_vendor/html5lib/__init__.py,sha256=pWnYcfZ69wNLrdQL7bpr49FUi8O8w0KhKCOHsyRgYGQ,1143 +bleach/_vendor/html5lib/__pycache__/__init__.cpython-312.pyc,, +bleach/_vendor/html5lib/__pycache__/_ihatexml.cpython-312.pyc,, +bleach/_vendor/html5lib/__pycache__/_inputstream.cpython-312.pyc,, +bleach/_vendor/html5lib/__pycache__/_tokenizer.cpython-312.pyc,, +bleach/_vendor/html5lib/__pycache__/_utils.cpython-312.pyc,, +bleach/_vendor/html5lib/__pycache__/constants.cpython-312.pyc,, +bleach/_vendor/html5lib/__pycache__/html5parser.cpython-312.pyc,, +bleach/_vendor/html5lib/__pycache__/serializer.cpython-312.pyc,, +bleach/_vendor/html5lib/_ihatexml.py,sha256=ifOwF7pXqmyThIXc3boWc96s4MDezqRrRVp7FwDYUFs,16728 +bleach/_vendor/html5lib/_inputstream.py,sha256=IKuMiY8rzb7pqIGCpbvTqsxysLEpgEHWYvYEFu4LUAI,32300 +bleach/_vendor/html5lib/_tokenizer.py,sha256=WvJQa2Mli4NtTmhLXkX8Jy5FcWttqCaiDTiKyaw8D-k,77028 +bleach/_vendor/html5lib/_trie/__init__.py,sha256=nqfgO910329BEVJ5T4psVwQtjd2iJyEXQ2-X8c1YxwU,109 +bleach/_vendor/html5lib/_trie/__pycache__/__init__.cpython-312.pyc,, +bleach/_vendor/html5lib/_trie/__pycache__/_base.cpython-312.pyc,, +bleach/_vendor/html5lib/_trie/__pycache__/py.cpython-312.pyc,, +bleach/_vendor/html5lib/_trie/_base.py,sha256=CaybYyMro8uERQYjby2tTeSUatnWDfWroUN9N7ety5w,1013 +bleach/_vendor/html5lib/_trie/py.py,sha256=zg7RZSHxJ8mLmuI_7VEIV8AomISrgkvqCP477AgXaG0,1763 +bleach/_vendor/html5lib/_utils.py,sha256=AxAJSG15eyarCgKMnlUwzs1X6jFHXqEvhlYEOxAFmis,4919 +bleach/_vendor/html5lib/constants.py,sha256=Ll-yzLU_jcjyAI_h57zkqZ7aQWE5t5xA4y_jQgoUUhw,83464 +bleach/_vendor/html5lib/filters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +bleach/_vendor/html5lib/filters/__pycache__/__init__.cpython-312.pyc,, +bleach/_vendor/html5lib/filters/__pycache__/alphabeticalattributes.cpython-312.pyc,, +bleach/_vendor/html5lib/filters/__pycache__/base.cpython-312.pyc,, +bleach/_vendor/html5lib/filters/__pycache__/inject_meta_charset.cpython-312.pyc,, +bleach/_vendor/html5lib/filters/__pycache__/lint.cpython-312.pyc,, +bleach/_vendor/html5lib/filters/__pycache__/optionaltags.cpython-312.pyc,, +bleach/_vendor/html5lib/filters/__pycache__/sanitizer.cpython-312.pyc,, +bleach/_vendor/html5lib/filters/__pycache__/whitespace.cpython-312.pyc,, +bleach/_vendor/html5lib/filters/alphabeticalattributes.py,sha256=lViZc2JMCclXi_5gduvmdzrRxtO5Xo9ONnbHBVCsykU,919 +bleach/_vendor/html5lib/filters/base.py,sha256=z-IU9ZAYjpsVsqmVt7kuWC63jR11hDMr6CVrvuao8W0,286 +bleach/_vendor/html5lib/filters/inject_meta_charset.py,sha256=egDXUEHXmAG9504xz0K6ALDgYkvUrC2q15YUVeNlVQg,2945 +bleach/_vendor/html5lib/filters/lint.py,sha256=upXATs6By7cot7o0bnNqR15sPq2Fn6Vnjvoy3gyO_rY,3631 +bleach/_vendor/html5lib/filters/optionaltags.py,sha256=8lWT75J0aBOHmPgfmqTHSfPpPMp01T84NKu0CRedxcE,10588 +bleach/_vendor/html5lib/filters/sanitizer.py,sha256=XGNSdzIqDTaHot1V-rRj1V_XOolApJ7n95tHP9JcgNU,26885 +bleach/_vendor/html5lib/filters/whitespace.py,sha256=8eWqZxd4UC4zlFGW6iyY6f-2uuT8pOCSALc3IZt7_t4,1214 +bleach/_vendor/html5lib/html5parser.py,sha256=w5hZJh0cvD3g4CS196DiTmuGpSKCMYe1GS46-yf_WZQ,117174 +bleach/_vendor/html5lib/serializer.py,sha256=K2kfoLyMPMFPfdusfR30SrxNkf0mJB92-P5_RntyaaI,15747 +bleach/_vendor/html5lib/treeadapters/__init__.py,sha256=18hyI-at2aBsdKzpwRwa5lGF1ipgctaTYXoU9En2ZQg,650 +bleach/_vendor/html5lib/treeadapters/__pycache__/__init__.cpython-312.pyc,, +bleach/_vendor/html5lib/treeadapters/__pycache__/genshi.cpython-312.pyc,, +bleach/_vendor/html5lib/treeadapters/__pycache__/sax.cpython-312.pyc,, +bleach/_vendor/html5lib/treeadapters/genshi.py,sha256=CH27pAsDKmu4ZGkAUrwty7u0KauGLCZRLPMzaO3M5vo,1715 +bleach/_vendor/html5lib/treeadapters/sax.py,sha256=BKS8woQTnKiqeffHsxChUqL4q2ZR_wb5fc9MJ3zQC8s,1776 +bleach/_vendor/html5lib/treebuilders/__init__.py,sha256=AysSJyvPfikCMMsTVvaxwkgDieELD5dfR8FJIAuq7hY,3592 +bleach/_vendor/html5lib/treebuilders/__pycache__/__init__.cpython-312.pyc,, +bleach/_vendor/html5lib/treebuilders/__pycache__/base.cpython-312.pyc,, +bleach/_vendor/html5lib/treebuilders/__pycache__/dom.cpython-312.pyc,, +bleach/_vendor/html5lib/treebuilders/__pycache__/etree.cpython-312.pyc,, +bleach/_vendor/html5lib/treebuilders/__pycache__/etree_lxml.cpython-312.pyc,, +bleach/_vendor/html5lib/treebuilders/base.py,sha256=oeZNGEB-kt90YJGVH05gb5a8E7ids2AbYwGRsVCieWk,14553 +bleach/_vendor/html5lib/treebuilders/dom.py,sha256=22whb0C71zXIsai5mamg6qzBEiigcBIvaDy4Asw3at0,8925 +bleach/_vendor/html5lib/treebuilders/etree.py,sha256=EbmHx-wQ-11MVucTPtF7Ul92-mQGN3Udu_KfDn-Ifhk,12824 +bleach/_vendor/html5lib/treebuilders/etree_lxml.py,sha256=OazDHZGO_q4FnVs4Dhs4hzzn2JwGAOs-rfV8LAlUGW4,14754 +bleach/_vendor/html5lib/treewalkers/__init__.py,sha256=OBPtc1TU5mGyy18QDMxKEyYEz0wxFUUNj5v0-XgmYhY,5719 +bleach/_vendor/html5lib/treewalkers/__pycache__/__init__.cpython-312.pyc,, +bleach/_vendor/html5lib/treewalkers/__pycache__/base.cpython-312.pyc,, +bleach/_vendor/html5lib/treewalkers/__pycache__/dom.cpython-312.pyc,, +bleach/_vendor/html5lib/treewalkers/__pycache__/etree.cpython-312.pyc,, +bleach/_vendor/html5lib/treewalkers/__pycache__/etree_lxml.cpython-312.pyc,, +bleach/_vendor/html5lib/treewalkers/__pycache__/genshi.cpython-312.pyc,, +bleach/_vendor/html5lib/treewalkers/base.py,sha256=ouiOsuSzvI0KgzdWP8PlxIaSNs9falhbiinAEc_UIJY,7476 +bleach/_vendor/html5lib/treewalkers/dom.py,sha256=EHyFR8D8lYNnyDU9lx_IKigVJRyecUGua0mOi7HBukc,1413 +bleach/_vendor/html5lib/treewalkers/etree.py,sha256=gkD4tfEfRWPsEGvgHHJxZmKZXUvBzVVGz3v5C_MIiOE,4539 +bleach/_vendor/html5lib/treewalkers/etree_lxml.py,sha256=eLedbn6nPjlpebibsWVijey7WEpzDwxU3ubwUoudBuA,6345 +bleach/_vendor/html5lib/treewalkers/genshi.py,sha256=4D2PECZ5n3ZN3qu3jMl9yY7B81jnQApBQSVlfaIuYbA,2309 +bleach/_vendor/parse.py,sha256=Rq-WbjO2JHrh1X2UWRFaPrRs2p-AnJ8U4FKrwv6NrLI,39023 +bleach/_vendor/parse.py.SHA256SUM,sha256=-AaiqN-9otw_X0vFjKkbKWFvkp68iLME92_wI-8-vm0,75 +bleach/_vendor/vendor.txt,sha256=6FFZyenumgWqnhLgbCa4yzL4HVNaSUDC2DHNyR5Fy6w,184 +bleach/_vendor/vendor_install.sh,sha256=x_Pn4dkfzPMJCZKwHHFxp0EAL5RsIfz-HSdTWHuI4yA,453 +bleach/callbacks.py,sha256=JNTGiM5_3bKsGltpR9ZYEz_C_b7-vfDlTTdQCirbdyc,752 +bleach/css_sanitizer.py,sha256=QFMxRKBUMSuNvYkVpB2WRBQO609eFbU-p9P_LhU6jtM,2526 +bleach/html5lib_shim.py,sha256=cWdAh70QZWz4MwtihdiA1gZJ0hTkvRjUYurE4uoCHCg,23294 +bleach/linkifier.py,sha256=vWOXKuRXirpCwejUEEyfe8EWJ7rBlieMDEerg95OhPU,22375 +bleach/parse_shim.py,sha256=VDPOdBOKbuDEceKVvfoggcr6A332bkcq4Z8jMtOJlAQ,50 +bleach/sanitizer.py,sha256=JqDuTINOybpc_eHBzG_H7cnkHdFskZGbfsaBc-hDPH8,21934 diff --git a/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/REQUESTED b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/REQUESTED new file mode 100644 index 00000000..e69de29b diff --git a/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/WHEEL b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/WHEEL new file mode 100644 index 00000000..7e688737 --- /dev/null +++ b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/WHEEL @@ -0,0 +1,5 @@ +Wheel-Version: 1.0 +Generator: bdist_wheel (0.41.2) +Root-Is-Purelib: true +Tag: py3-none-any + diff --git a/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/top_level.txt b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/top_level.txt new file mode 100644 index 00000000..a02d6008 --- /dev/null +++ b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/top_level.txt @@ -0,0 +1 @@ +bleach diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/__init__.py b/Backend/venv/lib/python3.12/site-packages/bleach/__init__.py new file mode 100644 index 00000000..12e93b4d --- /dev/null +++ b/Backend/venv/lib/python3.12/site-packages/bleach/__init__.py @@ -0,0 +1,125 @@ +from bleach.linkifier import ( + DEFAULT_CALLBACKS, + Linker, +) +from bleach.sanitizer import ( + ALLOWED_ATTRIBUTES, + ALLOWED_PROTOCOLS, + ALLOWED_TAGS, + Cleaner, +) + + +# yyyymmdd +__releasedate__ = "20231006" +# x.y.z or x.y.z.dev0 -- semver +__version__ = "6.1.0" + + +__all__ = ["clean", "linkify"] + + +def clean( + text, + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES, + protocols=ALLOWED_PROTOCOLS, + strip=False, + strip_comments=True, + css_sanitizer=None, +): + """Clean an HTML fragment of malicious content and return it + + This function is a security-focused function whose sole purpose is to + remove malicious content from a string such that it can be displayed as + content in a web page. + + This function is not designed to use to transform content to be used in + non-web-page contexts. + + Example:: + + import bleach + + better_text = bleach.clean(yucky_text) + + + .. Note:: + + If you're cleaning a lot of text and passing the same argument values or + you want more configurability, consider using a + :py:class:`bleach.sanitizer.Cleaner` instance. + + :arg str text: the text to clean + + :arg set tags: set of allowed tags; defaults to + ``bleach.sanitizer.ALLOWED_TAGS`` + + :arg dict attributes: allowed attributes; can be a callable, list or dict; + defaults to ``bleach.sanitizer.ALLOWED_ATTRIBUTES`` + + :arg list protocols: allowed list of protocols for links; defaults + to ``bleach.sanitizer.ALLOWED_PROTOCOLS`` + + :arg bool strip: whether or not to strip disallowed elements + + :arg bool strip_comments: whether or not to strip HTML comments + + :arg CSSSanitizer css_sanitizer: instance with a "sanitize_css" method for + sanitizing style attribute values and style text; defaults to None + + :returns: cleaned text as unicode + + """ + cleaner = Cleaner( + tags=tags, + attributes=attributes, + protocols=protocols, + strip=strip, + strip_comments=strip_comments, + css_sanitizer=css_sanitizer, + ) + return cleaner.clean(text) + + +def linkify(text, callbacks=DEFAULT_CALLBACKS, skip_tags=None, parse_email=False): + """Convert URL-like strings in an HTML fragment to links + + This function converts strings that look like URLs, domain names and email + addresses in text that may be an HTML fragment to links, while preserving: + + 1. links already in the string + 2. urls found in attributes + 3. email addresses + + linkify does a best-effort approach and tries to recover from bad + situations due to crazy text. + + .. Note:: + + If you're linking a lot of text and passing the same argument values or + you want more configurability, consider using a + :py:class:`bleach.linkifier.Linker` instance. + + .. Note:: + + If you have text that you want to clean and then linkify, consider using + the :py:class:`bleach.linkifier.LinkifyFilter` as a filter in the clean + pass. That way you're not parsing the HTML twice. + + :arg str text: the text to linkify + + :arg list callbacks: list of callbacks to run when adjusting tag attributes; + defaults to ``bleach.linkifier.DEFAULT_CALLBACKS`` + + :arg list skip_tags: list of tags that you don't want to linkify the + contents of; for example, you could set this to ``['pre']`` to skip + linkifying contents of ``pre`` tags + + :arg bool parse_email: whether or not to linkify email addresses + + :returns: linkified text as unicode + + """ + linker = Linker(callbacks=callbacks, skip_tags=skip_tags, parse_email=parse_email) + return linker.linkify(text) diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/__init__.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..25b2c08e1bc560fb2ac9aa6730c73b463ffbe503 GIT binary patch literal 3871 zcmd5iNX*K!NK6%<;9V4^iqp!W%XGAOo+ zL8(;=%B^xxX;m;k*_v{Tt!cO9mbWKbRZ$j2QF1HplsnxmyVXAxS~H^HPP>)vq&tJR zGlS`sGyf)KD-+4W-Fx>po@_SlI~$vuw>R$md@Ct$df|?cE5&3HbB}&=@2@R=b*% z99l^#KID7d!Tsh#r!umczNYY*#OLTfhEFVAz?XUU3;PrMll#T}(ti1`g-=I*`mnNJ z`GEF+?b)|J9i7~Qf&5n`%QA=<583<8Uu?3j_B_S9ap-6-!mMJPDdEJ@(}T5c=^ z2%=r_3awok1teBWFWhD-c6v!g*dn;I;_vPMeQ|+ z!$>n%C~rH2039)4rY|jdsJf91a`Q0>WyT~gGo?e5Pas(&8Amq+;^+qU>9S?M z5x$@lQe%ERmS8(kU{+r*%9NaJ+nA6$TU%RM?l4CwJI|y>o)~mrEJZ{#!D;! z=m;g}IGvI1CJjyT&;!(-Jfv)t`*cB+S)%MakxaO3p%_6+OCJJaa+6YzZY)2iD78!J1zl z&Mpj>&JP#AJDgh@zWu}D^7miPR_4nG?;W1C&MbX=^P`*3&wVj-;YIPn5l&f7k;c&j zR!Yh4XZUfN>_Bsq>CO*}`^9fSb3Y-Pi^5%j+WX}3=9=&Apa>c|QOKv$uz!aO#l)sEOK-~(cp zQ$(C$S;d9)h&XXr$X$ePL@d{8DOgl2DY!c@VyVg-1C7e{8jGb5o{nNbA&c`uXV$OR zQhY4OmRde)MqmwD0eDKUi7xIIvc8Xu28$Wvpa~m*R0;>q$C8*^0 zxy*Y8Q>yiz_f4ofg)$f`=!Ut7G&?9JD4-dWxeuCtTa<$|2?X;iBz|dRTSyF7=D7;J_K_4`_g77K%J_|$E7NT|P-Y`ro(vEM{ zbWlzd`(PF7TBt>ajCT9CmvMV9|GwR(S3;1>8o7*YMuDMbo5bP>4$yW$8*`^2S&ELZ zO}mzh~0C9#4*lCNdGfs~|J(!V1~f2$0+CiAu}(ZX>2N8wu7b~0nz zs9p-C3NIDgM#5qXaj*gT7t822(7&2`H;CNW7q=wArhiqciQ#avP$+ykFq>zct^Wm}hMo!l literal 0 HcmV?d00001 diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/callbacks.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/callbacks.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f05236ac42253e132c52443b5aa64a5607234776 GIT binary patch literal 1274 zcmbtUJ#5oZ5Pr{ownJJdseoe2Pa=MeXf0qs;!h$9KPpugmI?_)j{TB2aqY-<6M`HO zB!&)1{4A6K@v{Kx!pI6M3!#XHH&tR}L6n6~+)EmUFdJ90Aq7 z9kRa*2z}*;KEb(5tJ^_5L>9V=EMno?_$me|fD{^{Cfzu4E*a1;=@pWBBe2b+X*f>a zFe^c_;Q7hCLk+Vu>DX??E;J_1epa4ZhDU^F^#bnRfSW=jM5u;+;8#$DB0&Xr8N7pJ z35)C29@Y|@VuZ>(+WjsG3LLg&PhPO&YQhCE55-O1U6=0~QJoGmr`cotH zL_7@u-XBxIJTs~HHMx#7CHqRqGE(wsVNIDeY$xM*p1lPu1h}csWTRT8uEj{%vt3PLk`ab}z(kkUS&UzitI!5hAaz#} zYlg$(`~=Dz4SF8X1Ky!zkJhf~C2yAMMfbX%p+P0|s`@1_q|QXz^D4Gm)YC9LaI0RU zZcTUWyk2dDCC@!RdGweb*dd*$0>CKJK<^Eg{%7JQs|}wwxXTDA!E<0O)EU{;y0oH< z+?#7FJ3Av==7(35;rW?cH1)Nob7fK6rNqL9ouo@xN(xk1Tf*(P{;f<-YxN$)p;l`6ZJv zvl@WO00a|Vv25%lUI5^e3N#%TS>W+T08vsZcd?-WW2WU>xnWhu3WbRu?MBoZwPB8F zgkqHu({TGVVq3b>aAR1Vg3vook;#^BbrrKrRxIsG~@aNgf^qh=jbICRAgTS^UX-|tO%r3fxg z!dI4X(WDqG2vf^o!KXY`j1qREl&EC`+qN6FEv!_Wgf~HW1(w|fI>G?H+4wF>0c=mhCM#uQY%DbOWau2JaVb?E!rqVYDb{Xi>x=EEPpf zSQQq-nz)J=jQp^0Qz`J*X<7E}{L4U`iu z=R|i@(czn8O;&ANwy0@w-mv`wx9g=IJQs2$8|y`yfuM932%8_6&1Rn~g|b=2g_3cj zZ~@yDc>jeDOosLRdDE=Gb1R&OD#V6XsAHpE(8*H4YOu0toSHguvS9P?09;p;61EFV zP%vt_z*Dlj7oD;iZd8^D+*_8NkzNj_y6{DwPa4E6v>8W-&#g(nMfZ0Oon4b2M)z+4 zj?^yIT;Yw!qw5EtWgW3?a+Z6oxFw9H&}}_8#inQr4WWc?hMM99bp0!YZc0sQx!cHm z2Ty8Z

Zp3%ijXI9N8(a`a`ij_mQ*+Iwwurw`Z*w`ZH7<$;|z*P2o{YXRN<_%%6( zya-w|1aV5ukS2|T=c{h>YX8bi7~8*qxP>04 z#@ngmt<>@FM>bNY*CKx=M>@kJk4DDU;+>IQ&rmFuUz_VB(_c8BJKtP?kUaV*J>E_q zXr&L_mD)#UTkuQIcE%>!V~1N~hwr}KJ~rPvHveF3{!w~QJAJT~K6nQ|OuyAhjjm_D z%-r95=0WO=JG}eO(Yx2aooY``wfE)DKAc-{xABEfF81J*H&=QB#COO$$D^6P$x$xm;qNt%RkRsF z*_qDX>8-v<_}tdeczDKl;AZeW55eTdiqv%A>tRNxL8miZc)_3?dJcFVJ?rQHE`0|! zc#pBU#b!tlgx^u}X#{2VZ;FL*WGi$cBi=`&Ps@S_#W>XOLH|rl3Nz0T2rrTU076xl A5C8xG literal 0 HcmV?d00001 diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/html5lib_shim.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/html5lib_shim.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7c9264f3e15c11e82fe4fd72e7b1dd5c7a8f3738 GIT binary patch literal 18728 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�^;(k5J(~)VJn#m zwf9_HMceGrKRfx($@%i#FbfKSMU5@vj-R^g9_B4B4AzL>T^I1=>-sHIZ2D^+GY z28)6*UWd|{?Fygt=AQIYg5$MM*6Hh@K9l^l0u$267db$U@)sP0m)~H9h7dl0x%CU12=szGpwYrPAeL z`Cz(p9nJzTX(ZCIpE=c)gB@0fr-<3?!C?QB*v%czy{-J`HQT07%%qoU>SjhWHCwYn z4oF zCX)GD<%8oyjvPO9?$D8*u5;ZFRXus=Tz6O3xpQaFnZ{Ot2?^#I(M1Iuv!rv%e}kb{ z$%O?Jtt%JUI~}5}`ZkhZvc1Yy*Uh;@9|o(Yp2-B8=YuVGxx>6CG~Si<*UkC2ER{9Q zd_GgwHn;WZTV+pYL$%WbuNS>nmkI4g{!P#8o|#Pxq42!`*Su|oc75s5z2DvW?H3jw zJ)U{=`23?!+;gJvF6VA8pX{36Gj(o?kq0I)lH^>E$Y}IsC^b-#WS2 zv@6rJYrbiZKH2T21A1nuWs5oIQm|@z=hO=en;yFrd<+Yy-g2vKbGD@|Q`TfXRn>j% zWR`VV3WS#3Tp&34`H3qF!k%0HJ=w0RMK^!DBq%PlRh;q>a$?-5($!9SE|)tjoL00hO!+b}=~( z7Xh&m)4k9QNh)tdUz%psWy)?sYR_NmsLxuiVo5F zhRu}9JVpt-be<~T#GoGmQMvp4CV3wBZ~@bfbRsP>G^{RC3r96y|A;I@*<$LX=4Ax( z)QB`9$$x~N@@?vFI4=r~bH^iK)mjnRgNMwMzO_i|7wDRD;WA{&eJrC<;wD)ZPdyrXw|JU+sn7RR4Ml>bp zc{xwf^}>n5MR#q+UAy3}yX`NVxRmiX&ik9^+|8c?fn%6?eF#9)Thnu~mgDi|zcn=o z)uIPz5(_A0xIYByS$&xh`|Ptol)t-9YiP}@FL@W*T9lKkm0PW~TAC@-rfNRRYSn-_ z65h`)+OOD>wW0%Q6=o|j>vH#@U@mXw%s0PcQ_IaV=z^eZI4$j$$?7tT$$qjRiWw|z z)M~&DRp;_%F6YLC6~_(LnB}=U#`T*|j2D>?dH4LeRl%&7Z~)`Gau{!Np&z4x<{$;* zKJS(PHJ)@fR3d*!`79+LQ1VksG)jI($zLPU%2tW|?w&*EdbA*sKm=9Yk;A9X9yu8~ z)%E$VQ#nn6F)I1L(xFsq!%Fj@kk!WgZp-xI{htmawnxr5q7Na zp5^tZ=O?r~vG9}vy)-A~V@)hm_$G(pp9%swF^5=MG%f}aO z0q4{Fy(X7)(Z1BD0hGuiY5z9&hF{b3NPiXCtN2D;8 zU`$JMC4|;vQLHt*&l;1~h2_wD9$0Tp2t7l%8N9F(&b#|nt1ny6YTwlZOEW4hcbw2F zN(0f6xC(bT1+*c+X*G}#l7`vXV&S+4P<4m9?l&hFdg~gv)Q-?7z}p%$cTR9-Z4-Jw zP{J~yfeLi2xYp@~DXVl#{#rSVXJ*Umr`J5=t64;W;X!=Bvp8??$bKSy^CBNBV*_5y0^5Yh*{sva z8Rhyl205G~^_+K>oF0(*Yj|kK;N5-0KISBXM_+4Rv?dq-s>|GYp7W3nVQCAwX1Nxl zaCPQ1qb7toF}>qcNBvD>J_k&@#9g*O$H8-CKNGg=a-VH3QzqWWO5i9jVo@igDV(N& zJqo`>Bpw1+G>;xI2AW}#LU$H>n1Cn#D}NmWXl@FOI+aQdYk`MCG7Zf6D{AMlrjdV* zXZi0bAtYjkCc_`yGo-m9%*&Ckco>CP#V3S7Y4imClyDp2K&Cj9$_8Lcv2nXy*|yBt zs=V3K%GslfZHF>#hkn$wVyBXG&$2r;27W>H_fB}P`Ia3HPYucH(-Wt^diIyM{dIRZ zn_lw7#1q#}8CAb`cG-#I`wD5Q-**NMm2=-O4 zcmFFAGR#dwnyKec=A_{m_pU((qWuCGj?9mO7(^#Cvt3Zk-FQL-d=)1aTpB(;v`6p^|uM3<_hkM38Yv$Yd9D>Fp(9!-Oaam)!g>zPRbd z7g_%oU)+bBz7DJc6h^Dor`{{;XV-g$kz|ZQX7diAAbUxql6;M+h{MKfMiYGqa!uhC z5z>iRUwTD4bhc!wsRtyYD#efIjm8d#M)g@`5;OPt#*gTJM*ca{cWfGeS>vDmh%B^^ z$RK1!NQ~n)kdkJPBh(eauZV!{M~tky>rBsyo)cZ&@_$B4M7uc|EQp(^k*GpJZ^So9 zvDCcuS`l^tZaNSRQZF_@p&wk0%4OJ7KbE9?4gHvSa2Jg~_44J(?O*=V?ZV>mkyrOl z^ItibEv;B83`}mA*gcuf6xJ^_w9N9ewwdtkx#=Afr$N?@t+|5n(@TY+>BF}QH=^5z z!O(Oq0um6Z=FJxR#?uRhP1DD36*k>2EuT!k_Sl@S0m8@EyyD`@LLU~EUf(~lf7&^% z&KEY#Io3DM ze|j>HWGv4W*|{$t$;qsoy?Vtl=6HE0bP~G`DU$vz&vt1Hu?}{~`%tVi5>+ z_aHPE5*9J)4+%;-_N7+2V_yp4*hcj8r^27%f)!C5nrvG*S ztY;zg2-+{X1K0f%{%IWb)-E?==H*t-SA2cf#IDKR^My5Y4qc?JEzMkbp$DYdE`YHx zM_QsKNxls7BNs2wm&=n&mbAOViLNX7ic2L)$*#5B&qM+^Lgr)~$ePD`Oxnq!n>qE? zAgvt1lHx{=ZKJ0L(zHVKf?!Fh{M4T0-iEBTiG^s*$!+EsV>Ymq=HJwt2!G6umVDBW zxtOz@R?B2LJF;fu@YkAg5b8s@*BoE;vA2WT+s%!_vXu{*HLSV~vGgHWNp z!ZEbdHB4c6LIX+h5jl!W6O=Am#*e!q=Rymk)Q{CC53_IOEE<Om4eY^z*_ElLOOFO~q#RX3E>M z6wENx3Ut-#Ifd`_x;po7-<~+L`rloO5r46k>)=+TOY;{D3H+jcCM= zedwWkU2^O{cg{Fu85d}4Tsc$6)LEc^)#XAS=q!V=Kj=G#k0wCp7-<+IdJVL+O3^-M zBe7t#sD~HF!9pOM>3`e9zQ>FTOqn2coZ=c}IM2Ze@DV212tIx>5zk@-1`)5V!Wcfh z)r4*g42p(JO9s)?7owy$2(1$P43a5Gj4Ot9-Ua6uX*YRMks#1~a%PGR(KP%6ng`#g z!Sun>HQ%ArPe!_*JawWca;^)90mJc_3J*dhk-(E^E>|P>KtP9FPIZ*LM&xk{jhGJ# zr_E)xZ+u~~wlh=PdDAgp`{;b>&O4mLSvyfM&X4ba7Cy;;WoNde{M9G2Ma59+pPr1q z+M6v2PCKSH&--dtJZSqXg#^o&cO7!@f9^%nk0qy1r;*n#^>RtOL0wCB7L)NTEH+Z%(sSLI}f${9xyG| zAP9_sdFYaN=NS-IDKjm$*eVO6thsapLm^^uTt~gYE8<9@9a4rIz%l8C<-JSzZI>+od zP<9%@nAFdn+&t!x>rrOEKpK4`^vpHzb<8q0yMTFL1lIq)?S}1loKAR+X!dJcF@hdI zX@hp}7;_ppw13PwMDE~KmsnuoKedND*J$5EOnn!3t(`G;cltJcFUhbhZ&wxbn{7tx zy8Q)o1a&{m4-Ayl*)8c2q<89!goXbb)*JDYPwQ_md(!fN_Y|(om;n|^B~j$MBJZHie}i5M?E)>=Iw2s}U3%kMe7D1z z{3k>(I-+0Ht9w!L2GY{~>vpWC|gX=^IO}X}xax(v%KjsWKgU zXyM<~)cThcG6kW?bh;vw<5H%*k;y$TQ}nau?>7H;h5pYW)?Wm^fyEd0x``#@M32)N z)?0*yq_P~Ondrc6{nmO&1UoUQpw=O3UAx}c5jajInLP1CVfsf1GN}e~3&Bv2HS0lW zuc8Nn{BLSmvMT(e>&#I-w&TChgnvg(JXS1-{C}u$ijp5t@(CqnGy}Phn1zG?<&a7Y z$KsOQK}AetGOT=`+P*=_H;~+S@7KRrIVeAccKG9ze};6~&hz|=!_GVIdO6b?# z$C2Sn+a2<;%90c&F>GUEzytS3N7TovUnEwlz>uQ>AT=3x0Bj-_L`Bk=*+& zK4Ie&M|%uA6UN3bAhUH`FeUHJ>5L- Z7q0s5xQZ}}aDwM+rw@M2(VcD6{{tM@PhkK6 literal 0 HcmV?d00001 diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/linkifier.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/linkifier.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ab87ba6699c0f970b7c9e8308ffa3479baa3679 GIT binary patch literal 18371 zcmeHuZE#!HncltlA_#&6Kfq6s;t?r{1SOK9Wyv-rS(Yg3!{uSBjkdc#oY|e$8BZFwo#_aoGDNWM3^VP{Y^HxG z%b8^KqwVvai+cf5pxq|zOn>x}IJo!RbKdj)oacStga24r=@M|w{I~8<@N0tbU+BTO z?D?CACq+THA;^L(hJ{i3wuHrqWz-_ly)|r&*hcOA-iCX}s6!MK`&5-7rtlpUN1d{L zs;&@Y+N>t(V7DAkK}t^%$}W-yc}> zQXA$(U*)gqVZ=%=d$tk7;m?9^cqA(@M6qP9&QxZ%`vWXCzm|_#tY$C!YVr)WXL5T&&Sx{y{g#{;A zFlY!1PO@N#1;Z>DVZkU1#%5SB&Vm zg=7{|SSV}=3q@EcihqERw{(rX-POhDBysDkr77ejzm_=hO8fQ_JMUyO=Vi?Gn8f2JVafQVvSRD70 zEI!TRVHS_Dc$CFsnmIAzDvM*H#x)jCu=p&C&#{XVQx%3tRby(Bsk2Ozm^RL|0Mlfq zDNGA8ZIWprrcE(znrUYLYZ0bJnHFQ3$~28>38p2PHp8?_Oq*j`iX{RpA+v-MW{C-w z2(rW^OH8rEG)o{R!V*!Ih#AdH#92bczcfS16D%>q60=4A3=_;aj#$l@&eaZxiK4Tw44fFV*<|(j0=3()`ZXCSeE_ zrufESKJuHQx9;_C=qZI-7Nsb$0Z}$b0vPtP4frF%^q-eeEb!WX`W=W`_!|`^Kq_`4`RS`yLH)z%u@0>q}R}qW8FQBb+Q+w1ZpTn=U6PRM5#S-i8^Ug(G;zZH=Qav zHr{>s;I?zm^_};1A0EX2Ica8P=c9DpIc@j1@cy3ScGv{~t4tF2kHr6*b>l+(KiJe$3eRFetXnglz zXQ%IQLCWF5bNz?UpL=ag8@qgYY<_+$JvKJ>+J%G9cAW1iN=NnXWc_g|G1;F=v2Nv3 zAe@w`i5hY}G^XAE` zC;!Q-H%~3s?_73w-LCs_)B8<-z4JrwU+rG@9e!xFRJuNQ2;~)TR$i@q+wzX{#xahrhPI*9994{=IF!Z!Ygg4_l?-*x=z8*plIhN>^0R3j(^lKeJvORkSddKz*?7?R*K2PGa9}#<>+Sb`S zc5du^@7TaoPn|b2QGviIGvC1ae3aJL(?R8NqA&Mq2^H(u%

+Sa}YOAh0al_MRsU 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. +2. Remove all old files and directories of the old version. +3. Run ``pip_install_vendor.sh`` and check everything it produced in including + the ``.dist-info`` directory and contents. +4. Update the bleach minor version in the next release. + + +Reviewing a change involving a vendored library +=============================================== + +Way to verify a vendored library addition/update: + +1. Pull down the branch. +2. Delete all the old files and directories of the old version. +3. Run ``pip_install_vendor.sh``. +4. Run ``git diff`` and verify there are no changes. + + +NB: the current ``vendor.txt`` was generated with pip 20.2.3, which might be necessary to reproduce the dist-info + + +Removing/Unvendoring a vendored library +======================================= + +A vendored library might be removed for any of the following reasons: + +* it violates the vendoring policy (e.g. an incompatible license + change) +* a suitable replacement is found +* bleach has the resources to test and QA new bleach releases against + multiple versions of the previously vendored library + +To unvendor a library: + +1. Remove the library and its hashes from ``vendor.txt``. +2. Remove library files and directories from this directory. +3. Run ``install_vendor.sh`` and check the previously vendored library including + the ``.dist-info`` directory and contents is not installed. +4. Update the bleach minor version in the next release. diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__init__.py b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__pycache__/__init__.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2a6517075bad0dc4c629f44920c9db823731599b GIT binary patch literal 197 zcmX@j%ge<81ZA%^GC}lX5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!O3}~A&rQ`&&#TaP zNiEJU$uH3N$S+CF(RIqt&(6$C*LO-x&Q8rs(JxEQE7Q-(Owuo?EXl~vGuAUS(l5>| zN!2X?Do9LEE!I!UNli@7(2obIOvx|OkB`sH%PfhH*DI*}#bJ}1pHiBWYFESxw3HEu Qi$RQ!%#4hTMa)1J0MZ#Wz5oCK literal 0 HcmV?d00001 diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__pycache__/parse.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__pycache__/parse.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f217c15683dac5ca527c900ba96e49c2e606b41d GIT binary patch literal 43429 zcmeIbd2}2{dMB7!7wSaezCk2z6p52JAzmPOi-aVC)Io`jE+7kJkw6ucRUip;K|-r# zyU?PWg+9!prCyb`d&ba6^M-A!$J|}-Znr(&_3rr17F3j3EOTchZ~rlx*#jat-fqsk z_5Qxdqq2aaq;B_nW501eASyF5G9vPeFTVJ$`0`)6Ty_q}<$vGd55_s}|DX%?uqY?1 zWjMRRiQHLE?VmpLO8efpe$mJnQmvXA4Ew>)ctlSa`Nbbe}C2i*QxqEAkbO8^q$*`Lm_U zc?r(T#8TWXH*iNdvFvqDEcaEsXOw$=wvy#mAh%MNTg7s#kXxx){=*%MzwzLGgL<$1fu)Rd=W zPEC2B@R*wNl+4y7_Mq1%vhSdtWlQ9T=VRJBu0Egr&8aC5+#gd@o|3wnYR3u%eGR(&jVyl)^0%U%?2OvP^0y&>JL;*`>gi

a<8H6$75Ct(Puz>E&Eh_!TTo&@ z^0tZtxY{NjK)PKV6$g>GLp+G9o#LUhyF`PxUp$PfC(r_a@oE;n>X~@tJzhRPyGI-o zkK*~gHqOS0qIewV`;_x1alRk-PvCa|bsZ2-YR?Vg`k-%6yFLW!cbL8J5Z?DGpYh_i z)Yh`U8htw|`oyPE>oIX$dU&MadTm`9gv}KyTWPx0*pSNBseGrBgW2J?NFD&?%p!G?;Dfpl{4K zIqH*yzFoU^sqJmqwY^{X{5KOi&YU{tY3dh_`0!@_F=2Qn5cXb`-`D=Q_w8iEf)~zq zU|{mn9*0;x3ib3J*D7;ux{K=1ToG*3>s@gH%gttnL**0blD%k=pCO9 z`0VJdIyikhwotW?t&IS6!jl&fa^P^X+kSFv(0jo@G0`dLM`f!H>wvFgKt#XjlYq2@ zuVd4agpn0OVW5&%5*dKKff-?ZIy^1m{snK~JTN01T!;Lj3&H6Lkt$^Pd&M8VAo#;T zMuPnMnPOL(0#pORus}GD`89**C#NR-Ui5+05Wav;(limLeP~q>edFFSv>qDs1-z0! z7-INKvk>?%;{ze|sCVqr73x1#hv_!zpYVrggsEw1DhSN(6hgsCpIv!OczN0{T|!M# za8d|P02!x*^I>FlzYLN%@z{qK5Lu3J%f?{krX&p$n(IvrE zZd;%;RwL^GgDk;tcECG22BbfK!GH16#AF~i^|BNSPhY-rb!K+pz~I3{hmRaRcKpc` zCx@PT`k7P1r_Vh5-19G-?d{vVW$U)>J9h5c)Ya`VKH_bUc-JG|{%IMi9K*kM7;t99 z|LSQZH@L8N<09p*IkL=H7@UE8e15qZ-eZ{U9GviZB}P`+TolH zx!pq^W5zim&smm}F^|#X8P`b2JMJ4%O25mkQu~j*Jr_X0dd>%~_8jzuE`@_rJx4Le zC%O&v(h;2d@E0`6ZTWp7xfJzkB$f*F{twDFee>C*1LSh zB2!O~Q96u_jFnviint+3Si(b8uOKMBDjboEkAO=836xIa<^}wRN|DTSA3F-K_b>Fv zE+i}|M|;xL{yANx$?;qKZyA7rT4$qt)Bv>r|GG889pqHpQyJ=E&SMzL0jU{vY{CC3 z2~OWLJjwCw?ayJP=J*INl|=Y_kXSG*7*$9Fc#-=Z{szx;*Xm5%4F6A!SAf855)To0 zL?10K@0v1(?%oW4ReBOFNhI-w24-c+xTzfkRKm8^KSH7(oe0!Lv`U1Dg0MpcB7n_7MB!>MYUl7~bB2f^#9y;yM>b+u z|5Sv(v;#fkuhoR9O?HSzb+ljOO+Z=69^t|?Vc0Rw&*FEO3)_?opt@QJ9bYtnxeES|(@Q6L!TV&~HN=}E9*A&(_v_k*1x9&pTu6*DYlY^P_Y ze1~WcdrtqK0-jvo%DiaO?xrQM~mf!KlQ)d!r(e2|YCA6Pi2`@8+|u^(HJLpLmNz#2LX+6X6X6RjmL?Dy~kpLa6BO?Ltq;F&- zV;>ojH2^qwj$p0!PAGQ@Mn=To7_x06BcX|4I5aZCdc%gE)yfE#G)nbS@^$>L79n|^ zyJtF@E-INn_Rz$epXKGkRAdbQ+F?-6z6|~r>mk6n?Z;p+Bi&g4@?tPUUBY3Y^516+ zej1Q_ECz2{2dZv)J0F;aL@njHl4RRIa(3Guu3kQa1DH`!7N zj>k4K9fBGPnv8dO$&Uy0T&b1r(fkIJllP6f)dJtcldHu@z!kbnufMYJO5D5b7Lrzh z4MPqa_e*#6Eb5hr6_6rGaymHW4TY`*CGoKxR14gs$6uj@&?2WlL~|MFeu$IL2OY7Q zA%2DnQ+ako_^hO)@<$TXw_r4l;EVW+#9OU7z?dsT_Y&%vpgd6y0r44g{x~zT=>9vr*`Y&;O&;EE#0*7ZqwG~rmeSLNHy(Swl+kqvG(}Z zw7ZgUDCKTQv@g3oO9z+Ty-92D=ZxL!X!MK6?(5z9krOomX%3?m;l~VD4VP*FJhy0~ z-%zEFQ#Gq&_8QM~`ST+@rj}($I*Y!1O8hCCWd6VT8$ho5>7W!Ag46gP7bH?G3cX$1 zw{6?94SXeaK4XL)J7a&=J25S@pAbuEK4wf)crrwjMjeXlIG?S3%#5VcODGT`s-HU> zqo%aOopjXRFRh4$1+?}e}lPub^V&p11^cmnc zqYgFzL(sK6jy5xu6!0iKBh8_GX`T{79f>-2Lasjdg@H%g8o;(&DzP&=Mr(rr)&{<74;aoFfejql>ttT$#Im=u@#1ewIhF^1}VB9baUx2IpoH={#F%CWA_dnwM!CqSAl_q0n{3atF z5^;|2S{XV@H*gN^tg>iiY|!!gAP$WOWun3;5Dk6C#qOx|mtjK4iW{IP?2z{3Li!q# zd}IY+?ADg1AEQu+xC-ENaruqf#oDMP-O+uwW7l%WuG`J2j=js)R!|O4{P}y1vUKmx zyS)dNdk_3%ORD$Cva>6CG&Y%d{$8n&u5E}}fks7D@v5Y|F==fCnm4y6ooy&`6+}nP zUQcMW5i|h0k0Uf+dIT|s5#yH_J;R)7&KxmGn-pdBnf?h(9OM@-O`kk{m4IN2IrqmvJ&o2*QvyG715E zR3eRC*e7NQLmtE+n)q16P}jPjqELt^iykqm8}3$bT&~`@v?W#Dvrq`}u`hZd-P(1x zb;ok+j@!mm>+WT16S8;5tM54~(}L%&uw_};a%(Up>|A!%0~fsUvUGL*+Z%3fSTd%n zI~R^e2cj=8wtOsf+!Z!23!86kNeMf&qW9dTSlmENfk0)^SCZCxnNGf2u$E3T_BDq7 zGQ4MWe=SWU3L#PQ`}kk&Lh?G7C5Se2lS1flwo<_eox{9AA#^yC2^~HEYbA7*>j@nb ztNuQcI>s~YXK6n}T8Ws$JSfO$Q{!XaJWHKY^z>t?Q%kA?d`&*;v^CXx^dG1bfCsO$ zUq5yFC#aW=@rx`yClN0#5BEDb%QM`J;yix1Rhsi}=;_DOoUN(q-t{!clxp3RpXMA) z3A;2xqS72ws=8}E&Dp9aPmiNH(m$ftBrAhV3HH+&@q;W$(#b!jgbBx#4=LVGl7Pt6 z%~A=nW}Psz!ba>p&ty%~CC@g>ZUm|v3QO|^*mZpHx6sO!Qn`*iO!A|8Te9kq1I-S(1G4B5~{*!;)B z+B33a49Nmsdk8iMazU6O{i*B;16K{nHx6F~sHI5(<%Pu^24Q^>a)9xQB4g0t&!W8H z473Gy#)HA(1#%zW>j!q77C+&$ZChZ+L%{w0CTEcAlH71+!HqiiYiO`7zI1UFQ?-%i7N0X{`uY3u@=6eg5c z4oyRc?UnI~VKW0WS=tHUV(>~}BIp%S!~3iT8P~Pp~WIo$BcMnMZ|&1t>`G6&eHUMDT^<5ImF=*kJtaVAXSgf!=;UAAV{b`9fsG z%vg(z_P-S}0`(nMF98~DC z%5B06zbQ86iaJezI0LUB^10DXJAfV##|sEhFu_v>_Z7bvyLW61eq$`hC(-*cFB<{l z1XT)QrQ#H)4rRAJhZ|D_G;Xy5Br{j+B%$-HX~yU=F;cvqztXg4q%eJ)AR^U4#8ib_ zlv59pS=922pR#T#|8q=b_=sPv~uJ^n3SqJmGat0(JkqZ=n3LZhVs5C>k}o1sqlZV7XB3mwVa+MIOZsW|MN{R z#-=t)e?Z;J3$ZHwlw#7C0=FT6BLBP+7xyJj|K7QG&MlX2PC7TQnOAuM%)mm)2?FqK z>J8%+DeG5&awS!;4$5DC>PY_`Rgf1RmAZ+e2gkEt0*zR=C^?;jMiOQH3U5ht{BQM^ zKcNcpzol-T=-=|iFY%VFTa>(z`<9fI^OiJCX-rmW5zsq{W!*T&4Ov5n&PBrzCd$SR zolAyc+&BkoKHNhvRbRrKf{_DGA`yN>bM7Gjq!G)u(F;h7*?!T%&6NVlh%oX{Q zOMi}1BY;xqI8NX=V=KQ?*^#t*?%T@lRJ0|n?b^vofvMawe{#jnHFQ4U4CcONYYlXd z9qEqVsP&GumHmEXZT-xQd-B|&1!9c)p&kS?2Je54liZ~v!mDN^rqg5QA)V1e_UlsW zg4xLQ5_G_Tu6d4QnqHkb>M|#lig4AzuRE+kI_pBi#)X@+24Jq5v+A5|c<70_h&2LT z-+Rh<&Dr3cVT{JL3Fcr1*(PN4e?z1fK{V3WRVMm$T&qqBOh#P zNQ6@?nV@MfjVtD{)ZDWax^+0!^u)5MVSX@L5!-OTq$a*4VOesgN_NganclR0+0-$A zIC?lX7%xv)(*>^Y)-D&+B}$i!@6_C{Xi98JRkSZSqQ-@1KQ1nfTJF25;uVRCrP8JG zTfW=Rr(B2U58rcC$J5br2(b2VsXh1%dUUOuP zDC+bgWxZ&;1mYx>!=KGWhLOkB`JU1eo`LZs!q=cAo;DM?BtJNM7M8z9e2?_6kj3nN zvS;vFTzU-T!W|IdpG`k7?6I-s{W(@WrkkN8Lhye9Ct#Bc?9tHIzm|5ouOD4F8XJx` zzwNo{NesT(opO4jeA?lRzWmK?>C%e0A+{qr^s!p*jgxeE#15BrzDjhLc=^q`f;_#X!*8KyHlrVGIJH5icT5qT$j@coqIyZPs3j zMZlzufZb~t6*t4wED#*#zfE+C+ctX6qb{!*hr^-Om3Y)hKTv)!a zjO9y|Daa|4*R?^kX1$JqCWeUdQU~S(y@w6RQv>7yVnmFW2`zMMjtIw3HklkObyBKA zfm}mpIFBpW8Q6ppTkyZS6N%;u4Eb-4&jJ?ssLWiEyL6CKU4P56uD_qYipPhb1T{j) zhuhmQE~URhk~25YAOprIxUY|0lF=xicN8HNE%maWZ(aYA_pRYX@te;k#=ig3a$Udd z-TPWq4KLG&?^07#UN)coKF%KT`!c4X!%q&*nt}cM`)93kYeM^MX?s=&>JMD@PWVM( zwi&yWU`=D1jcM#zlLUGg3N~n5Cj7HZ7XdT15&N33;spHU+)O&COo8N^n(&Un|3fY7 zlgPUZQ|kux=>I~J;RBfhx&6&DNC=79k|8rwaCXfCM0$XmzfB{8oueQ$Eh(dGK1>G}ho z8Tk!^{3^$n9^_Xz-UWYJGd=S;b9O)8NY51mlfjs>Ihkjv!NQOwgWRd?ujo;@V}->i(XLX7IMFjEi4butKZ`*eWq zlo+M0M&Jpsvx!tOe~l;KrU3wnCc@_Gc)-DJ0f7*~wQhVy>D zy#>Uau!Ue06D)Tj$P)MboT)n1=5pMlv|L`l2xz%ju1ONe$Diu*v}rtUo-<`7j*AZT zcFlohCkuUZ#4K4E=c>8y40B=9tgs_nliHtfp?Z&&F3BDypEh==u2?)MGQ1}&_%E2l z#)XT-uB|z;ZtZinhz-s}AU)DA$e})Bt3hl9MibvxuP>Ibc^sE&YlLH{!NRB_*bb7a z5WS!PwOCm#8^a_Wu>*GsR65Uid&?ZCZo~rq-vs(|u~sdU{S}>}C8sUO5u)pJpKz^vq99za))=vi3v6uIq} zkRrcOcvLJ|j};#OE((o9I?jOjJ9%T_S)6@JmyZlRJ<{8hpFlEObWGt0*tT{oD-v-N z(!WEJBT5lvXG~KIPBf+o+6dq(FJg8mTOjON1}S6D?lqLRuSuWcVg14+Z-uDE8pMRq zJgm{bqGgH5oXi?+7`$B^e|gDyr(x^zhCzidDlFnXC9;G}D_O=g8VpWkOkr%tk)EIo zBdiG0zoxUolLOC>3=Rw)Jv1_W?Cc@7XOD>o(r@B6O_#AI5~-3hPFbD? z;{uG6{!e6iTx_kCmg&h4DM?X5<0aor#=^D)iZV;|5#1wcTDngK_{)qZ!p>V*CPTEO z$ilRe$}%;rrcB=dApzhOjNId7YI{38jqIKCgZEtP-T(>S*W2JAnz>U(WED>sgR z=%`DVSH(->jj`u$oLfBiossC_bV)hfqxi+Km>Ba!2h;A7*yh+k^h&(to3GyA0H>>4 zd}_nyMMumMH`2q07tbX^x7wE5cc!cB5|*3wC^5dgY4`n_mSpRWRL#z0+0J|J4T&vF z&F}Q5h34P0y<=NyUV1qt^ewt#M)VOaj4jNj>xH*pxcNdN1g@{Xd(nC1y;!ieiTs&c#FEK^uC} zfw*O{9`8E7+>DJ~b;rEoBipk+P7^tZHeZ@%c&~Qf-7o_K2KVJ zv9Hepnm1lpd?6l645Z527VNln=Dw*Y#duuuxj zjL&=`px+|BoYzisD>UIhe<6(U37Qvz>{$R80Y9W7_#%)l5+?O=rrpE2qWOcZUv8CE z6O{bt{Q+pzW`qk~+PNk|exg_mOoKo{LJ&HE>HWOIKG}GWs2RS=Da2M_>jkT&gY5I7 zmB*2Z!Z6TMIGx#NBd*$h7sv#(U2-3_Rh@B$#toBi+Kur4jF_riWO0qmW*x5Jt{zvgB^3K{;kn9ZZ3y5* znL~!&pm1^PGibX7^qK@CJjEqlkaOutxC zLS#T44OaoUM5wD>hX=R`y|;{=G@KLgG+_F0JmFPz#Bxv#61_E-BW#Bc2&1%19zIlv zwbKQpn?xKDxDa5Yx~#Z9O))_xy*absSDgV2VXtVj08R}#GhjLpKt!8Rh@6bFb3o`I z9~|FRFTECmj%4Qot4=o`o?w7uv46teFPI5K($6t&(l00>mPWcqNmdm?d<)}Xl-Z(a z%Vk}Jr$8#eaIKv#Yi0_qyg5rTnOxYc`}Z_azK<(RltOOPj@$O+#zC4WyRu5`C7LCR zZSiM+__gKw&9_3gH{Tw(b>$~5zyInzM`3JBOp5cd9r3Ym?}ZjS^lo)xOS-TmHva92 zgn<+jW6OmeOsxV}y09!3T9}9rFBb}w;e;d}8(i?mTbBzPD8sF0bSxJ(vDp}JPB}It zO|l}im8Fgr-Upi_DwPm|41mL!omqmD+Cd}L4~QN2X-R-x{5 z);XK59BBNSa`rjLoO8}KS2*X6*dr#kq1y_Hj0g}rAXCcnwkENohxV+!5EP&`;*b#C zFJjbLoa_-t#2Il#3M1}uO%i+$14RdFFhh!Sh8iQTP(#Er=ZqB0p)B6pI#>8h)?~@2 zGsy0lm@A4DiAEBNMYdgCGSc-PNP;4ENP|KdnABYoaehHP#eud+(HE5>sx()e)klb? zNHOihuHn@2ldo&J^W0oXq!a90Q#9n0jw2;%I|@xVJbuiZxp0yCPAxD@ z@!SjCwfQUDRpSdBVm8=jy~hzU2o#CpkjdnhJR$`|Jqr0pv8<~x%-B5;Xy&E`lB)(f zvlDq>vBRb)^pWhgB{K~Oeg-n!EmI_-O`rY^4tZnOBSSYVdflK*E}}xvC2$^xyryHW zVcQEc?5T}QHY!BZL*j6jvB1F)RcDM-!KsY>7*w`bS*(GK#XE)l5n{$ffsj09kbdx5 zvSgAKN(x35b6~-!ObavSQ8J`v3h=`$iI!2uoM4lMYAVdqN$ey;*)@Dc#!d~9Au~wr{{MD4Rhsf~G`6JQiV!rruU`zbiG3Y9X z-K#yZFXh}qw;>q8)Oax7mS|n7!d@P0VXX4Q0>~NfcP-cLAmL{$(E=!ly zL=U0vw=OQV-DFck=OFk^9#XVPLOo|Rs-<{C|(P!qb#|F-zrH2Qq|kwb5I=H94-8~tYZG;$8HEU4s%=72=Oh}^35k! z3}#n(x~TqcQR8w^<3~l!@K>P4=bLBK&a#i4)pwo3vQtQue&lRjsleS$+C%mc;QDE($?}-BfkSp@;f%9YwF{d6Vpo<0FAbt z07LCAB+h<-!rcG!hxJ_91}NoS|MrE;`g__|rbyp*`k5-OVG- zQB}}-O%qD7`s$ZAm53+~3?z(@lS7Mc>C%ixYah0L-It_J2IZwDMr~z{&Zp`e_^P`KJg40eKc)kmj>bt zWwsK2y-bK;p~lGHUiMpE=fe(}=-4=`fr^t@qc|UeGz%IHO}dI@g8@>}UsOwQlakENJ%#U3-O5%AgRA z?cEF&ADO4a<6S$YZ=g|G45DWvX{NJEauD;IoM!$Gq7 zWeVtA^qDj7n1naGhTW5kNiYLc3%o&P|Om`LPg zvBs1+E6-ROUWt-tX~u3qaV+KVbtw55wp-^nomkQDV?}FwtNBjBhIDb|jrzs<_?1*~ zJ9Cw|zI$Oei$*km5cEU$bgS>y%Rkz7yXn1G zlHGf0b6v$gEHxFok`--OcPe&8?H?DF#h!tt&-#F~n5&~EtcAAfd(INXRT2}M69e%J z%cbpioE^xDZG~@)A-3})XWdEx3a=ExrQ`a}g`Kg%H})h=Rlis*MU_8C;zv zoMva;5AZ5ZO}te2IMPEBUWA_~Pd&(P_(gDutX?G_Ny+;8(OzTGq>v1FUtA=1bj>km z&MtJKSx162!UXFbkx3koLblSR#qwwlJLEjl!bB|k>3UJKvZ^J>vk6m@5FbZ&a#20> zwCql$uLIJ^c1-Sqd=d`ahyXb{un1edzk`cYvQLB1diAR6d`FrZtbBe+aoOwA942(C zIaxUqoFWmxJ7K4}phdPKS3Ws39%&ENT_kdNW2g>;SLAIYdTgAjsfx2&kv4XoFsCc=J_DO&mE?#e;4lsG&c9#)YYGe%yq50Hvx+zY)ZHgL&7 zJJd$8@~aJ!{!t0fBP%e^oIc#O6Yb%Z6c3U$U8E-z#}L*PNK@n_azTz1tMysoWE3K} zj`n3qAFEz^4PzvEFh0z;LH2!NS|6qaVtj{HmX53=ES{p|lS!VL#mQhMA4ZPkz&|o| zlX|GaC#mWal=RYNA0!SQU zYvCP)GBG8dd8Z)d?zrpjS$6l_vfge>xd-l858!q*92{KC;$=s+jA*f;$VZ z?^xIo+w#Wlq^XiEBZtR`Zm5T*yPrqS4bG1U4|+D4W}C|5zR$m>JPDr#D`s$u;+JiM zMco!D0Q+1HJG+f$Uq1KAh-P2MoNS2L&+}B8tnb&_pf4!E95i^J2Vr?yvE(u_R5%a4 zCM0g+c4dxJ(|#&AM00IG4ae6nbI6qUa;SeDX3JG7^@Bcl`E zz@-t`00=#>vlpvSFeTJ&kG97E@6tzry^OV5PXgCb_ zVVbXaHMt_WTiD7d*C3|@C^zks8&Uh9+6dTV`YK5YLGhPSXHF%#^~*t~mDw+hBh+O6 z`twwwyemy|8G8QnaQ>nOK@fB=BwJ2q1jGxHMF#biXy2wkFg7s_CoI2GKI;KaiQWiAuE1IOjFxVO%qWH29MkbUXo7SOA6xsuDyWHZZI`Y(62T z_C$wA*S;1a3LrsP7U47rm@|{3!3i2x=(|{Ca-|f_G4x3`l4;X@&BQn;4=me3*`{kL zD;#6KvgKHsk;fPNAvI#3Da?h46a^iELXLP5M*cmHi~)hci`S8ZOkpmvX6ux8(6T`l zWn3~=4pU?EG7HHr#c#Ej!y1 z(;qpzK5p(vv`0_ITRwC)P$x%wKO|&i4eHHAmLFi)p;A;Pgx`eC!qt*DNM@ht1ibDR)HdSmFLB+<{M52o^;0HHI!e z4@U|yRC}{IS6I`eM_ekD;H^hC$yWHp73z9xi8$Xhd|wkV(PxT3?7FCl##sYNqsA#LC^1^ujHThm558UvXxj9uXVNAM7zpE&lQHt)jP1AK%MozL*>$$Wuzcd zsJ1;B5ti#cf^tJotY&^gARCxKLeNGP{eScG?5t?da)%j1s88CB(ivWo1N+F5P{t6rEbXCM_96+9-cg-P>aWbj z(DKbVss9#l6nYMYp|0=eYT6Q`scH{CgfM>~TKI{h?5(p)whspXsQlLMJN5hT7gi;! z52XqZC#{FUlUOT~1&tJ1vgGZmn^g%9LZ~g5cSh}Lc%mKu(9y;`-*%@;_9UHqKCY>I zyW?g@VppoB>o1($u>;>cm2`H`A3~D0+Tq7mQTu=^HMhWq=&pXJ8`i6|t5WlN>`S>? zqlWvm)$NTPG4D5b!$|kPR|x;j#$@B+RK<~G(UDKw0>Y==ur1o+P2Y2UTvWvh#~K%= zul0Xey)j)1cgDqSH})*lHYQpSj2-da$|_U5%WC6?mX6+j_Dp)5W&ziSa|Rb00aI zSK9FoD;=EEHGlLM54t#4Pl#BwpKh`pGMjQVgJeTxoZEh!umB$zgS7|wP-$F%e!qpR zW25JBF!R)#C;XT2St9wHHyx0rC=L(g#-OU|fs$t$f+)l(_W7$2zL!DY4T5M(ad!~6 z2vLoK5}Q?WEyUs?oY*LpwVuguU72{N8M|0&^6PGv-+d~ z!pH+iFllJ#3)9XZB5JO7Cua}0M7Yb6|3nztj4SZjPOh-*`d1dd5+6*I0w>@->nMqB zkMB%8n{sp}O`VKG4h^92<3Fl`uA!&-KE#r>BaL)-jiD0lP{(Gq30e6n7+6Gdm-G~dMem_b5$q93J3KNn>z2Xa zt!9*=r_8khoBqp$yJc<5Wo?OYs;o0=`fZymSE*x6J=5f>Aza9M`-_yn=ExU(2VS!V zo%vbQfWHZC{`bw8t0rt9-pyviBYrP-y&{tS7-AJsB>f50pD~e8yf#V%y&813QqHT% z8CCRZw0Y<2+{bmo+e0^p?$&Kd)ouCU^zD`(pG~^=C$0PCH`L&c!rJJGxB&ejX$)s; z4`>V>I;l|`#Xu2^amsET{WDo(CW{1tdP#d2p^ejqq1l4gul4tCetr&9pT;g@9J?UF zS%yW9LLM7bGFdSdZw@)gIPggVKYV@y=K+EeZFIG~I)Xwko<)pM7?Vzu7x~S?Yh$s- zYvW7D?so28?%aL*nN;UK_)e6RLjr$hp@8|UFwhLa-H?F4HW2;O=%vIWjhv-3ig3cp z%Zuoflt+n^8Yk1_>)dCEKxBPTXfSs@uo~c%yJ|I=OXX*%Pf&1#Sb6lV%V)(j4XZ_JEK9FgRId?RR%WHZg zHAYgc4P$BczK_ncZ#Xf-0HgIX?FV^POr$a$J49a*fv$yx#AbVc6pw47Jmhu+mTo*K z`!g_x3%A2#-JNjQhN_E^9nA-vQ7&+8Fmn1NxGmA=`_zrGEPkNM1G1i}+Dr0Q-HdNG zuK%RrF)f&2Nd92sy6-g-?PSyhP2BhXy zeS!U?XN3lTVuEqc_>!dZGI|pf0f503-%boLwH-N}sQfrn4$^H^Ge|?r8iSLH>UGD4 zzng^BX|NuC`XnNnq@KveG@-qxo!v&o+N-fjbZ;lKHngJ6OtDCR(;@G9*`a%V_eOo_ z532z&0VjYl2-!ya5`cEukb;^wPY87QLbhe#AC_GfHBQmiYY^6Y2iabJOL@5_B|BS0 z$(<`4+%aEK(I0Cl~T`*0c~q}n(0 zRZi3Jv9?WwOr+q2tOFG1aA0`w*fAX}@>(#_A>~^*nm(H_SC2NT&V5fi^Nna1IER2V-DSCQ}qTlDvepR+tEU$;y*CP*+IYD7vWYgpz z2<>bqLlb!PQXV}ajJiGDnrJ1!tR~Fmqvl8HOG$oy*q!0-d+dZ^e z(6X16@Itf#5WwX1#7U(oX~L=yqVuQL zrpxQGV@vVSxVls!`%O`y2KHI0O)`?WNB5(GAE}+WJUb+3de&5|lF9dE}>dpuDx~Wj;$#XzO{Y1eb;?=S?t;P>BP>Z zXH)L2N$XZ}Hfnjxc&DH)T~xYYhI>!cOqTZT_?BAh&D~2?x3FQdfBrDgnAzu>=RT-JY%?Whp-FkjE?{>VvoN zfd)N04HJh!F>%18L@YB55qJ}r2W$}o%pFd6(;#B!DqOZAM@@`d_n=n2FvWf%rl9Z*Uj1`6XG}K z7RkS~S%79CTXfi(z~&3BzmT}uegb*j?Ft9G#qoch|r*Wx=8M^o;u1=G*mWr?!CDDR68 zz+rg!y~=NUKP>M{xi`}na9r1SE$qT4ZI zYAM?Z*AudkB^%@MH6E5$8|;;@(DXldRk0^yhnTJ=dv=0qeY7)wKYP{_aLyhmqdc9} zQS&yEJ(#o=+peqI@z}Z@y1E_f>ek|%tf|`(vE_9lwu>_7z#>zrBn=kLSrZp{$lm?` z`8)CMjSQ|i@BT}^kuL|jnv1kHa?NTBjJN|2>43HUmxEh82LrXyfUVvEJUW6JUAlg_ z9y`h|-6*@(jk0FHUo*53pTP&!e31|-O!2FDu+KE#@B-(GC;+F$oSBL zQCr%B>mFd$urCfPy*rKK*}ws^3MUI8QZUem&UIP_#>SBT1%sn12*?zwtB*tnDA-6m zMnRe7QOx7I4l!#3RMG$-eIkLNNPSoJJ~G^uPdPA;GMQ1i?DxX1pw!IroSkis`GI?mqI6mH1I})1S~y6bwxTHWjc=W~<8HoJRv-5!$`Iso zk^iKu{;jb@Cy2##2ovb{bJH=anEDt`VD z!KRBa1V#+l{pz~6>u%P)xdGq&IS@0%4nPxwEiLuE*w0eh_iy_4{b<8a45_|B@Q{dB zxMHSf{^LUgz^_6#jl~;3scQUTbHe+Bwxt&4R9-a@8%URx-#EH>6#M2%Z$6iJ`R0pv z%RJb8S-kO4h=Q)Y-Ezdv{i)zMYBT)L_T5J<#y_)dJ;s~=7mi1ovpr<>U-;!M<{V>!@&_*z?yEt?#U+DB(P7b9jEQx88eg1W=*uMa(08dvy#44vtHea zFB7lPtdm+ss_fKg?E80AGO_ao%zZ<%P zEy4zqZGeX{>c+;!jq&GFC9QW$I+sg2QzhNeK^Am&{&;lD{PCYZEZ~ajXj{?%f4^iy z{IVP%YPq2=-LMZ~E`|L(VbB2E+l?pUiZ z&1b^ZkDeCXr2|x}76I^}{9}P#=(-fw1`&Z?zC9zy?K$_lhh|+J zFMg$`9~<)eJGwV|uu;-BLN(#rKIbzA3A-zawZbNYjOE}{rw2})$e0UB8!plyt3d#OfeI-QeKZN zMBaEM9!|QOlUAUDJ60Oql{Cqpxy_l<8a|}a?%x4?X#0}*AV-CgBeAeJGl&X>xY0!9 z<+##07-61$f`OXmc}KfE-(Tz}W6(J^?NyYZ`$|4xhI|hw;qK`nq-frEZli~gLf)iy z4~@ZLNB*=np@;N34L1c3mwuBHHu_PV>Cpk7#6>G3)Hp3$Twa`@BGR{T_umpukbA>A zoJcnGe$e!Xo*#KqWxMksMNPxooi{t*>|St3ZLzKQfCC2+x$a0j^z9)E2w%3?PhTXh zinl~xOW^Z1eg zV!_bJe1#A~28OIGMZAa}1|AtCGJq8LDEeONKLJ86feqx}L#qUX-@q@NVZM#7v6s(S zw%zA zlK+7L;XnJzQvD?;2{W7kM}es{1Qd@~!O@K}lrTv<7{)PTp(hh;1aKRS!E7PXg#L~f zd%JdlK(sKLK=b?aX@XH&q>*Ao;yPfKJ4`aJGAi-AC}se7LnMgip#wc1l>A}!kE&B; zPh{!9!IZO6UL4A6ZoIVk(s#asfRLuB2s(gBGV!ulf6`e`lfEq4pEQ-Pw}ffK8Dql< zt{uHtdt|wGHt7LQBEBJn&m&Gf!hneYenn8}(Hvn%Hnbi>{c{oJyBcy?aP`zKJHXXj zDVX-0=>@8bmD3bU#@W9drnM70tl_&&S{{N_CmdNQ0v}2IaCye2%rTZPTT1p?-Ryk3eb6m7@yE>LY<5`KpAW7CK)?JVs=aV zukfUskmxeX!y(qr;RDv|NggDBcPT<5AkINitTIY54nA{n_Ci=bY}NFI3)6RwMQKkO zVkn%6l1+q=NG{0a4M^_1tvX#+wQvYEDP>m7$a@qbxAk`%Te%M$17_n-O+3;ZW<;Z- z`%&;q(oq(ER9R>k8%Q#QGKpnM$~0eC^@+dV$1c=)EU)h%rE+dJ3y3^~kD_E(Y+9ii zF=c9;HEXpDU-qz+K*0urud<*(W>-HCPEPSeNy~{>(_lh@m`so1%6{E|@GwLOd(SIi zXvXO}AW&X63-;gT=`0jdG-?P9x3&dH-Cc8`}Q#HaUZ+bEMIJX zqPx4Bt*~}g!=~_mox+Rl`lpEU)3y2_uJPzOIeu$4d;vZh!uD=JE+kPPW5%ag#ZZpd zu}tHsF6kcwdR+v)Hk8HmL=>>1>&F(3#m3$k!nbD}MX_Q89XS*4i*Js-kko~M#BAyBXaK%D*P$&mCtq{N4@m-+-fllPNQ&j41Yu~f(s zdk#7fL>#C0Ru~WvrFGMW7?5qT)}&)o(zJ=VF#2UH7GPV2nok4NB%FHq)5y_6Nq8)W z4`;82q)V)CESTuf5U49XiW8pY;D@Ao&r4De*)k_TiHwY6EC~NU=7|_WWStRs1Hy=1 zh+A^-zW)uyGeyt@jp#q{o3VpU3ym<^pv3&sBJT0@>_ckhZzy?!k^!ovWWebboSWG1Bg_c!4Z7nAAxy+e8UDrN}bYR=T7$h3%@Aw|5ue zGGoJriCJF&+Yh9ZRE9YziF8JO2^oj7_%nC;Cn?)Wm5@1(c^NQmI#a+gZH@E|DnbIf zbd3@c%OsK?822pQq%&gC*b>Xu0Y(eilxFz7#{2t`b@yaYoSyLQm2TrP{BfZNfC$vL zJpY-YmN%{TbG-d8IsBD8Fqs`k`T2qe6~^M;dDjEG$z3<^c;Ga+Tk%uKmmTJlT+ssy zU$}GL{=i}=Zk>0n*p0;%^UeoOuAwiJ&e^Ry~{>suTsj|IE4zU;O)28BN z>2^d6EZd3xe4=FRp^SaVSh1MvO(=PXqwq_OPvV7(?{ekKT=^ZYe#K;JqnmfRnq{u$ z4%f8eG}j@c=nhwf(#=Bzp1WM-GFN$rYgjQEn(1aX!)j=hGt~O5rY1R~VwtPB!);h` z8e8Rz>SeC_4%fJ1H)#zxx#lf*n|3WX?Yi4Eu-r7DuR7hd`LVZz-n+uKWntT0Vb8L# zhdrgXZ!oUup`AlTjXRbbcie5+ou(7xu{NyV!?$xXX;R zE8q878OK*)bke5URZ}6~^gt-!H>{Rec+Z2PBK{!%pvB0$SMB|L{e!E#jUVD4R21_K zt8E8(zW>2-Lm|I?Mc^FG^95;FF_73)$5$gRg0mvJ`Fj6CKjL=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* +Requires-Dist: six (>=1.9) +Requires-Dist: webencodings +Provides-Extra: all +Requires-Dist: genshi ; extra == 'all' +Requires-Dist: chardet (>=2.2) ; extra == 'all' +Requires-Dist: lxml ; (platform_python_implementation == 'CPython') and extra == 'all' +Provides-Extra: chardet +Requires-Dist: chardet (>=2.2) ; extra == 'chardet' +Provides-Extra: genshi +Requires-Dist: genshi ; extra == 'genshi' +Provides-Extra: lxml +Requires-Dist: lxml ; (platform_python_implementation == 'CPython') and extra == 'lxml' + +html5lib +======== + +.. image:: https://travis-ci.org/html5lib/html5lib-python.svg?branch=master + :target: https://travis-ci.org/html5lib/html5lib-python + + +html5lib is a pure-python library for parsing HTML. It is designed to +conform to the WHATWG HTML specification, as is implemented by all major +web browsers. + + +Usage +----- + +Simple usage follows this pattern: + +.. code-block:: python + + import html5lib + with open("mydocument.html", "rb") as f: + document = html5lib.parse(f) + +or: + +.. code-block:: python + + import html5lib + document = html5lib.parse("

Hello World!") + +By default, the ``document`` will be an ``xml.etree`` element instance. +Whenever possible, html5lib chooses the accelerated ``ElementTree`` +implementation (i.e. ``xml.etree.cElementTree`` on Python 2.x). + +Two other tree types are supported: ``xml.dom.minidom`` and +``lxml.etree``. To use an alternative format, specify the name of +a treebuilder: + +.. code-block:: python + + import html5lib + with open("mydocument.html", "rb") as f: + lxml_etree_document = html5lib.parse(f, treebuilder="lxml") + +When using with ``urllib2`` (Python 2), the charset from HTTP should be +pass into html5lib as follows: + +.. code-block:: python + + from contextlib import closing + from urllib2 import urlopen + import html5lib + + with closing(urlopen("http://example.com/")) as f: + document = html5lib.parse(f, transport_encoding=f.info().getparam("charset")) + +When using with ``urllib.request`` (Python 3), the charset from HTTP +should be pass into html5lib as follows: + +.. code-block:: python + + from urllib.request import urlopen + import html5lib + + with urlopen("http://example.com/") as f: + document = html5lib.parse(f, transport_encoding=f.info().get_content_charset()) + +To have more control over the parser, create a parser object explicitly. +For instance, to make the parser raise exceptions on parse errors, use: + +.. code-block:: python + + import html5lib + with open("mydocument.html", "rb") as f: + parser = html5lib.HTMLParser(strict=True) + document = parser.parse(f) + +When you're instantiating parser objects explicitly, pass a treebuilder +class as the ``tree`` keyword argument to use an alternative document +format: + +.. code-block:: python + + import html5lib + parser = html5lib.HTMLParser(tree=html5lib.getTreeBuilder("dom")) + minidom_document = parser.parse("

Hello World!") + +More documentation is available at https://html5lib.readthedocs.io/. + + +Installation +------------ + +html5lib works on CPython 2.7+, CPython 3.5+ and PyPy. To install: + +.. code-block:: bash + + $ pip install html5lib + +The goal is to support a (non-strict) superset of the versions that `pip +supports +`_. + +Optional Dependencies +--------------------- + +The following third-party libraries may be used for additional +functionality: + +- ``lxml`` is supported as a tree format (for both building and + walking) under CPython (but *not* PyPy where it is known to cause + segfaults); + +- ``genshi`` has a treewalker (but not builder); and + +- ``chardet`` can be used as a fallback when character encoding cannot + be determined. + + +Bugs +---- + +Please report any bugs on the `issue tracker +`_. + + +Tests +----- + +Unit tests require the ``pytest`` and ``mock`` libraries and can be +run using the ``py.test`` command in the root directory. + +Test data are contained in a separate `html5lib-tests +`_ repository and included +as a submodule, thus for git checkouts they must be initialized:: + + $ git submodule init + $ git submodule update + +If you have all compatible Python implementations available on your +system, you can run tests on all of them using the ``tox`` utility, +which can be found on PyPI. + + +Questions? +---------- + +There's a mailing list available for support on Google Groups, +`html5lib-discuss `_, +though you may get a quicker response asking on IRC in `#whatwg on +irc.freenode.net `_. + +Change Log +---------- + +1.1 +~~~ + +UNRELEASED + +Breaking changes: + +* Drop support for Python 3.3. (#358) +* Drop support for Python 3.4. (#421) + +Deprecations: + +* Deprecate the ``html5lib`` sanitizer (``html5lib.serialize(sanitize=True)`` and + ``html5lib.filters.sanitizer``). We recommend users migrate to `Bleach + `. Please let us know if Bleach doesn't suffice for your + use. (#443) + +Other changes: + +* Try to import from ``collections.abc`` to remove DeprecationWarning and ensure + ``html5lib`` keeps working in future Python versions. (#403) +* Drop optional ``datrie`` dependency. (#442) + + +1.0.1 +~~~~~ + +Released on December 7, 2017 + +Breaking changes: + +* Drop support for Python 2.6. (#330) (Thank you, Hugo, Will Kahn-Greene!) +* Remove ``utils/spider.py`` (#353) (Thank you, Jon Dufresne!) + +Features: + +* Improve documentation. (#300, #307) (Thank you, Jon Dufresne, Tom Most, + Will Kahn-Greene!) +* Add iframe seamless boolean attribute. (Thank you, Ritwik Gupta!) +* Add itemscope as a boolean attribute. (#194) (Thank you, Jonathan Vanasco!) +* Support Python 3.6. (#333) (Thank you, Jon Dufresne!) +* Add CI support for Windows using AppVeyor. (Thank you, John Vandenberg!) +* Improve testing and CI and add code coverage (#323, #334), (Thank you, Jon + Dufresne, John Vandenberg, Sam Sneddon, Will Kahn-Greene!) +* Semver-compliant version number. + +Bug fixes: + +* Add support for setuptools < 18.5 to support environment markers. (Thank you, + John Vandenberg!) +* Add explicit dependency for six >= 1.9. (Thank you, Eric Amorde!) +* Fix regexes to work with Python 3.7 regex adjustments. (#318, #379) (Thank + you, Benedikt Morbach, Ville Skyttä, Mark Vasilkov!) +* Fix alphabeticalattributes filter namespace bug. (#324) (Thank you, Will + Kahn-Greene!) +* Include license file in generated wheel package. (#350) (Thank you, Jon + Dufresne!) +* Fix annotation-xml typo. (#339) (Thank you, Will Kahn-Greene!) +* Allow uppercase hex chararcters in CSS colour check. (#377) (Thank you, + Komal Dembla, Hugo!) + + +1.0 +~~~ + +Released and unreleased on December 7, 2017. Badly packaged release. + + +0.999999999/1.0b10 +~~~~~~~~~~~~~~~~~~ + +Released on July 15, 2016 + +* Fix attribute order going to the tree builder to be document order + instead of reverse document order(!). + + +0.99999999/1.0b9 +~~~~~~~~~~~~~~~~ + +Released on July 14, 2016 + +* **Added ordereddict as a mandatory dependency on Python 2.6.** + +* Added ``lxml``, ``genshi``, ``datrie``, ``charade``, and ``all`` + extras that will do the right thing based on the specific + interpreter implementation. + +* Now requires the ``mock`` package for the testsuite. + +* Cease supporting DATrie under PyPy. + +* **Remove PullDOM support, as this hasn't ever been properly + tested, doesn't entirely work, and as far as I can tell is + completely unused by anyone.** + +* Move testsuite to ``py.test``. + +* **Fix #124: move to webencodings for decoding the input byte stream; + this makes html5lib compliant with the Encoding Standard, and + introduces a required dependency on webencodings.** + +* **Cease supporting Python 3.2 (in both CPython and PyPy forms).** + +* **Fix comments containing double-dash with lxml 3.5 and above.** + +* **Use scripting disabled by default (as we don't implement + scripting).** + +* **Fix #11, avoiding the XSS bug potentially caused by serializer + allowing attribute values to be escaped out of in old browser versions, + changing the quote_attr_values option on serializer to take one of + three values, "always" (the old True value), "legacy" (the new option, + and the new default), and "spec" (the old False value, and the old + default).** + +* **Fix #72 by rewriting the sanitizer to apply only to treewalkers + (instead of the tokenizer); as such, this will require amending all + callers of it to use it via the treewalker API.** + +* **Drop support of charade, now that chardet is supported once more.** + +* **Replace the charset keyword argument on parse and related methods + with a set of keyword arguments: override_encoding, transport_encoding, + same_origin_parent_encoding, likely_encoding, and default_encoding.** + +* **Move filters._base, treebuilder._base, and treewalkers._base to .base + to clarify their status as public.** + +* **Get rid of the sanitizer package. Merge sanitizer.sanitize into the + sanitizer.htmlsanitizer module and move that to sanitizer. This means + anyone who used sanitizer.sanitize or sanitizer.HTMLSanitizer needs no + code changes.** + +* **Rename treewalkers.lxmletree to .etree_lxml and + treewalkers.genshistream to .genshi to have a consistent API.** + +* Move a whole load of stuff (inputstream, ihatexml, trie, tokenizer, + utils) to be underscore prefixed to clarify their status as private. + + +0.9999999/1.0b8 +~~~~~~~~~~~~~~~ + +Released on September 10, 2015 + +* Fix #195: fix the sanitizer to drop broken URLs (it threw an + exception between 0.9999 and 0.999999). + + +0.999999/1.0b7 +~~~~~~~~~~~~~~ + +Released on July 7, 2015 + +* Fix #189: fix the sanitizer to allow relative URLs again (as it did + prior to 0.9999/1.0b5). + + +0.99999/1.0b6 +~~~~~~~~~~~~~ + +Released on April 30, 2015 + +* Fix #188: fix the sanitizer to not throw an exception when sanitizing + bogus data URLs. + + +0.9999/1.0b5 +~~~~~~~~~~~~ + +Released on April 29, 2015 + +* Fix #153: Sanitizer fails to treat some attributes as URLs. Despite how + this sounds, this has no known security implications. No known version + of IE (5.5 to current), Firefox (3 to current), Safari (6 to current), + Chrome (1 to current), or Opera (12 to current) will run any script + provided in these attributes. + +* Pass error message to the ParseError exception in strict parsing mode. + +* Allow data URIs in the sanitizer, with a whitelist of content-types. + +* Add support for Python implementations that don't support lone + surrogates (read: Jython). Fixes #2. + +* Remove localization of error messages. This functionality was totally + unused (and untested that everything was localizable), so we may as + well follow numerous browsers in not supporting translating technical + strings. + +* Expose treewalkers.pprint as a public API. + +* Add a documentEncoding property to HTML5Parser, fix #121. + + +0.999 +~~~~~ + +Released on December 23, 2013 + +* Fix #127: add work-around for CPython issue #20007: .read(0) on + http.client.HTTPResponse drops the rest of the content. + +* Fix #115: lxml treewalker can now deal with fragments containing, at + their root level, text nodes with non-ASCII characters on Python 2. + + +0.99 +~~~~ + +Released on September 10, 2013 + +* No library changes from 1.0b3; released as 0.99 as pip has changed + behaviour from 1.4 to avoid installing pre-release versions per + PEP 440. + + +1.0b3 +~~~~~ + +Released on July 24, 2013 + +* Removed ``RecursiveTreeWalker`` from ``treewalkers._base``. Any + implementation using it should be moved to + ``NonRecursiveTreeWalker``, as everything bundled with html5lib has + for years. + +* Fix #67 so that ``BufferedStream`` to correctly returns a bytes + object, thereby fixing any case where html5lib is passed a + non-seekable RawIOBase-like object. + + +1.0b2 +~~~~~ + +Released on June 27, 2013 + +* Removed reordering of attributes within the serializer. There is now + an ``alphabetical_attributes`` option which preserves the previous + behaviour through a new filter. This allows attribute order to be + preserved through html5lib if the tree builder preserves order. + +* Removed ``dom2sax`` from DOM treebuilders. It has been replaced by + ``treeadapters.sax.to_sax`` which is generic and supports any + treewalker; it also resolves all known bugs with ``dom2sax``. + +* Fix treewalker assertions on hitting bytes strings on + Python 2. Previous to 1.0b1, treewalkers coped with mixed + bytes/unicode data on Python 2; this reintroduces this prior + behaviour on Python 2. Behaviour is unchanged on Python 3. + + +1.0b1 +~~~~~ + +Released on May 17, 2013 + +* Implementation updated to implement the `HTML specification + `_ as of 5th May + 2013 (`SVN `_ revision r7867). + +* Python 3.2+ supported in a single codebase using the ``six`` library. + +* Removed support for Python 2.5 and older. + +* Removed the deprecated Beautiful Soup 3 treebuilder. + ``beautifulsoup4`` can use ``html5lib`` as a parser instead. Note that + since it doesn't support namespaces, foreign content like SVG and + MathML is parsed incorrectly. + +* Removed ``simpletree`` from the package. The default tree builder is + now ``etree`` (using the ``xml.etree.cElementTree`` implementation if + available, and ``xml.etree.ElementTree`` otherwise). + +* Removed the ``XHTMLSerializer`` as it never actually guaranteed its + output was well-formed XML, and hence provided little of use. + +* Removed default DOM treebuilder, so ``html5lib.treebuilders.dom`` is no + longer supported. ``html5lib.treebuilders.getTreeBuilder("dom")`` will + return the default DOM treebuilder, which uses ``xml.dom.minidom``. + +* Optional heuristic character encoding detection now based on + ``charade`` for Python 2.6 - 3.3 compatibility. + +* Optional ``Genshi`` treewalker support fixed. + +* Many bugfixes, including: + + * #33: null in attribute value breaks XML AttValue; + + * #4: nested, indirect descendant,