aird 0.4.23.dev11__tar.gz → 0.4.23.dev12__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aird-0.4.23.dev11/aird.egg-info → aird-0.4.23.dev12}/PKG-INFO +1 -1
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/cli/session.py +10 -71
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/config.py +30 -4
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/constants/__init__.py +3 -5
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/core/events.py +2 -0
- aird-0.4.23.dev12/aird/core/folder_size.py +51 -0
- aird-0.4.23.dev12/aird/core/zip_download.py +116 -0
- aird-0.4.23.dev12/aird/email/__init__.py +6 -0
- aird-0.4.23.dev12/aird/email/brevo.py +83 -0
- aird-0.4.23.dev12/aird/email/resolve.py +36 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/handlers/api_handlers.py +11 -5
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/handlers/file_op_handlers.py +140 -466
- aird-0.4.23.dev12/aird/handlers/folder_size_ws_handlers.py +193 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/handlers/share_handlers.py +6 -2
- aird-0.4.23.dev12/aird/handlers/transfer_ws_handlers.py +364 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/handlers/view_handlers.py +0 -2
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/main.py +14 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/services/__init__.py +4 -0
- aird-0.4.23.dev12/aird/services/email_service.py +97 -0
- aird-0.4.23.dev12/aird/services/email_subscriber.py +52 -0
- aird-0.4.23.dev12/aird/static/css/app.css +2 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/browse/app.js +458 -252
- aird-0.4.23.dev12/aird/static/js/download-manager.js +309 -0
- aird-0.4.23.dev12/aird/static/js/feature-flags-live.js +44 -0
- aird-0.4.23.dev12/aird/static/js/file-transfer-ws.js +223 -0
- aird-0.4.23.dev12/aird/static/js/folder-size-scan.js +115 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/share/app.js +46 -12
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/browse.html +33 -54
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/share.html +52 -37
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/shared_list.html +23 -6
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/tagged_files.html +2 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12/aird.egg-info}/PKG-INFO +1 -1
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird.egg-info/SOURCES.txt +17 -1
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/setup.py +1 -1
- aird-0.4.23.dev12/tests/test_email_service.py +76 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_file_op_handlers.py +7 -183
- aird-0.4.23.dev12/tests/test_folder_size.py +39 -0
- aird-0.4.23.dev12/tests/test_transfer_ws_handlers.py +101 -0
- aird-0.4.23.dev12/tests/test_zip_download.py +43 -0
- aird-0.4.23.dev11/aird/static/css/app.css +0 -2
- aird-0.4.23.dev11/aird/static/js/feature-flags-live.js +0 -43
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/LICENSE +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/MANIFEST.in +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/README.md +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/__main__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/app_context.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/cli/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/cli/__main__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/cli/authelia.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/cli/config.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/cli/main.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/cloud/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/constants/admin.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/constants/file_ops.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/constants/input_limits.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/constants/media.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/core/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/core/file_operations.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/core/filter_expression.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/core/input_validation.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/core/mmap_handler.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/core/security.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/core/share_root.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/core/websocket_manager.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/database/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/database/db.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/database/feature_flags.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/database/ldap.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/db/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/db/audit.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/db/config.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/db/favorites.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/db/network_shares.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/db/policies.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/db/policy_decisions.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/db/policy_seeds.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/db/quota.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/db/resource_tags.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/db/schema.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/db/shares.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/db/user_attributes.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/db/users.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/domain/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/domain/contracts.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/domain/models.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/handlers/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/handlers/abac_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/handlers/admin_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/handlers/auth_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/handlers/base_handler.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/handlers/constants.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/handlers/health_handler.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/handlers/p2p_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/network_share_manager.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/server_runtime.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/services/audit_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/services/config_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/services/event_subscribers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/services/favorites_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/services/network_share_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/services/p2p_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/services/policy_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/services/quota_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/services/share_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/services/tag_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/services/user_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/sql_identifiers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/favicon.png +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/favicon.svg +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/img/logo-icon.png +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/img/logo-mark.svg +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/img/logo-text.png +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/img/logo.png +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/aird-core.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/bg-canvas.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/common/command-palette.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/components/folder-picker.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/login-ui.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/media-view.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/p2p/app.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/p2p/mediator.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/p2p/qr-adapter.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/p2p/signaling-service.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/p2p/state-machine.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/p2p/transfer-service.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/pages/p2p-page.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/pages/super-search.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/theme.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/vendor/pdf.min.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/vendor/pdf.worker.min.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/static/js/vendor/qrcode-browser.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/_admin_tabs.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/_app_nav_header.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/_bg_canvas.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/_theme_early.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/_theme_login_corner.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/admin.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/admin_audit.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/admin_ldap.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/admin_login.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/admin_network_shares.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/admin_policies.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/admin_tags.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/admin_user_attributes.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/admin_users.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/directory.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/edit.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/error.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/file.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/ldap_config_create.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/ldap_config_edit.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/login.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/media_view.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/p2p_transfer.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/profile.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/super_search.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/token_verification.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/user_create.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/templates/user_edit.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/utils/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird/utils/util.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird.egg-info/dependency_links.txt +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird.egg-info/entry_points.txt +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird.egg-info/requires.txt +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/aird.egg-info/top_level.txt +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/setup.cfg +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/conftest.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/handler_helpers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_admin_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_api_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_architecture_conformance.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_auth_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_auth_handlers_extended.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_base_handler.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_base_handler_pep.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_cli.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_cloud.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_config.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_core_file_operations.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_database_db.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_database_feature_flags.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_database_ldap.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_database_shares.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_database_users.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_database_users_hashing.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_db.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_filter_expression.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_main.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_mmap_handler.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_multi_user.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_network_shares.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_p2p_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_password_hashing.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_policy_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_rate_limit.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_security.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_security_comprehensive.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_server_runtime.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_share_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_share_ownership.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_super_search_handler.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_tag_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_util.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_view_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_websocket_manager.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev12}/tests/test_wheel_static_assets.py +0 -0
|
@@ -6,7 +6,6 @@ import json
|
|
|
6
6
|
import logging
|
|
7
7
|
import os
|
|
8
8
|
import re
|
|
9
|
-
import uuid
|
|
10
9
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
10
|
from pathlib import Path
|
|
12
11
|
from typing import Any, Callable, Iterator
|
|
@@ -15,12 +14,10 @@ from urllib.parse import quote, urljoin
|
|
|
15
14
|
import requests
|
|
16
15
|
|
|
17
16
|
from aird.cli.config import ensure_config_dir, get_authelia_url, get_server_url, session_path
|
|
18
|
-
from aird.constants import UPLOAD_CHUNK_SIZE_BYTES
|
|
19
17
|
|
|
20
18
|
logger = logging.getLogger(__name__)
|
|
21
19
|
|
|
22
20
|
XSRF_COOKIE = "_xsrf"
|
|
23
|
-
CHUNK_SIZE = UPLOAD_CHUNK_SIZE_BYTES
|
|
24
21
|
|
|
25
22
|
|
|
26
23
|
def _remote_url(base_path: str, remote_path: str) -> str:
|
|
@@ -280,86 +277,28 @@ class AirdClient:
|
|
|
280
277
|
on_progress(path)
|
|
281
278
|
return count
|
|
282
279
|
|
|
283
|
-
def
|
|
284
|
-
self
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
offset: int,
|
|
290
|
-
total_size: int,
|
|
291
|
-
chunk_last: bool,
|
|
292
|
-
) -> None:
|
|
293
|
-
params: dict[str, str | int] = {
|
|
294
|
-
"upload_id": upload_id,
|
|
295
|
-
"upload_dir": remote_dir,
|
|
296
|
-
"upload_filename": filename,
|
|
297
|
-
"chunk_offset": offset,
|
|
298
|
-
"total_size": total_size,
|
|
299
|
-
}
|
|
300
|
-
if chunk_last:
|
|
301
|
-
params["chunk_last"] = 1
|
|
280
|
+
def upload_file(self, local_path: Path, remote_dir: str = "") -> None:
|
|
281
|
+
self.ensure_auth()
|
|
282
|
+
if not local_path.is_file():
|
|
283
|
+
raise FileNotFoundError(local_path)
|
|
284
|
+
remote_dir = remote_dir.strip("/")
|
|
285
|
+
filename = local_path.name
|
|
302
286
|
r = self.http.post(
|
|
303
287
|
self._url("/upload"),
|
|
304
|
-
params=
|
|
305
|
-
data=
|
|
288
|
+
params={"upload_dir": remote_dir, "upload_filename": filename},
|
|
289
|
+
data=local_path.read_bytes(),
|
|
306
290
|
headers={
|
|
307
291
|
**self._xsrf_header(),
|
|
308
292
|
"Content-Type": "application/octet-stream",
|
|
309
293
|
},
|
|
310
|
-
timeout=
|
|
294
|
+
timeout=600,
|
|
311
295
|
)
|
|
312
296
|
if r.status_code >= 400:
|
|
313
297
|
raise AirdAPIError(
|
|
314
|
-
f"Upload
|
|
298
|
+
f"Upload failed for {filename} (HTTP {r.status_code})",
|
|
315
299
|
r.status_code,
|
|
316
300
|
)
|
|
317
301
|
|
|
318
|
-
def upload_file(self, local_path: Path, remote_dir: str = "") -> None:
|
|
319
|
-
self.ensure_auth()
|
|
320
|
-
if not local_path.is_file():
|
|
321
|
-
raise FileNotFoundError(local_path)
|
|
322
|
-
remote_dir = remote_dir.strip("/")
|
|
323
|
-
filename = local_path.name
|
|
324
|
-
total = local_path.stat().st_size
|
|
325
|
-
upload_id = uuid.uuid4().hex
|
|
326
|
-
|
|
327
|
-
if total <= CHUNK_SIZE:
|
|
328
|
-
r = self.http.post(
|
|
329
|
-
self._url("/upload"),
|
|
330
|
-
params={"upload_dir": remote_dir, "upload_filename": filename},
|
|
331
|
-
data=local_path.read_bytes(),
|
|
332
|
-
headers={
|
|
333
|
-
**self._xsrf_header(),
|
|
334
|
-
"Content-Type": "application/octet-stream",
|
|
335
|
-
},
|
|
336
|
-
timeout=300,
|
|
337
|
-
)
|
|
338
|
-
if r.status_code >= 400:
|
|
339
|
-
raise AirdAPIError(
|
|
340
|
-
f"Upload failed for {filename} (HTTP {r.status_code})",
|
|
341
|
-
r.status_code,
|
|
342
|
-
)
|
|
343
|
-
return
|
|
344
|
-
|
|
345
|
-
offset = 0
|
|
346
|
-
with local_path.open("rb") as f:
|
|
347
|
-
while offset < total:
|
|
348
|
-
chunk = f.read(CHUNK_SIZE)
|
|
349
|
-
if not chunk:
|
|
350
|
-
break
|
|
351
|
-
chunk_last = offset + len(chunk) >= total
|
|
352
|
-
self._upload_chunk(
|
|
353
|
-
upload_id,
|
|
354
|
-
remote_dir,
|
|
355
|
-
filename,
|
|
356
|
-
chunk,
|
|
357
|
-
offset,
|
|
358
|
-
total,
|
|
359
|
-
chunk_last=chunk_last,
|
|
360
|
-
)
|
|
361
|
-
offset += len(chunk)
|
|
362
|
-
|
|
363
302
|
def upload_tree(
|
|
364
303
|
self,
|
|
365
304
|
local_dir: Path,
|
|
@@ -16,9 +16,7 @@ from aird.constants import (
|
|
|
16
16
|
ALLOWED_UPLOAD_EXTENSIONS as _ALLOWED_UPLOAD_EXTENSIONS,
|
|
17
17
|
MMAP_MIN_SIZE as _MMAP_MIN_SIZE,
|
|
18
18
|
CHUNK_SIZE as _CHUNK_SIZE,
|
|
19
|
-
UPLOAD_CHUNK_SIZE_BYTES as _UPLOAD_CHUNK_SIZE_BYTES,
|
|
20
19
|
UPLOAD_REQUEST_MAX_BODY_SIZE as _UPLOAD_REQUEST_MAX_BODY_SIZE,
|
|
21
|
-
UPLOAD_MAX_PARALLEL_CHUNKS as _UPLOAD_MAX_PARALLEL_CHUNKS,
|
|
22
20
|
)
|
|
23
21
|
|
|
24
22
|
# Module-level variables to hold configuration
|
|
@@ -49,9 +47,35 @@ MAX_READABLE_FILE_SIZE = _MAX_READABLE_FILE_SIZE
|
|
|
49
47
|
ALLOWED_UPLOAD_EXTENSIONS = _ALLOWED_UPLOAD_EXTENSIONS
|
|
50
48
|
MMAP_MIN_SIZE = _MMAP_MIN_SIZE
|
|
51
49
|
CHUNK_SIZE = _CHUNK_SIZE
|
|
52
|
-
UPLOAD_CHUNK_SIZE_BYTES = _UPLOAD_CHUNK_SIZE_BYTES
|
|
53
50
|
UPLOAD_REQUEST_MAX_BODY_SIZE = _UPLOAD_REQUEST_MAX_BODY_SIZE
|
|
54
|
-
|
|
51
|
+
BREVO_API_KEY = None
|
|
52
|
+
BREVO_SENDER_EMAIL = None
|
|
53
|
+
BREVO_SENDER_NAME = "Aird"
|
|
54
|
+
PUBLIC_BASE_URL = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _apply_brevo_settings(config: dict) -> None:
|
|
58
|
+
global BREVO_API_KEY, BREVO_SENDER_EMAIL, BREVO_SENDER_NAME, PUBLIC_BASE_URL
|
|
59
|
+
brevo = config.get("brevo") if isinstance(config, dict) else {}
|
|
60
|
+
if not isinstance(brevo, dict):
|
|
61
|
+
brevo = {}
|
|
62
|
+
BREVO_API_KEY = (
|
|
63
|
+
os.environ.get("AIRD_BREVO_API_KEY", "").strip() or brevo.get("api_key")
|
|
64
|
+
)
|
|
65
|
+
BREVO_SENDER_EMAIL = (
|
|
66
|
+
os.environ.get("AIRD_BREVO_SENDER_EMAIL", "").strip()
|
|
67
|
+
or brevo.get("sender_email")
|
|
68
|
+
)
|
|
69
|
+
BREVO_SENDER_NAME = (
|
|
70
|
+
os.environ.get("AIRD_BREVO_SENDER_NAME", "").strip()
|
|
71
|
+
or brevo.get("sender_name")
|
|
72
|
+
or "Aird"
|
|
73
|
+
)
|
|
74
|
+
PUBLIC_BASE_URL = (
|
|
75
|
+
os.environ.get("AIRD_PUBLIC_BASE_URL", "").strip()
|
|
76
|
+
or brevo.get("public_base_url")
|
|
77
|
+
or None
|
|
78
|
+
)
|
|
55
79
|
|
|
56
80
|
|
|
57
81
|
def _configure_google_drive(cloud_config: dict) -> None:
|
|
@@ -172,6 +196,7 @@ def init_config():
|
|
|
172
196
|
global LDAP_BASE_DN, LDAP_USER_TEMPLATE, LDAP_FILTER_TEMPLATE, LDAP_ATTRIBUTES
|
|
173
197
|
global LDAP_ATTRIBUTE_MAP, HOSTNAME, SSL_CERT, SSL_KEY, ADMIN_USERS, FEATURE_FLAGS, CLOUD_MANAGER
|
|
174
198
|
global MULTI_USER, WORKERS
|
|
199
|
+
global BREVO_API_KEY, BREVO_SENDER_EMAIL, BREVO_SENDER_NAME, PUBLIC_BASE_URL
|
|
175
200
|
|
|
176
201
|
parser = argparse.ArgumentParser(description="Run Aird")
|
|
177
202
|
parser.add_argument("--config", help="Path to JSON config file")
|
|
@@ -251,6 +276,7 @@ def init_config():
|
|
|
251
276
|
LDAP_ATTRIBUTE_MAP = ldap_settings["attribute_map"]
|
|
252
277
|
|
|
253
278
|
_apply_feature_flags_from_config(config)
|
|
279
|
+
_apply_brevo_settings(config)
|
|
254
280
|
|
|
255
281
|
MULTI_USER = args.multi_user or config.get("multi_user", False)
|
|
256
282
|
|
|
@@ -41,6 +41,7 @@ FEATURE_FLAGS = {
|
|
|
41
41
|
"storage_quotas": False,
|
|
42
42
|
"abac_engine": False,
|
|
43
43
|
"abac_audit_decisions": True,
|
|
44
|
+
"email_notifications": False,
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
# WebSocket connection configuration
|
|
@@ -59,13 +60,10 @@ UPLOAD_CONFIG = {
|
|
|
59
60
|
"allow_all_file_types": 0, # 0 = use whitelist below, 1 = allow any extension
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
# Per-request body limit (Cloudflare: keep each POST under ~100 MB and ~100s proxy timeout)
|
|
63
|
-
UPLOAD_CHUNK_SIZE_BYTES = 50 * 1024 * 1024 # 50 MiB per HTTP request
|
|
64
|
-
UPLOAD_REQUEST_MAX_BODY_SIZE = UPLOAD_CHUNK_SIZE_BYTES + (1024 * 1024) # chunk + slack
|
|
65
|
-
UPLOAD_MAX_PARALLEL_CHUNKS = 3 # fewer concurrent POSTs through reverse proxies
|
|
66
|
-
|
|
67
63
|
# File operation constants (derived from UPLOAD_CONFIG at startup)
|
|
68
64
|
MAX_FILE_SIZE = UPLOAD_CONFIG["max_file_size_mb"] * 1024 * 1024
|
|
65
|
+
# HTTP /upload body limit (browser uploads use WebSocket; CLI may POST whole file)
|
|
66
|
+
UPLOAD_REQUEST_MAX_BODY_SIZE = MAX_FILE_SIZE + (1024 * 1024)
|
|
69
67
|
MAX_READABLE_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
|
70
68
|
|
|
71
69
|
# Default line window for /files/... viewer when no ?end_line= is supplied (protects DOM from huge renders)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Incremental folder size calculation (sum of file sizes)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Files processed per batch before yielding back to the event loop.
|
|
9
|
+
FOLDER_SIZE_BATCH_FILES = 250
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FolderSizeWalker:
|
|
13
|
+
"""Walk a directory tree in batches without loading all paths at once."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, root_abspath: str) -> None:
|
|
16
|
+
self._walk_gen = os.walk(root_abspath)
|
|
17
|
+
self._current_dir: str | None = None
|
|
18
|
+
self._pending_files: list[str] = []
|
|
19
|
+
self.total_bytes = 0
|
|
20
|
+
self.file_count = 0
|
|
21
|
+
self.done = False
|
|
22
|
+
|
|
23
|
+
def step(self, batch_size: int = FOLDER_SIZE_BATCH_FILES) -> tuple[int, int, bool]:
|
|
24
|
+
"""Process up to *batch_size* files. Returns (total_bytes, file_count, done)."""
|
|
25
|
+
if self.done:
|
|
26
|
+
return self.total_bytes, self.file_count, True
|
|
27
|
+
|
|
28
|
+
processed = 0
|
|
29
|
+
while processed < batch_size and not self.done:
|
|
30
|
+
if not self._pending_files:
|
|
31
|
+
try:
|
|
32
|
+
self._current_dir, _dirnames, filenames = next(self._walk_gen)
|
|
33
|
+
self._pending_files = list(filenames)
|
|
34
|
+
except StopIteration:
|
|
35
|
+
self.done = True
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
while self._pending_files and processed < batch_size:
|
|
39
|
+
fname = self._pending_files.pop()
|
|
40
|
+
if not self._current_dir:
|
|
41
|
+
continue
|
|
42
|
+
fpath = os.path.join(self._current_dir, fname)
|
|
43
|
+
try:
|
|
44
|
+
if os.path.isfile(fpath):
|
|
45
|
+
self.total_bytes += os.path.getsize(fpath)
|
|
46
|
+
except OSError:
|
|
47
|
+
pass
|
|
48
|
+
self.file_count += 1
|
|
49
|
+
processed += 1
|
|
50
|
+
|
|
51
|
+
return self.total_bytes, self.file_count, self.done
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Build ZIP archives from user file paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
import zipfile
|
|
9
|
+
from typing import Iterable
|
|
10
|
+
|
|
11
|
+
from aird.core.security import is_within_root
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
MAX_ZIP_ENTRIES = 10_000
|
|
16
|
+
MAX_ZIP_UNCOMPRESSED_BYTES = 2 * 1024 * 1024 * 1024 # 2 GiB
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ZipDownloadError(Exception):
|
|
20
|
+
"""Raised when a zip cannot be built."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, message: str, status: int = 400):
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.status = status
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _normalise_rel_path(path: str) -> str:
|
|
28
|
+
return path.replace("\\", "/").strip().strip("/")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _safe_arcname(rel_path: str) -> str | None:
|
|
32
|
+
parts = [p for p in _normalise_rel_path(rel_path).split("/") if p and p not in (".", "..")]
|
|
33
|
+
if not parts:
|
|
34
|
+
return None
|
|
35
|
+
return "/".join(parts)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def collect_zip_entries(root_dir: str, paths: Iterable[str]) -> list[tuple[str, str]]:
|
|
39
|
+
"""Return (absolute_path, archive_name) pairs for all files under *paths*."""
|
|
40
|
+
root_dir = os.path.realpath(root_dir)
|
|
41
|
+
entries: list[tuple[str, str]] = []
|
|
42
|
+
seen_arc: set[str] = set()
|
|
43
|
+
total_bytes = 0
|
|
44
|
+
|
|
45
|
+
for raw in paths:
|
|
46
|
+
if not isinstance(raw, str):
|
|
47
|
+
raise ZipDownloadError("Invalid path", 400)
|
|
48
|
+
rel = _normalise_rel_path(raw)
|
|
49
|
+
if not rel:
|
|
50
|
+
continue
|
|
51
|
+
abspath = os.path.realpath(os.path.join(root_dir, rel))
|
|
52
|
+
if not is_within_root(abspath, root_dir):
|
|
53
|
+
raise ZipDownloadError("Access denied", 403)
|
|
54
|
+
if not os.path.exists(abspath):
|
|
55
|
+
raise ZipDownloadError(f"Not found: {rel}", 404)
|
|
56
|
+
|
|
57
|
+
def add_file(file_abs: str, arc: str | None) -> None:
|
|
58
|
+
nonlocal total_bytes
|
|
59
|
+
if arc is None:
|
|
60
|
+
return
|
|
61
|
+
if arc in seen_arc:
|
|
62
|
+
return
|
|
63
|
+
try:
|
|
64
|
+
size = os.path.getsize(file_abs)
|
|
65
|
+
except OSError:
|
|
66
|
+
return
|
|
67
|
+
total_bytes += size
|
|
68
|
+
if total_bytes > MAX_ZIP_UNCOMPRESSED_BYTES:
|
|
69
|
+
raise ZipDownloadError("Selection is too large to zip", 413)
|
|
70
|
+
seen_arc.add(arc)
|
|
71
|
+
entries.append((file_abs, arc))
|
|
72
|
+
if len(entries) > MAX_ZIP_ENTRIES:
|
|
73
|
+
raise ZipDownloadError("Too many files in selection", 400)
|
|
74
|
+
|
|
75
|
+
if os.path.isfile(abspath):
|
|
76
|
+
add_file(abspath, _safe_arcname(rel))
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
if os.path.isdir(abspath):
|
|
80
|
+
prefix = _safe_arcname(rel)
|
|
81
|
+
for dirpath, _dirnames, filenames in os.walk(abspath):
|
|
82
|
+
for fname in filenames:
|
|
83
|
+
full = os.path.join(dirpath, fname)
|
|
84
|
+
if not os.path.isfile(full):
|
|
85
|
+
continue
|
|
86
|
+
file_rel = os.path.relpath(full, root_dir).replace("\\", "/")
|
|
87
|
+
add_file(full, _safe_arcname(file_rel))
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
raise ZipDownloadError(f"Not a file or folder: {rel}", 400)
|
|
91
|
+
|
|
92
|
+
return entries
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_zip_file(entries: list[tuple[str, str]]) -> str:
|
|
96
|
+
"""Write entries to a temporary store-only zip (no compression, low CPU)."""
|
|
97
|
+
if not entries:
|
|
98
|
+
raise ZipDownloadError("No files to download", 400)
|
|
99
|
+
|
|
100
|
+
fd, zip_path = tempfile.mkstemp(prefix="aird_zip_", suffix=".zip")
|
|
101
|
+
os.close(fd)
|
|
102
|
+
try:
|
|
103
|
+
# ZIP_STORED: pack files without deflate — minimal CPU; larger wire size.
|
|
104
|
+
with zipfile.ZipFile(
|
|
105
|
+
zip_path, "w", compression=zipfile.ZIP_STORED, allowZip64=True
|
|
106
|
+
) as zf:
|
|
107
|
+
for file_abs, arcname in entries:
|
|
108
|
+
zf.write(file_abs, arcname, compress_type=zipfile.ZIP_STORED)
|
|
109
|
+
except Exception:
|
|
110
|
+
logger.exception("ZIP build failed")
|
|
111
|
+
try:
|
|
112
|
+
os.remove(zip_path)
|
|
113
|
+
except OSError:
|
|
114
|
+
pass
|
|
115
|
+
raise ZipDownloadError("Failed to create zip archive", 500) from None
|
|
116
|
+
return zip_path
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Brevo (Sendinblue) transactional email client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
BREVO_API_URL = "https://api.brevo.com/v3/smtp/email"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BrevoError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BrevoClient:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
api_key: str | None,
|
|
23
|
+
*,
|
|
24
|
+
sender_email: str | None,
|
|
25
|
+
sender_name: str = "Aird",
|
|
26
|
+
):
|
|
27
|
+
self.api_key = (api_key or "").strip()
|
|
28
|
+
self.sender_email = (sender_email or "").strip()
|
|
29
|
+
self.sender_name = (sender_name or "Aird").strip() or "Aird"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def configured(self) -> bool:
|
|
33
|
+
return bool(self.api_key and self.sender_email)
|
|
34
|
+
|
|
35
|
+
def send(
|
|
36
|
+
self,
|
|
37
|
+
to_email: str,
|
|
38
|
+
subject: str,
|
|
39
|
+
*,
|
|
40
|
+
html_content: str,
|
|
41
|
+
text_content: str | None = None,
|
|
42
|
+
to_name: str | None = None,
|
|
43
|
+
) -> bool:
|
|
44
|
+
if not self.configured:
|
|
45
|
+
logger.debug("Brevo not configured; skipping email to %s", to_email)
|
|
46
|
+
return False
|
|
47
|
+
to_email = to_email.strip()
|
|
48
|
+
if not to_email:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
payload: dict[str, Any] = {
|
|
52
|
+
"sender": {"email": self.sender_email, "name": self.sender_name},
|
|
53
|
+
"to": [{"email": to_email, "name": (to_name or to_email).strip()}],
|
|
54
|
+
"subject": subject,
|
|
55
|
+
"htmlContent": html_content,
|
|
56
|
+
}
|
|
57
|
+
if text_content:
|
|
58
|
+
payload["textContent"] = text_content
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
resp = requests.post(
|
|
62
|
+
BREVO_API_URL,
|
|
63
|
+
headers={
|
|
64
|
+
"api-key": self.api_key,
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
"accept": "application/json",
|
|
67
|
+
},
|
|
68
|
+
json=payload,
|
|
69
|
+
timeout=30,
|
|
70
|
+
)
|
|
71
|
+
except requests.RequestException as exc:
|
|
72
|
+
logger.warning("Brevo request failed for %s: %s", to_email, exc)
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
if resp.status_code >= 400:
|
|
76
|
+
logger.warning(
|
|
77
|
+
"Brevo API error %s for %s: %s",
|
|
78
|
+
resp.status_code,
|
|
79
|
+
to_email,
|
|
80
|
+
resp.text[:500],
|
|
81
|
+
)
|
|
82
|
+
return False
|
|
83
|
+
return True
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Resolve a delivery email address for an Aird username."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sqlite3
|
|
7
|
+
|
|
8
|
+
from aird.db.user_attributes import get_user_attributes
|
|
9
|
+
|
|
10
|
+
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def looks_like_email(value: str) -> bool:
|
|
14
|
+
return bool(_EMAIL_RE.match((value or "").strip()))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_user_email(conn: sqlite3.Connection | None, username: str) -> str | None:
|
|
18
|
+
"""
|
|
19
|
+
Return an email for *username*.
|
|
20
|
+
|
|
21
|
+
Order: user_attributes ``email`` → username when it is an email address.
|
|
22
|
+
"""
|
|
23
|
+
username = (username or "").strip()
|
|
24
|
+
if not username:
|
|
25
|
+
return None
|
|
26
|
+
if conn is not None:
|
|
27
|
+
try:
|
|
28
|
+
attrs = get_user_attributes(conn, username)
|
|
29
|
+
raw = (attrs.get("email") or attrs.get("mail") or "").strip()
|
|
30
|
+
if looks_like_email(raw):
|
|
31
|
+
return raw
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
if looks_like_email(username):
|
|
35
|
+
return username
|
|
36
|
+
return None
|
|
@@ -63,7 +63,8 @@ from aird.core.mmap_handler import MMapFileHandler
|
|
|
63
63
|
class FeatureFlagSocketHandler(
|
|
64
64
|
ManagedWebSocketMixin, tornado.websocket.WebSocketHandler
|
|
65
65
|
):
|
|
66
|
-
|
|
66
|
+
"""Legacy WebSocket kept for backward compatibility; new clients use GET /api/features."""
|
|
67
|
+
|
|
67
68
|
connection_manager = WebSocketConnectionManager(
|
|
68
69
|
"feature_flags", default_max_connections=50, default_idle_timeout=600
|
|
69
70
|
)
|
|
@@ -76,25 +77,30 @@ class FeatureFlagSocketHandler(
|
|
|
76
77
|
if not self.register_connection():
|
|
77
78
|
return
|
|
78
79
|
|
|
79
|
-
# Load current feature flags from SQLite and send to client
|
|
80
80
|
current_flags = self._get_current_feature_flags()
|
|
81
81
|
self.write_message(json.dumps(current_flags))
|
|
82
82
|
|
|
83
83
|
def check_origin(self, origin):
|
|
84
|
-
# Improved origin validation (Priority 2)
|
|
85
84
|
return is_valid_websocket_origin(self, origin)
|
|
86
85
|
|
|
87
86
|
def _get_current_feature_flags(self):
|
|
88
|
-
"""Get current feature flags using the consolidated implementation."""
|
|
89
87
|
return get_current_feature_flags()
|
|
90
88
|
|
|
91
89
|
@classmethod
|
|
92
90
|
def send_updates(cls):
|
|
93
|
-
"""Send feature flag updates to all connected clients."""
|
|
94
91
|
current_flags = get_current_feature_flags()
|
|
95
92
|
cls.connection_manager.broadcast_message(json.dumps(current_flags))
|
|
96
93
|
|
|
97
94
|
|
|
95
|
+
class FeatureFlagAPIHandler(BaseHandler):
|
|
96
|
+
"""GET /api/features — lightweight JSON endpoint for on-demand flag checks."""
|
|
97
|
+
|
|
98
|
+
@tornado.web.authenticated
|
|
99
|
+
def get(self):
|
|
100
|
+
self.set_header("Content-Type", "application/json")
|
|
101
|
+
self.write(json.dumps(get_current_feature_flags()))
|
|
102
|
+
|
|
103
|
+
|
|
98
104
|
class FileStreamHandler(ManagedWebSocketMixin, tornado.websocket.WebSocketHandler):
|
|
99
105
|
# Use connection manager with configurable limits for file streaming
|
|
100
106
|
connection_manager = WebSocketConnectionManager(
|