aird 0.4.23.dev22__tar.gz → 0.4.25.dev1__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.dev22/aird.egg-info → aird-0.4.25.dev1}/PKG-INFO +5 -4
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/cli/session.py +58 -22
- aird-0.4.25.dev1/aird/cli/transfer_http.py +175 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/constants/__init__.py +41 -5
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/constants/admin.py +2 -0
- aird-0.4.25.dev1/aird/core/compression.py +171 -0
- aird-0.4.25.dev1/aird/core/file_send.py +58 -0
- aird-0.4.25.dev1/aird/core/rate_limit.py +113 -0
- aird-0.4.25.dev1/aird/event_loop.py +74 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/admin_handlers.py +28 -1
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/file_op_handlers.py +1 -13
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/ranged_upload_handlers.py +62 -32
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/view_handlers.py +76 -31
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/main.py +13 -1
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/network_share_manager.py +48 -4
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/config_service.py +1 -3
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/css/app.css +1 -1
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/file-transfer-http.js +141 -22
- aird-0.4.25.dev1/aird/static/js/share/app.js +2421 -0
- aird-0.4.25.dev1/aird/static/js/share/src/add-files-modal.js +228 -0
- aird-0.4.25.dev1/aird/static/js/share/src/cloud.js +491 -0
- aird-0.4.25.dev1/aird/static/js/share/src/create-share.js +166 -0
- aird-0.4.25.dev1/aird/static/js/share/src/create-users.js +228 -0
- aird-0.4.25.dev1/aird/static/js/share/src/expiry.js +72 -0
- aird-0.4.25.dev1/aird/static/js/share/src/file-icons.js +84 -0
- aird-0.4.25.dev1/aird/static/js/share/src/file-picker.js +233 -0
- aird-0.4.25.dev1/aird/static/js/share/src/init.js +279 -0
- aird-0.4.25.dev1/aird/static/js/share/src/main.js +3 -0
- aird-0.4.25.dev1/aird/static/js/share/src/management-templates.js +221 -0
- aird-0.4.25.dev1/aird/static/js/share/src/management.js +360 -0
- aird-0.4.25.dev1/aird/static/js/share/src/selection.js +118 -0
- aird-0.4.25.dev1/aird/static/js/share/src/share-popup.js +109 -0
- aird-0.4.25.dev1/aird/static/js/share/src/shares-list.js +197 -0
- aird-0.4.25.dev1/aird/static/js/share/src/state.js +58 -0
- aird-0.4.25.dev1/aird/static/js/share/src/utils.js +26 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin.html +8 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin_network_shares.html +15 -3
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/browse.html +2 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/share.html +3 -3
- {aird-0.4.23.dev22 → aird-0.4.25.dev1/aird.egg-info}/PKG-INFO +5 -4
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird.egg-info/SOURCES.txt +22 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird.egg-info/requires.txt +4 -5
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/setup.py +6 -4
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_admin_handlers.py +2 -2
- aird-0.4.25.dev1/tests/test_compression.py +66 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_network_shares.py +76 -3
- aird-0.4.25.dev1/tests/test_transfer_rate_limit.py +35 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_view_handlers.py +29 -25
- aird-0.4.23.dev22/aird/event_loop.py +0 -34
- aird-0.4.23.dev22/aird/static/js/share/app.js +0 -2606
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/LICENSE +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/MANIFEST.in +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/README.md +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/__init__.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/__main__.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/app_context.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/cli/__init__.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/cli/__main__.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/cli/authelia.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/cli/config.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/cli/main.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/cloud/__init__.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/config.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/constants/file_ops.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/constants/input_limits.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/constants/media.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/__init__.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/events.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/file_operations.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/filter_expression.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/folder_size.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/http_range.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/input_validation.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/mmap_handler.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/secret_storage.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/security.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/share_root.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/webauthn_config.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/websocket_manager.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/zip_download.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/database/__init__.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/database/db.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/database/feature_flags.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/database/ldap.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/__init__.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/audit.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/config.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/favorites.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/network_shares.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/policies.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/policy_decisions.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/policy_seeds.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/quota.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/ranged_uploads.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/resource_tags.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/schema.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/shares.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/user_attributes.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/users.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/webauthn.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/domain/__init__.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/domain/contracts.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/domain/models.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/email/__init__.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/email/brevo.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/email/resolve.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/__init__.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/abac_handlers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/api_handlers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/auth_handlers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/base_handler.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/constants.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/health_handler.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/p2p_handlers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/share_handlers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/transfer_ws_handlers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/webauthn_handlers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/server_runtime.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/__init__.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/audit_service.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/email_service.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/email_subscriber.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/event_subscribers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/favorites_service.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/network_share_service.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/p2p_service.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/policy_service.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/quota_service.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/share_service.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/tag_service.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/user_service.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/sql_identifiers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/favicon.png +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/favicon.svg +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/img/logo-icon.png +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/img/logo-mark.svg +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/img/logo-text.png +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/img/logo.png +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/aird-core.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/bg-canvas.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/browse/app.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/common/command-palette.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/components/folder-picker.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/download-manager.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/feature-flags-live.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/file-transfer-ws.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/folder-size-scan.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/login-ui.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/media-view.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/p2p/app.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/p2p/mediator.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/p2p/qr-adapter.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/p2p/signaling-service.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/p2p/state-machine.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/p2p/transfer-service.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/pages/p2p-page.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/pages/super-search.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/theme.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/transfer-tracker.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/vendor/pdf.min.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/vendor/pdf.worker.min.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/vendor/qrcode-browser.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/webauthn.js +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/_admin_tabs.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/_app_nav_header.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/_bg_canvas.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/_theme_early.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/_theme_login_corner.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin_audit.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin_ldap.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin_login.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin_policies.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin_tags.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin_user_attributes.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin_users.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/directory.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/edit.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/error.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/file.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/ldap_config_create.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/ldap_config_edit.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/login.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/media_view.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/p2p_transfer.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/profile.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/shared_list.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/super_search.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/tagged_files.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/token_verification.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/user_create.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/user_edit.html +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/utils/__init__.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/utils/util.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird.egg-info/dependency_links.txt +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird.egg-info/entry_points.txt +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird.egg-info/top_level.txt +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/setup.cfg +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/__init__.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/conftest.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/handler_helpers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_api_handlers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_architecture_conformance.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_auth_handlers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_auth_handlers_extended.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_base_handler.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_base_handler_pep.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_cli.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_cloud.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_config.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_core_file_operations.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_database_db.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_database_feature_flags.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_database_ldap.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_database_shares.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_database_users.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_database_users_hashing.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_db.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_email_service.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_file_op_handlers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_filter_expression.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_folder_size.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_http_range.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_main.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_mmap_handler.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_multi_user.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_p2p_handlers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_password_hashing.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_policy_service.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_rate_limit.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_secret_storage.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_security.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_security_comprehensive.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_server_runtime.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_share_handlers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_share_ownership.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_super_search_handler.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_tag_service.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_transfer_ws_handlers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_util.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_webauthn_handlers.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_websocket_manager.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_wheel_static_assets.py +0 -0
- {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_zip_download.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aird
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.25.dev1
|
|
4
4
|
Summary: Aird - A lightweight web-based file browser, editor, and streamer with real-time capabilities
|
|
5
5
|
Home-page: https://github.com/blinkerbit/aird
|
|
6
6
|
Author: Viswantha Srinivas P
|
|
@@ -19,11 +19,11 @@ Requires-Dist: aiofiles>=23.0.0
|
|
|
19
19
|
Requires-Dist: argon2-cffi>=23.1.0
|
|
20
20
|
Requires-Dist: requests>=2.31.0
|
|
21
21
|
Requires-Dist: chardet<6.0.0,>=5.0.0
|
|
22
|
-
Requires-Dist: pysmbserver>=0.1.0; python_version >= "3.13"
|
|
23
|
-
Requires-Dist: wsgidav>=4.3.0
|
|
24
|
-
Requires-Dist: cheroot>=10.0.0
|
|
25
22
|
Requires-Dist: pyasn1>=0.6.2
|
|
26
23
|
Requires-Dist: webauthn>=2.0.0
|
|
24
|
+
Provides-Extra: compress
|
|
25
|
+
Requires-Dist: zstandard>=0.22.0; extra == "compress"
|
|
26
|
+
Requires-Dist: brotli>=1.1.0; extra == "compress"
|
|
27
27
|
Dynamic: author
|
|
28
28
|
Dynamic: author-email
|
|
29
29
|
Dynamic: classifier
|
|
@@ -32,6 +32,7 @@ Dynamic: description-content-type
|
|
|
32
32
|
Dynamic: home-page
|
|
33
33
|
Dynamic: license
|
|
34
34
|
Dynamic: license-file
|
|
35
|
+
Dynamic: provides-extra
|
|
35
36
|
Dynamic: requires-dist
|
|
36
37
|
Dynamic: requires-python
|
|
37
38
|
Dynamic: summary
|
|
@@ -14,10 +14,16 @@ from urllib.parse import quote, urljoin
|
|
|
14
14
|
import requests
|
|
15
15
|
|
|
16
16
|
from aird.cli.config import ensure_config_dir, get_authelia_url, get_server_url, session_path
|
|
17
|
+
from aird.cli.transfer_http import (
|
|
18
|
+
DEFAULT_CONCURRENCY,
|
|
19
|
+
download_file_ranged,
|
|
20
|
+
upload_file_ranged,
|
|
21
|
+
)
|
|
17
22
|
|
|
18
23
|
logger = logging.getLogger(__name__)
|
|
19
24
|
|
|
20
25
|
XSRF_COOKIE = "_xsrf"
|
|
26
|
+
LARGE_CLI_TRANSFER_BYTES = 100 * 1024 * 1024
|
|
21
27
|
|
|
22
28
|
|
|
23
29
|
def _remote_url(base_path: str, remote_path: str) -> str:
|
|
@@ -266,10 +272,26 @@ class AirdClient:
|
|
|
266
272
|
size = int(entry.get("size_bytes") or entry.get("size") or 0)
|
|
267
273
|
yield child, size
|
|
268
274
|
|
|
269
|
-
def download_file(self, remote_path: str, local_path: Path) -> None:
|
|
275
|
+
def download_file(self, remote_path: str, local_path: Path, *, workers: int = 2) -> None:
|
|
270
276
|
self.ensure_auth()
|
|
271
277
|
url = self._url(_remote_url("/files", remote_path)) + "?download=1"
|
|
272
278
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
head = self.http.head(url, timeout=120)
|
|
280
|
+
if head.status_code >= 400:
|
|
281
|
+
raise AirdAPIError(
|
|
282
|
+
f"Download failed for {remote_path} (HTTP {head.status_code})",
|
|
283
|
+
head.status_code,
|
|
284
|
+
)
|
|
285
|
+
total = int(head.headers.get("Content-Length") or 0)
|
|
286
|
+
if total >= LARGE_CLI_TRANSFER_BYTES:
|
|
287
|
+
download_file_ranged(
|
|
288
|
+
self.http,
|
|
289
|
+
self.server.rstrip("/"),
|
|
290
|
+
remote_path,
|
|
291
|
+
local_path,
|
|
292
|
+
workers=max(1, workers or DEFAULT_CONCURRENCY),
|
|
293
|
+
)
|
|
294
|
+
return
|
|
273
295
|
with self.http.get(url, stream=True, timeout=300) as r:
|
|
274
296
|
if r.status_code >= 400:
|
|
275
297
|
raise AirdAPIError(
|
|
@@ -281,6 +303,41 @@ class AirdClient:
|
|
|
281
303
|
if chunk:
|
|
282
304
|
f.write(chunk)
|
|
283
305
|
|
|
306
|
+
def upload_file(
|
|
307
|
+
self, local_path: Path, remote_dir: str = "", *, workers: int = 2
|
|
308
|
+
) -> None:
|
|
309
|
+
self.ensure_auth()
|
|
310
|
+
if not local_path.is_file():
|
|
311
|
+
raise FileNotFoundError(local_path)
|
|
312
|
+
remote_dir = remote_dir.strip("/")
|
|
313
|
+
filename = local_path.name
|
|
314
|
+
size = local_path.stat().st_size
|
|
315
|
+
if size >= LARGE_CLI_TRANSFER_BYTES:
|
|
316
|
+
upload_file_ranged(
|
|
317
|
+
self.http,
|
|
318
|
+
self.server.rstrip("/"),
|
|
319
|
+
self._xsrf_header(),
|
|
320
|
+
local_path,
|
|
321
|
+
remote_dir,
|
|
322
|
+
workers=max(1, workers or DEFAULT_CONCURRENCY),
|
|
323
|
+
)
|
|
324
|
+
return
|
|
325
|
+
r = self.http.post(
|
|
326
|
+
self._url("/upload"),
|
|
327
|
+
params={"upload_dir": remote_dir, "upload_filename": filename},
|
|
328
|
+
data=local_path.read_bytes(),
|
|
329
|
+
headers={
|
|
330
|
+
**self._xsrf_header(),
|
|
331
|
+
"Content-Type": "application/octet-stream",
|
|
332
|
+
},
|
|
333
|
+
timeout=600,
|
|
334
|
+
)
|
|
335
|
+
if r.status_code >= 400:
|
|
336
|
+
raise AirdAPIError(
|
|
337
|
+
f"Upload failed for {filename} (HTTP {r.status_code})",
|
|
338
|
+
r.status_code,
|
|
339
|
+
)
|
|
340
|
+
|
|
284
341
|
def download_tree(
|
|
285
342
|
self,
|
|
286
343
|
remote_dir: str,
|
|
@@ -314,27 +371,6 @@ class AirdClient:
|
|
|
314
371
|
on_progress(path)
|
|
315
372
|
return count
|
|
316
373
|
|
|
317
|
-
def upload_file(self, local_path: Path, remote_dir: str = "") -> None:
|
|
318
|
-
self.ensure_auth()
|
|
319
|
-
if not local_path.is_file():
|
|
320
|
-
raise FileNotFoundError(local_path)
|
|
321
|
-
remote_dir = remote_dir.strip("/")
|
|
322
|
-
filename = local_path.name
|
|
323
|
-
r = self.http.post(
|
|
324
|
-
self._url("/upload"),
|
|
325
|
-
params={"upload_dir": remote_dir, "upload_filename": filename},
|
|
326
|
-
data=local_path.read_bytes(),
|
|
327
|
-
headers={
|
|
328
|
-
**self._xsrf_header(),
|
|
329
|
-
"Content-Type": "application/octet-stream",
|
|
330
|
-
},
|
|
331
|
-
timeout=600,
|
|
332
|
-
)
|
|
333
|
-
if r.status_code >= 400:
|
|
334
|
-
raise AirdAPIError(
|
|
335
|
-
f"Upload failed for {filename} (HTTP {r.status_code})",
|
|
336
|
-
r.status_code,
|
|
337
|
-
)
|
|
338
374
|
|
|
339
375
|
def upload_tree(
|
|
340
376
|
self,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Parallel HTTP Range upload/download for aird-cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DEFAULT_CHUNK = 32 * 1024 * 1024
|
|
14
|
+
DEFAULT_CONCURRENCY = 4
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _clone_session(http: requests.Session) -> requests.Session:
|
|
18
|
+
s = requests.Session()
|
|
19
|
+
s.cookies.update(http.cookies)
|
|
20
|
+
s.headers.update(getattr(http, "headers", {}))
|
|
21
|
+
return s
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _range_session(
|
|
25
|
+
http: requests.Session,
|
|
26
|
+
base_url: str,
|
|
27
|
+
xsrf_header: dict[str, str],
|
|
28
|
+
upload_dir: str,
|
|
29
|
+
filename: str,
|
|
30
|
+
total_size: int,
|
|
31
|
+
) -> str:
|
|
32
|
+
r = http.post(
|
|
33
|
+
f"{base_url}/api/upload/range/session",
|
|
34
|
+
json={
|
|
35
|
+
"upload_dir": upload_dir.strip("/"),
|
|
36
|
+
"filename": filename,
|
|
37
|
+
"total_size": total_size,
|
|
38
|
+
},
|
|
39
|
+
headers={"Content-Type": "application/json", **xsrf_header},
|
|
40
|
+
timeout=120,
|
|
41
|
+
)
|
|
42
|
+
if r.status_code >= 400:
|
|
43
|
+
raise RuntimeError(f"Range session failed ({r.status_code}): {r.text}")
|
|
44
|
+
return r.json()["upload_id"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _put_chunk(
|
|
48
|
+
http: requests.Session,
|
|
49
|
+
base_url: str,
|
|
50
|
+
xsrf_header: dict[str, str],
|
|
51
|
+
upload_id: str,
|
|
52
|
+
data: bytes,
|
|
53
|
+
start: int,
|
|
54
|
+
end: int,
|
|
55
|
+
total_size: int,
|
|
56
|
+
) -> bool:
|
|
57
|
+
r = http.put(
|
|
58
|
+
f"{base_url}/api/upload/range/{upload_id}",
|
|
59
|
+
data=data,
|
|
60
|
+
headers={
|
|
61
|
+
"Content-Type": "application/octet-stream",
|
|
62
|
+
"Content-Range": f"bytes {start}-{end}/{total_size}",
|
|
63
|
+
**xsrf_header,
|
|
64
|
+
},
|
|
65
|
+
timeout=600,
|
|
66
|
+
)
|
|
67
|
+
if r.status_code == 201:
|
|
68
|
+
return True
|
|
69
|
+
if r.status_code not in (200, 201):
|
|
70
|
+
raise RuntimeError(f"Chunk upload failed ({r.status_code}): {r.text}")
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def upload_file_ranged(
|
|
75
|
+
http: requests.Session,
|
|
76
|
+
base_url: str,
|
|
77
|
+
xsrf_header: dict[str, str],
|
|
78
|
+
local_path: Path,
|
|
79
|
+
remote_dir: str = "",
|
|
80
|
+
*,
|
|
81
|
+
chunk_size: int = DEFAULT_CHUNK,
|
|
82
|
+
workers: int = DEFAULT_CONCURRENCY,
|
|
83
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
total = local_path.stat().st_size
|
|
86
|
+
filename = local_path.name
|
|
87
|
+
upload_id = _range_session(
|
|
88
|
+
http, base_url, xsrf_header, remote_dir, filename, total
|
|
89
|
+
)
|
|
90
|
+
total_chunks = math.ceil(total / chunk_size)
|
|
91
|
+
done_bytes = 0
|
|
92
|
+
lock = __import__("threading").Lock()
|
|
93
|
+
|
|
94
|
+
def _job(idx: int) -> bool:
|
|
95
|
+
start = idx * chunk_size
|
|
96
|
+
end = min(start + chunk_size, total) - 1
|
|
97
|
+
with local_path.open("rb") as f:
|
|
98
|
+
f.seek(start)
|
|
99
|
+
data = f.read(end - start + 1)
|
|
100
|
+
return _put_chunk(
|
|
101
|
+
_clone_session(http),
|
|
102
|
+
base_url,
|
|
103
|
+
xsrf_header,
|
|
104
|
+
upload_id,
|
|
105
|
+
data,
|
|
106
|
+
start,
|
|
107
|
+
end,
|
|
108
|
+
total,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
finished = False
|
|
112
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
113
|
+
futures = {pool.submit(_job, i): i for i in range(total_chunks)}
|
|
114
|
+
for fut in as_completed(futures):
|
|
115
|
+
if fut.result():
|
|
116
|
+
finished = True
|
|
117
|
+
with lock:
|
|
118
|
+
done_bytes = min(total, done_bytes + chunk_size)
|
|
119
|
+
if on_progress:
|
|
120
|
+
on_progress(min(done_bytes, total), total)
|
|
121
|
+
if not finished and done_bytes < total:
|
|
122
|
+
raise RuntimeError("Ranged upload did not complete")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def download_file_ranged(
|
|
126
|
+
http: requests.Session,
|
|
127
|
+
base_url: str,
|
|
128
|
+
remote_path: str,
|
|
129
|
+
local_path: Path,
|
|
130
|
+
*,
|
|
131
|
+
chunk_size: int = DEFAULT_CHUNK,
|
|
132
|
+
workers: int = DEFAULT_CONCURRENCY,
|
|
133
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
enc = "/".join(requests.utils.quote(p) for p in remote_path.strip("/").split("/") if p)
|
|
136
|
+
url = f"{base_url}/files/{enc}?download=1" if enc else f"{base_url}/files/?download=1"
|
|
137
|
+
head = http.head(url, timeout=120)
|
|
138
|
+
if head.status_code >= 400:
|
|
139
|
+
raise RuntimeError(f"HEAD failed ({head.status_code})")
|
|
140
|
+
total = int(head.headers.get("Content-Length") or 0)
|
|
141
|
+
if total <= 0:
|
|
142
|
+
raise RuntimeError("Missing Content-Length for ranged download")
|
|
143
|
+
|
|
144
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
with local_path.open("wb") as out:
|
|
146
|
+
out.truncate(total)
|
|
147
|
+
|
|
148
|
+
total_chunks = math.ceil(total / chunk_size)
|
|
149
|
+
done_bytes = 0
|
|
150
|
+
lock = __import__("threading").Lock()
|
|
151
|
+
|
|
152
|
+
def _job(idx: int) -> int:
|
|
153
|
+
start = idx * chunk_size
|
|
154
|
+
end = min(start + chunk_size, total) - 1
|
|
155
|
+
sess = _clone_session(http)
|
|
156
|
+
r = sess.get(
|
|
157
|
+
url,
|
|
158
|
+
headers={"Range": f"bytes={start}-{end}"},
|
|
159
|
+
timeout=600,
|
|
160
|
+
)
|
|
161
|
+
if r.status_code not in (200, 206):
|
|
162
|
+
raise RuntimeError(f"Range GET failed ({r.status_code})")
|
|
163
|
+
with local_path.open("r+b") as f:
|
|
164
|
+
f.seek(start)
|
|
165
|
+
f.write(r.content)
|
|
166
|
+
return len(r.content)
|
|
167
|
+
|
|
168
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
169
|
+
futures = [pool.submit(_job, i) for i in range(total_chunks)]
|
|
170
|
+
for fut in as_completed(futures):
|
|
171
|
+
n = fut.result()
|
|
172
|
+
with lock:
|
|
173
|
+
done_bytes += n
|
|
174
|
+
if on_progress:
|
|
175
|
+
on_progress(min(done_bytes, total), total)
|
|
@@ -44,6 +44,9 @@ FEATURE_FLAGS = {
|
|
|
44
44
|
"abac_audit_decisions": True,
|
|
45
45
|
"email_notifications": False,
|
|
46
46
|
"webauthn": False,
|
|
47
|
+
"smb_server": False,
|
|
48
|
+
"webdav_server": False,
|
|
49
|
+
"transfer_sendfile": False,
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
# WebSocket connection configuration
|
|
@@ -60,15 +63,48 @@ WEBSOCKET_CONFIG = {
|
|
|
60
63
|
UPLOAD_CONFIG = {
|
|
61
64
|
"max_file_size_mb": 512, # Default max upload file size in MB
|
|
62
65
|
"allow_all_file_types": 0, # 0 = use whitelist below, 1 = allow any extension
|
|
66
|
+
# 0 = stream via single POST up to max_file_size_mb; set lower (e.g. 90) behind reverse proxies
|
|
67
|
+
"single_request_max_mb": 0,
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
# File operation constants (derived from UPLOAD_CONFIG
|
|
70
|
+
# File operation constants (derived from UPLOAD_CONFIG; call refresh_upload_derived_constants after changes)
|
|
66
71
|
MAX_FILE_SIZE = UPLOAD_CONFIG["max_file_size_mb"] * 1024 * 1024
|
|
67
|
-
# HTTP /upload body limit (browser small uploads + CLI)
|
|
68
72
|
UPLOAD_REQUEST_MAX_BODY_SIZE = MAX_FILE_SIZE + (1024 * 1024)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
LARGE_FILE_THRESHOLD_BYTES = MAX_FILE_SIZE
|
|
74
|
+
RANGE_CHUNK_BYTES = 32 * 1024 * 1024
|
|
75
|
+
RANGE_UPLOAD_CONCURRENCY = 4
|
|
76
|
+
RANGE_DOWNLOAD_CONCURRENCY = 4
|
|
77
|
+
|
|
78
|
+
COMPRESSION_CONFIG = {
|
|
79
|
+
"mode": "wan_only",
|
|
80
|
+
"level": 6,
|
|
81
|
+
"algorithms": ["zstd", "br", "gzip"],
|
|
82
|
+
"min_bytes": 1024,
|
|
83
|
+
"max_bytes": 50 * 1024 * 1024,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
TRANSFER_CONFIG = {
|
|
87
|
+
"upload_mb_per_sec": 0,
|
|
88
|
+
"download_mb_per_sec": 0,
|
|
89
|
+
"burst_mb": 64,
|
|
90
|
+
"max_concurrent": 0,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def refresh_upload_derived_constants() -> None:
|
|
95
|
+
"""Recompute upload size limits after UPLOAD_CONFIG is loaded or changed."""
|
|
96
|
+
global MAX_FILE_SIZE, UPLOAD_REQUEST_MAX_BODY_SIZE, LARGE_FILE_THRESHOLD_BYTES
|
|
97
|
+
MAX_FILE_SIZE = UPLOAD_CONFIG["max_file_size_mb"] * 1024 * 1024
|
|
98
|
+
UPLOAD_REQUEST_MAX_BODY_SIZE = MAX_FILE_SIZE + (1024 * 1024)
|
|
99
|
+
single_mb = int(UPLOAD_CONFIG.get("single_request_max_mb", 0) or 0)
|
|
100
|
+
if single_mb <= 0:
|
|
101
|
+
single_mb = UPLOAD_CONFIG["max_file_size_mb"]
|
|
102
|
+
else:
|
|
103
|
+
single_mb = min(single_mb, UPLOAD_CONFIG["max_file_size_mb"])
|
|
104
|
+
LARGE_FILE_THRESHOLD_BYTES = single_mb * 1024 * 1024
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
refresh_upload_derived_constants()
|
|
72
108
|
# Max JSON WebSocket control message size (search, stream commands, P2P signaling)
|
|
73
109
|
WS_JSON_MESSAGE_MAX_BYTES = 64 * 1024
|
|
74
110
|
MAX_READABLE_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
|
@@ -16,6 +16,8 @@ URL_ADMIN_NETWORK_SHARES = "/admin/network-shares"
|
|
|
16
16
|
ERR_DB_UNAVAILABLE = "Database+unavailable"
|
|
17
17
|
ERR_ALL_FIELDS_REQUIRED = "All+fields+are+required"
|
|
18
18
|
ERR_INVALID_PROTOCOL = "Invalid+protocol"
|
|
19
|
+
ERR_SMB_UNAVAILABLE = "SMB+server+is+disabled+or+pysmbserver+is+not+installed"
|
|
20
|
+
ERR_WEBDAV_UNAVAILABLE = "WebDAV+server+is+disabled+or+wsgidav+is+not+installed"
|
|
19
21
|
ERR_FOLDER_NOT_EXIST = "Folder+does+not+exist"
|
|
20
22
|
ERR_PORT_RANGE = "Port+must+be+1-65535"
|
|
21
23
|
ERR_FAILED_CREATE_SHARE = "Failed+to+create+share"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""HTTP response compression: zstd, brotli, gzip with streaming."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import gzip
|
|
7
|
+
import io
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from typing import AsyncIterator
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_COMPRESSIBLE_MIME_PREFIXES = (
|
|
15
|
+
"text/",
|
|
16
|
+
"application/json",
|
|
17
|
+
"application/javascript",
|
|
18
|
+
"application/xml",
|
|
19
|
+
"application/yaml",
|
|
20
|
+
"application/x-yaml",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_INCOMPRESSIBLE_EXTENSIONS = frozenset(
|
|
24
|
+
{
|
|
25
|
+
".gz",
|
|
26
|
+
".zip",
|
|
27
|
+
".bz2",
|
|
28
|
+
".xz",
|
|
29
|
+
".7z",
|
|
30
|
+
".rar",
|
|
31
|
+
".mp4",
|
|
32
|
+
".webm",
|
|
33
|
+
".mp3",
|
|
34
|
+
".wav",
|
|
35
|
+
".jpg",
|
|
36
|
+
".jpeg",
|
|
37
|
+
".png",
|
|
38
|
+
".webp",
|
|
39
|
+
".gif",
|
|
40
|
+
".ico",
|
|
41
|
+
".pdf",
|
|
42
|
+
".br",
|
|
43
|
+
".zst",
|
|
44
|
+
".zstd",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_zstd_available = False
|
|
49
|
+
_brotli_available = False
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
import zstandard as _zstd # noqa: F401
|
|
53
|
+
|
|
54
|
+
_zstd_available = True
|
|
55
|
+
except ImportError:
|
|
56
|
+
_zstd = None
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
import brotli as _brotli # noqa: F401
|
|
60
|
+
|
|
61
|
+
_brotli_available = True
|
|
62
|
+
except ImportError:
|
|
63
|
+
_brotli = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def codecs_available() -> dict[str, bool]:
|
|
67
|
+
return {"zstd": _zstd_available, "br": _brotli_available, "gzip": True}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def negotiate_encoding(accept_header: str | None, enabled: list[str] | None = None) -> str | None:
|
|
71
|
+
"""Pick best Content-Encoding from Accept-Encoding (zstd > br > gzip)."""
|
|
72
|
+
if not accept_header:
|
|
73
|
+
return None
|
|
74
|
+
allowed = enabled or ["zstd", "br", "gzip"]
|
|
75
|
+
tokens: dict[str, float] = {}
|
|
76
|
+
for part in accept_header.split(","):
|
|
77
|
+
piece = part.strip()
|
|
78
|
+
if not piece:
|
|
79
|
+
continue
|
|
80
|
+
if ";" in piece:
|
|
81
|
+
name, _, qpart = piece.partition(";")
|
|
82
|
+
qval = 1.0
|
|
83
|
+
if "q=" in qpart:
|
|
84
|
+
try:
|
|
85
|
+
qval = float(qpart.split("q=", 1)[1].strip())
|
|
86
|
+
except ValueError:
|
|
87
|
+
qval = 0.0
|
|
88
|
+
else:
|
|
89
|
+
name, qval = piece, 1.0
|
|
90
|
+
name = name.strip().lower()
|
|
91
|
+
if qval > 0:
|
|
92
|
+
tokens[name] = max(tokens.get(name, 0.0), qval)
|
|
93
|
+
for codec in ("zstd", "br", "gzip"):
|
|
94
|
+
if codec in allowed and codec in tokens:
|
|
95
|
+
if codec == "zstd" and not _zstd_available:
|
|
96
|
+
continue
|
|
97
|
+
if codec == "br" and not _brotli_available:
|
|
98
|
+
continue
|
|
99
|
+
return codec
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _ip_in_corporate(remote_ip: str, cidrs: list[str]) -> bool:
|
|
104
|
+
if not remote_ip or not cidrs:
|
|
105
|
+
return False
|
|
106
|
+
try:
|
|
107
|
+
import ipaddress
|
|
108
|
+
|
|
109
|
+
addr = ipaddress.ip_address(remote_ip)
|
|
110
|
+
for cidr in cidrs:
|
|
111
|
+
if addr in ipaddress.ip_network(cidr, strict=False):
|
|
112
|
+
return True
|
|
113
|
+
except ValueError:
|
|
114
|
+
pass
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def should_compress(
|
|
119
|
+
*,
|
|
120
|
+
path: str,
|
|
121
|
+
mime_type: str,
|
|
122
|
+
file_size: int,
|
|
123
|
+
has_range: bool,
|
|
124
|
+
remote_ip: str,
|
|
125
|
+
compression_enabled: bool,
|
|
126
|
+
mode: str = "wan_only",
|
|
127
|
+
min_bytes: int = 1024,
|
|
128
|
+
max_bytes: int = 50 * 1024 * 1024,
|
|
129
|
+
corporate_cidrs: list[str] | None = None,
|
|
130
|
+
) -> bool:
|
|
131
|
+
if not compression_enabled or has_range:
|
|
132
|
+
return False
|
|
133
|
+
if file_size < min_bytes or file_size > max_bytes:
|
|
134
|
+
return False
|
|
135
|
+
ext = os.path.splitext(path)[1].lower()
|
|
136
|
+
if ext in _INCOMPRESSIBLE_EXTENSIONS:
|
|
137
|
+
return False
|
|
138
|
+
if not any(mime_type.startswith(p) for p in _COMPRESSIBLE_MIME_PREFIXES):
|
|
139
|
+
return False
|
|
140
|
+
if mode == "never":
|
|
141
|
+
return False
|
|
142
|
+
if mode == "wan_only" and _ip_in_corporate(remote_ip, corporate_cidrs or []):
|
|
143
|
+
return False
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _compress_file_sync(path: str, encoding: str, level: int) -> bytes:
|
|
148
|
+
with open(path, "rb") as f_in:
|
|
149
|
+
raw = f_in.read()
|
|
150
|
+
if encoding == "zstd":
|
|
151
|
+
cctx = _zstd.ZstdCompressor(level=level)
|
|
152
|
+
return cctx.compress(raw)
|
|
153
|
+
if encoding == "br":
|
|
154
|
+
return _brotli.compress(raw, quality=min(level, 11))
|
|
155
|
+
buf = io.BytesIO()
|
|
156
|
+
with gzip.GzipFile(fileobj=buf, mode="wb", compresslevel=min(level, 9)) as gz:
|
|
157
|
+
gz.write(raw)
|
|
158
|
+
return buf.getvalue()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def compress_file(path: str, encoding: str, level: int = 6) -> bytes:
|
|
162
|
+
return await asyncio.to_thread(_compress_file_sync, path, encoding, level)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def stream_uncompressed(path: str, chunk_size: int = 65536) -> AsyncIterator[bytes]:
|
|
166
|
+
with open(path, "rb") as f:
|
|
167
|
+
while True:
|
|
168
|
+
chunk = await asyncio.to_thread(f.read, chunk_size)
|
|
169
|
+
if not chunk:
|
|
170
|
+
break
|
|
171
|
+
yield chunk
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Zero-copy file send via os.sendfile (Linux)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
_SENDFILE_CHUNK = 8 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def sendfile_available() -> bool:
|
|
16
|
+
return sys.platform.startswith("linux") and hasattr(os, "sendfile")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _sendfile_sync(out_fd: int, in_fd: int, offset: int, count: int) -> int:
|
|
20
|
+
sent = 0
|
|
21
|
+
while sent < count:
|
|
22
|
+
n = os.sendfile(out_fd, in_fd, offset + sent, min(_SENDFILE_CHUNK, count - sent))
|
|
23
|
+
if n <= 0:
|
|
24
|
+
break
|
|
25
|
+
sent += n
|
|
26
|
+
return sent
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def sendfile_to_socket(
|
|
30
|
+
sock,
|
|
31
|
+
file_path: str,
|
|
32
|
+
start: int = 0,
|
|
33
|
+
length: int | None = None,
|
|
34
|
+
) -> bool:
|
|
35
|
+
"""Send file bytes to socket via sendfile. Returns False if unsupported or failed."""
|
|
36
|
+
if not sendfile_available():
|
|
37
|
+
return False
|
|
38
|
+
try:
|
|
39
|
+
out_fd = sock.fileno()
|
|
40
|
+
except (AttributeError, OSError):
|
|
41
|
+
return False
|
|
42
|
+
try:
|
|
43
|
+
file_size = os.path.getsize(file_path)
|
|
44
|
+
end = file_size if length is None else start + length
|
|
45
|
+
count = min(end, file_size) - start
|
|
46
|
+
if count <= 0:
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
def _run() -> int:
|
|
50
|
+
with open(file_path, "rb") as f:
|
|
51
|
+
in_fd = f.fileno()
|
|
52
|
+
return _sendfile_sync(out_fd, in_fd, start, count)
|
|
53
|
+
|
|
54
|
+
sent = await asyncio.to_thread(_run)
|
|
55
|
+
return sent >= count
|
|
56
|
+
except OSError:
|
|
57
|
+
logger.debug("sendfile failed for %s", file_path, exc_info=True)
|
|
58
|
+
return False
|