aird 0.4.23.dev11__tar.gz → 0.4.23.dev17__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-0.4.23.dev17}/MANIFEST.in +1 -0
- {aird-0.4.23.dev11/aird.egg-info → aird-0.4.23.dev17}/PKG-INFO +1 -1
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/cli/session.py +10 -71
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/config.py +30 -4
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/constants/__init__.py +45 -5
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/events.py +2 -0
- aird-0.4.23.dev17/aird/core/folder_size.py +101 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/mmap_handler.py +67 -26
- aird-0.4.23.dev17/aird/core/zip_download.py +116 -0
- aird-0.4.23.dev17/aird/email/__init__.py +6 -0
- aird-0.4.23.dev17/aird/email/brevo.py +83 -0
- aird-0.4.23.dev17/aird/email/resolve.py +36 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/api_handlers.py +64 -5
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/base_handler.py +1 -1
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/file_op_handlers.py +140 -466
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/share_handlers.py +6 -2
- aird-0.4.23.dev17/aird/handlers/transfer_ws_handlers.py +382 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/view_handlers.py +16 -5
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/main.py +14 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/__init__.py +4 -0
- aird-0.4.23.dev17/aird/services/email_service.py +97 -0
- aird-0.4.23.dev17/aird/services/email_subscriber.py +52 -0
- aird-0.4.23.dev17/aird/static/css/app.css +2 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/browse/app.js +448 -401
- aird-0.4.23.dev17/aird/static/js/download-manager.js +151 -0
- aird-0.4.23.dev17/aird/static/js/feature-flags-live.js +44 -0
- aird-0.4.23.dev17/aird/static/js/file-transfer-ws.js +260 -0
- aird-0.4.23.dev17/aird/static/js/folder-size-scan.js +188 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/share/app.js +46 -12
- aird-0.4.23.dev17/aird/static/js/transfer-tracker.js +315 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/_app_nav_header.html +32 -3
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/_bg_canvas.html +1 -1
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin.html +1 -1
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin_audit.html +4 -4
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin_ldap.html +3 -3
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin_login.html +4 -4
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin_network_shares.html +3 -3
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin_policies.html +4 -4
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin_tags.html +4 -4
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin_user_attributes.html +4 -4
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin_users.html +3 -3
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/browse.html +40 -59
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/directory.html +3 -3
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/edit.html +4 -4
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/error.html +3 -3
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/file.html +4 -4
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/ldap_config_create.html +97 -97
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/ldap_config_edit.html +99 -99
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/login.html +4 -4
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/media_view.html +3 -3
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/p2p_transfer.html +11 -11
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/profile.html +3 -3
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/share.html +54 -39
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/shared_list.html +20 -7
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/super_search.html +3 -3
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/tagged_files.html +33 -9
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/token_verification.html +3 -3
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/user_create.html +75 -75
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/user_edit.html +116 -116
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/utils/util.py +26 -5
- {aird-0.4.23.dev11 → aird-0.4.23.dev17/aird.egg-info}/PKG-INFO +1 -1
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird.egg-info/SOURCES.txt +17 -1
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/setup.py +1 -1
- aird-0.4.23.dev17/tests/test_email_service.py +76 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_file_op_handlers.py +7 -183
- aird-0.4.23.dev17/tests/test_folder_size.py +45 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_mmap_handler.py +42 -4
- aird-0.4.23.dev17/tests/test_transfer_ws_handlers.py +101 -0
- aird-0.4.23.dev17/tests/test_wheel_static_assets.py +112 -0
- aird-0.4.23.dev17/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/tests/test_wheel_static_assets.py +0 -48
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/LICENSE +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/README.md +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/__main__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/app_context.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/cli/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/cli/__main__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/cli/authelia.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/cli/config.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/cli/main.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/cloud/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/constants/admin.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/constants/file_ops.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/constants/input_limits.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/constants/media.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/file_operations.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/filter_expression.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/input_validation.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/security.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/share_root.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/websocket_manager.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/database/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/database/db.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/database/feature_flags.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/database/ldap.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/audit.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/config.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/favorites.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/network_shares.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/policies.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/policy_decisions.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/policy_seeds.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/quota.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/resource_tags.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/schema.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/shares.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/user_attributes.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/users.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/domain/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/domain/contracts.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/domain/models.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/abac_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/admin_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/auth_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/constants.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/health_handler.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/p2p_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/network_share_manager.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/server_runtime.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/audit_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/config_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/event_subscribers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/favorites_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/network_share_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/p2p_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/policy_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/quota_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/share_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/tag_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/user_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/sql_identifiers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/favicon.png +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/favicon.svg +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/img/logo-icon.png +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/img/logo-mark.svg +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/img/logo-text.png +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/img/logo.png +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/aird-core.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/bg-canvas.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/common/command-palette.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/components/folder-picker.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/login-ui.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/media-view.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/p2p/app.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/p2p/mediator.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/p2p/qr-adapter.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/p2p/signaling-service.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/p2p/state-machine.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/p2p/transfer-service.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/pages/p2p-page.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/pages/super-search.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/theme.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/vendor/pdf.min.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/vendor/pdf.worker.min.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/vendor/qrcode-browser.js +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/_admin_tabs.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/_theme_early.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/_theme_login_corner.html +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/utils/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird.egg-info/dependency_links.txt +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird.egg-info/entry_points.txt +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird.egg-info/requires.txt +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird.egg-info/top_level.txt +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/setup.cfg +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/__init__.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/conftest.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/handler_helpers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_admin_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_api_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_architecture_conformance.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_auth_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_auth_handlers_extended.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_base_handler.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_base_handler_pep.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_cli.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_cloud.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_config.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_core_file_operations.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_database_db.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_database_feature_flags.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_database_ldap.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_database_shares.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_database_users.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_database_users_hashing.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_db.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_filter_expression.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_main.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_multi_user.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_network_shares.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_p2p_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_password_hashing.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_policy_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_rate_limit.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_security.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_security_comprehensive.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_server_runtime.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_share_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_share_ownership.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_super_search_handler.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_tag_service.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_util.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_view_handlers.py +0 -0
- {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_websocket_manager.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
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import sys
|
|
5
|
+
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
|
7
8
|
from aird.cloud import CloudManager
|
|
@@ -41,6 +42,7 @@ FEATURE_FLAGS = {
|
|
|
41
42
|
"storage_quotas": False,
|
|
42
43
|
"abac_engine": False,
|
|
43
44
|
"abac_audit_decisions": True,
|
|
45
|
+
"email_notifications": False,
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
# WebSocket connection configuration
|
|
@@ -59,13 +61,10 @@ UPLOAD_CONFIG = {
|
|
|
59
61
|
"allow_all_file_types": 0, # 0 = use whitelist below, 1 = allow any extension
|
|
60
62
|
}
|
|
61
63
|
|
|
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
64
|
# File operation constants (derived from UPLOAD_CONFIG at startup)
|
|
68
65
|
MAX_FILE_SIZE = UPLOAD_CONFIG["max_file_size_mb"] * 1024 * 1024
|
|
66
|
+
# HTTP /upload body limit (browser uploads use WebSocket; CLI may POST whole file)
|
|
67
|
+
UPLOAD_REQUEST_MAX_BODY_SIZE = MAX_FILE_SIZE + (1024 * 1024)
|
|
69
68
|
MAX_READABLE_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
|
70
69
|
|
|
71
70
|
# Default line window for /files/... viewer when no ?end_line= is supplied (protects DOM from huge renders)
|
|
@@ -102,6 +101,8 @@ UPLOAD_ALLOWED_EXTENSIONS = set(ALLOWED_UPLOAD_EXTENSIONS)
|
|
|
102
101
|
# Mmap constants
|
|
103
102
|
MMAP_MIN_SIZE = 1 * 1024 * 1024 # 1 MB
|
|
104
103
|
CHUNK_SIZE = 64 * 1024 # 64 KB
|
|
104
|
+
# WebSocket binary frame size (stay under Cloudflare ~1 MiB message limit)
|
|
105
|
+
WS_TRANSFER_FRAME_BYTES = 768 * 1024
|
|
105
106
|
|
|
106
107
|
# Network share manager (set at startup)
|
|
107
108
|
NETWORK_SHARE_MANAGER = None
|
|
@@ -143,3 +144,42 @@ def _read_app_version() -> str:
|
|
|
143
144
|
|
|
144
145
|
|
|
145
146
|
APP_VERSION = _read_app_version()
|
|
147
|
+
|
|
148
|
+
_UI_PACKAGE_SUFFIXES = frozenset(
|
|
149
|
+
{".html", ".css", ".js", ".png", ".ico", ".svg", ".jpg", ".jpeg", ".webp", ".gif"}
|
|
150
|
+
)
|
|
151
|
+
_PKG_ROOT = Path(__file__).resolve().parent.parent
|
|
152
|
+
_static_version_cache: tuple[float, str] | None = None
|
|
153
|
+
_STATIC_VERSION_TTL = 2.0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _ui_fingerprint() -> str:
|
|
157
|
+
"""Hex fingerprint from latest mtime among shipped UI package-data files."""
|
|
158
|
+
latest = 0
|
|
159
|
+
for sub in ("static", "templates"):
|
|
160
|
+
base = _PKG_ROOT / sub
|
|
161
|
+
if not base.is_dir():
|
|
162
|
+
continue
|
|
163
|
+
for path in base.rglob("*"):
|
|
164
|
+
if not path.is_file():
|
|
165
|
+
continue
|
|
166
|
+
if path.suffix.lower() not in _UI_PACKAGE_SUFFIXES:
|
|
167
|
+
continue
|
|
168
|
+
try:
|
|
169
|
+
latest = max(latest, path.stat().st_mtime_ns)
|
|
170
|
+
except OSError:
|
|
171
|
+
pass
|
|
172
|
+
return format(latest, "x")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_static_version() -> str:
|
|
176
|
+
"""Cache-bust query value: package version plus UI file fingerprint."""
|
|
177
|
+
import time
|
|
178
|
+
|
|
179
|
+
global _static_version_cache
|
|
180
|
+
now = time.monotonic()
|
|
181
|
+
if _static_version_cache and now - _static_version_cache[0] < _STATIC_VERSION_TTL:
|
|
182
|
+
return _static_version_cache[1]
|
|
183
|
+
version = f"{APP_VERSION}-{_ui_fingerprint()}"
|
|
184
|
+
_static_version_cache = (now, version)
|
|
185
|
+
return version
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def norm_rel_path(rel_path: str) -> str:
|
|
55
|
+
return rel_path.replace("\\", "/").strip().strip("/")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_folder_abspath(user_root: str, rel_path: str) -> str | None:
|
|
59
|
+
"""Return absolute folder path or None if invalid / not a directory."""
|
|
60
|
+
from aird.core.security import is_within_root
|
|
61
|
+
|
|
62
|
+
rel = norm_rel_path(rel_path)
|
|
63
|
+
if not rel or ".." in rel.split("/"):
|
|
64
|
+
return None
|
|
65
|
+
abs_path = os.path.abspath(os.path.join(user_root, rel))
|
|
66
|
+
if not is_within_root(abs_path, user_root) or not os.path.isdir(abs_path):
|
|
67
|
+
return None
|
|
68
|
+
return abs_path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def compute_folder_size(root_abspath: str) -> tuple[int, int]:
|
|
72
|
+
"""Sum file sizes under *root_abspath*. Returns (total_bytes, file_count)."""
|
|
73
|
+
walker = FolderSizeWalker(root_abspath)
|
|
74
|
+
while not walker.done:
|
|
75
|
+
walker.step(FOLDER_SIZE_BATCH_FILES)
|
|
76
|
+
return walker.total_bytes, walker.file_count
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def norm_rel_path(rel_path: str) -> str:
|
|
80
|
+
return rel_path.replace("\\", "/").strip().strip("/")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resolve_folder_abspath(user_root: str, rel_path: str) -> str | None:
|
|
84
|
+
"""Return absolute folder path or None if invalid / not a directory."""
|
|
85
|
+
from aird.core.security import is_within_root
|
|
86
|
+
|
|
87
|
+
rel = norm_rel_path(rel_path)
|
|
88
|
+
if not rel or ".." in rel.split("/"):
|
|
89
|
+
return None
|
|
90
|
+
abs_path = os.path.abspath(os.path.join(user_root, rel))
|
|
91
|
+
if not is_within_root(abs_path, user_root) or not os.path.isdir(abs_path):
|
|
92
|
+
return None
|
|
93
|
+
return abs_path
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def compute_folder_size(root_abspath: str) -> tuple[int, int]:
|
|
97
|
+
"""Sum file sizes under *root_abspath*. Returns (total_bytes, file_count)."""
|
|
98
|
+
walker = FolderSizeWalker(root_abspath)
|
|
99
|
+
while not walker.done:
|
|
100
|
+
walker.step(FOLDER_SIZE_BATCH_FILES)
|
|
101
|
+
return walker.total_bytes, walker.file_count
|
|
@@ -25,20 +25,50 @@ def _read_chunks_sync(
|
|
|
25
25
|
return chunks
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
28
|
+
class _SyncChunkReader:
|
|
29
|
+
"""Read one chunk at a time from disk (mmap or plain file)."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
file_path: str,
|
|
34
|
+
start: int,
|
|
35
|
+
end: int | None,
|
|
36
|
+
file_size: int,
|
|
37
|
+
chunk_size: int,
|
|
38
|
+
use_mmap: bool,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._file_path = file_path
|
|
41
|
+
self._pos = start
|
|
42
|
+
self._end = min(end if end is not None else file_size - 1, file_size - 1)
|
|
43
|
+
self._chunk_size = chunk_size
|
|
44
|
+
self._use_mmap = use_mmap
|
|
45
|
+
self._file = None
|
|
46
|
+
self._mm = None
|
|
47
|
+
|
|
48
|
+
def open(self) -> None:
|
|
49
|
+
self._file = open(self._file_path, "rb")
|
|
50
|
+
if self._use_mmap:
|
|
51
|
+
self._mm = mmap.mmap(self._file.fileno(), 0, access=mmap.ACCESS_READ)
|
|
52
|
+
|
|
53
|
+
def read_next(self) -> bytes | None:
|
|
54
|
+
if self._pos > self._end:
|
|
55
|
+
return None
|
|
56
|
+
chunk_end = min(self._pos + self._chunk_size, self._end + 1)
|
|
57
|
+
if self._mm is not None:
|
|
58
|
+
chunk = self._mm[self._pos : chunk_end]
|
|
59
|
+
else:
|
|
60
|
+
self._file.seek(self._pos)
|
|
61
|
+
chunk = self._file.read(chunk_end - self._pos)
|
|
62
|
+
self._pos = chunk_end
|
|
63
|
+
return chunk if chunk else None
|
|
64
|
+
|
|
65
|
+
def close(self) -> None:
|
|
66
|
+
if self._mm is not None:
|
|
67
|
+
self._mm.close()
|
|
68
|
+
self._mm = None
|
|
69
|
+
if self._file is not None:
|
|
70
|
+
self._file.close()
|
|
71
|
+
self._file = None
|
|
42
72
|
|
|
43
73
|
|
|
44
74
|
class MMapFileHandler:
|
|
@@ -53,12 +83,11 @@ class MMapFileHandler:
|
|
|
53
83
|
async def serve_file_chunk(
|
|
54
84
|
file_path: str, start: int = 0, end: int = None, chunk_size: int = CHUNK_SIZE
|
|
55
85
|
):
|
|
56
|
-
"""Serve file chunks
|
|
86
|
+
"""Serve file chunks; only one chunk is held in memory at a time."""
|
|
57
87
|
try:
|
|
58
88
|
file_size = await asyncio.to_thread(os.path.getsize, file_path)
|
|
59
89
|
|
|
60
90
|
if not MMapFileHandler.should_use_mmap(file_size):
|
|
61
|
-
# Use async file API for small files
|
|
62
91
|
remaining = (end - start + 1) if end is not None else file_size - start
|
|
63
92
|
async with aiofiles.open(file_path, "rb") as f:
|
|
64
93
|
await f.seek(start)
|
|
@@ -70,21 +99,33 @@ class MMapFileHandler:
|
|
|
70
99
|
remaining -= len(chunk)
|
|
71
100
|
return
|
|
72
101
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
_read_chunks_mmap, file_path, start, end, file_size, chunk_size
|
|
102
|
+
reader = _SyncChunkReader(
|
|
103
|
+
file_path, start, end, file_size, chunk_size, use_mmap=True
|
|
76
104
|
)
|
|
77
|
-
|
|
78
|
-
|
|
105
|
+
try:
|
|
106
|
+
await asyncio.to_thread(reader.open)
|
|
107
|
+
while True:
|
|
108
|
+
chunk = await asyncio.to_thread(reader.read_next)
|
|
109
|
+
if not chunk:
|
|
110
|
+
break
|
|
111
|
+
yield chunk
|
|
112
|
+
finally:
|
|
113
|
+
await asyncio.to_thread(reader.close)
|
|
79
114
|
|
|
80
115
|
except (OSError, ValueError):
|
|
81
|
-
# Fallback to traditional method on mmap errors (run in thread pool)
|
|
82
116
|
file_size = await asyncio.to_thread(os.path.getsize, file_path)
|
|
83
|
-
|
|
84
|
-
|
|
117
|
+
reader = _SyncChunkReader(
|
|
118
|
+
file_path, start, end, file_size, chunk_size, use_mmap=False
|
|
85
119
|
)
|
|
86
|
-
|
|
87
|
-
|
|
120
|
+
try:
|
|
121
|
+
await asyncio.to_thread(reader.open)
|
|
122
|
+
while True:
|
|
123
|
+
chunk = await asyncio.to_thread(reader.read_next)
|
|
124
|
+
if not chunk:
|
|
125
|
+
break
|
|
126
|
+
yield chunk
|
|
127
|
+
finally:
|
|
128
|
+
await asyncio.to_thread(reader.close)
|
|
88
129
|
|
|
89
130
|
@staticmethod
|
|
90
131
|
def find_line_offsets(file_path: str, max_lines: int = None) -> list[int]:
|
|
@@ -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
|