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.
Files changed (243) hide show
  1. {aird-0.4.23.dev22/aird.egg-info → aird-0.4.25.dev1}/PKG-INFO +5 -4
  2. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/cli/session.py +58 -22
  3. aird-0.4.25.dev1/aird/cli/transfer_http.py +175 -0
  4. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/constants/__init__.py +41 -5
  5. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/constants/admin.py +2 -0
  6. aird-0.4.25.dev1/aird/core/compression.py +171 -0
  7. aird-0.4.25.dev1/aird/core/file_send.py +58 -0
  8. aird-0.4.25.dev1/aird/core/rate_limit.py +113 -0
  9. aird-0.4.25.dev1/aird/event_loop.py +74 -0
  10. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/admin_handlers.py +28 -1
  11. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/file_op_handlers.py +1 -13
  12. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/ranged_upload_handlers.py +62 -32
  13. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/view_handlers.py +76 -31
  14. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/main.py +13 -1
  15. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/network_share_manager.py +48 -4
  16. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/config_service.py +1 -3
  17. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/css/app.css +1 -1
  18. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/file-transfer-http.js +141 -22
  19. aird-0.4.25.dev1/aird/static/js/share/app.js +2421 -0
  20. aird-0.4.25.dev1/aird/static/js/share/src/add-files-modal.js +228 -0
  21. aird-0.4.25.dev1/aird/static/js/share/src/cloud.js +491 -0
  22. aird-0.4.25.dev1/aird/static/js/share/src/create-share.js +166 -0
  23. aird-0.4.25.dev1/aird/static/js/share/src/create-users.js +228 -0
  24. aird-0.4.25.dev1/aird/static/js/share/src/expiry.js +72 -0
  25. aird-0.4.25.dev1/aird/static/js/share/src/file-icons.js +84 -0
  26. aird-0.4.25.dev1/aird/static/js/share/src/file-picker.js +233 -0
  27. aird-0.4.25.dev1/aird/static/js/share/src/init.js +279 -0
  28. aird-0.4.25.dev1/aird/static/js/share/src/main.js +3 -0
  29. aird-0.4.25.dev1/aird/static/js/share/src/management-templates.js +221 -0
  30. aird-0.4.25.dev1/aird/static/js/share/src/management.js +360 -0
  31. aird-0.4.25.dev1/aird/static/js/share/src/selection.js +118 -0
  32. aird-0.4.25.dev1/aird/static/js/share/src/share-popup.js +109 -0
  33. aird-0.4.25.dev1/aird/static/js/share/src/shares-list.js +197 -0
  34. aird-0.4.25.dev1/aird/static/js/share/src/state.js +58 -0
  35. aird-0.4.25.dev1/aird/static/js/share/src/utils.js +26 -0
  36. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin.html +8 -0
  37. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin_network_shares.html +15 -3
  38. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/browse.html +2 -0
  39. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/share.html +3 -3
  40. {aird-0.4.23.dev22 → aird-0.4.25.dev1/aird.egg-info}/PKG-INFO +5 -4
  41. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird.egg-info/SOURCES.txt +22 -0
  42. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird.egg-info/requires.txt +4 -5
  43. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/setup.py +6 -4
  44. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_admin_handlers.py +2 -2
  45. aird-0.4.25.dev1/tests/test_compression.py +66 -0
  46. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_network_shares.py +76 -3
  47. aird-0.4.25.dev1/tests/test_transfer_rate_limit.py +35 -0
  48. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_view_handlers.py +29 -25
  49. aird-0.4.23.dev22/aird/event_loop.py +0 -34
  50. aird-0.4.23.dev22/aird/static/js/share/app.js +0 -2606
  51. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/LICENSE +0 -0
  52. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/MANIFEST.in +0 -0
  53. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/README.md +0 -0
  54. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/__init__.py +0 -0
  55. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/__main__.py +0 -0
  56. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/app_context.py +0 -0
  57. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/cli/__init__.py +0 -0
  58. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/cli/__main__.py +0 -0
  59. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/cli/authelia.py +0 -0
  60. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/cli/config.py +0 -0
  61. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/cli/main.py +0 -0
  62. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/cloud/__init__.py +0 -0
  63. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/config.py +0 -0
  64. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/constants/file_ops.py +0 -0
  65. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/constants/input_limits.py +0 -0
  66. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/constants/media.py +0 -0
  67. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/__init__.py +0 -0
  68. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/events.py +0 -0
  69. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/file_operations.py +0 -0
  70. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/filter_expression.py +0 -0
  71. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/folder_size.py +0 -0
  72. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/http_range.py +0 -0
  73. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/input_validation.py +0 -0
  74. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/mmap_handler.py +0 -0
  75. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/secret_storage.py +0 -0
  76. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/security.py +0 -0
  77. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/share_root.py +0 -0
  78. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/webauthn_config.py +0 -0
  79. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/websocket_manager.py +0 -0
  80. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/core/zip_download.py +0 -0
  81. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/database/__init__.py +0 -0
  82. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/database/db.py +0 -0
  83. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/database/feature_flags.py +0 -0
  84. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/database/ldap.py +0 -0
  85. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/__init__.py +0 -0
  86. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/audit.py +0 -0
  87. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/config.py +0 -0
  88. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/favorites.py +0 -0
  89. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/network_shares.py +0 -0
  90. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/policies.py +0 -0
  91. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/policy_decisions.py +0 -0
  92. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/policy_seeds.py +0 -0
  93. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/quota.py +0 -0
  94. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/ranged_uploads.py +0 -0
  95. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/resource_tags.py +0 -0
  96. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/schema.py +0 -0
  97. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/shares.py +0 -0
  98. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/user_attributes.py +0 -0
  99. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/users.py +0 -0
  100. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/db/webauthn.py +0 -0
  101. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/domain/__init__.py +0 -0
  102. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/domain/contracts.py +0 -0
  103. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/domain/models.py +0 -0
  104. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/email/__init__.py +0 -0
  105. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/email/brevo.py +0 -0
  106. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/email/resolve.py +0 -0
  107. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/__init__.py +0 -0
  108. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/abac_handlers.py +0 -0
  109. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/api_handlers.py +0 -0
  110. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/auth_handlers.py +0 -0
  111. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/base_handler.py +0 -0
  112. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/constants.py +0 -0
  113. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/health_handler.py +0 -0
  114. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/p2p_handlers.py +0 -0
  115. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/share_handlers.py +0 -0
  116. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/transfer_ws_handlers.py +0 -0
  117. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/handlers/webauthn_handlers.py +0 -0
  118. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/server_runtime.py +0 -0
  119. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/__init__.py +0 -0
  120. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/audit_service.py +0 -0
  121. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/email_service.py +0 -0
  122. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/email_subscriber.py +0 -0
  123. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/event_subscribers.py +0 -0
  124. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/favorites_service.py +0 -0
  125. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/network_share_service.py +0 -0
  126. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/p2p_service.py +0 -0
  127. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/policy_service.py +0 -0
  128. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/quota_service.py +0 -0
  129. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/share_service.py +0 -0
  130. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/tag_service.py +0 -0
  131. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/services/user_service.py +0 -0
  132. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/sql_identifiers.py +0 -0
  133. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/favicon.png +0 -0
  134. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/favicon.svg +0 -0
  135. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/img/logo-icon.png +0 -0
  136. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/img/logo-mark.svg +0 -0
  137. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/img/logo-text.png +0 -0
  138. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/img/logo.png +0 -0
  139. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/aird-core.js +0 -0
  140. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/bg-canvas.js +0 -0
  141. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/browse/app.js +0 -0
  142. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/common/command-palette.js +0 -0
  143. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/components/folder-picker.js +0 -0
  144. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/download-manager.js +0 -0
  145. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/feature-flags-live.js +0 -0
  146. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/file-transfer-ws.js +0 -0
  147. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/folder-size-scan.js +0 -0
  148. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/login-ui.js +0 -0
  149. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/media-view.js +0 -0
  150. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/p2p/app.js +0 -0
  151. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/p2p/mediator.js +0 -0
  152. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/p2p/qr-adapter.js +0 -0
  153. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/p2p/signaling-service.js +0 -0
  154. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/p2p/state-machine.js +0 -0
  155. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/p2p/transfer-service.js +0 -0
  156. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/pages/p2p-page.js +0 -0
  157. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/pages/super-search.js +0 -0
  158. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/theme.js +0 -0
  159. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/transfer-tracker.js +0 -0
  160. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/vendor/pdf.min.js +0 -0
  161. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/vendor/pdf.worker.min.js +0 -0
  162. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/vendor/qrcode-browser.js +0 -0
  163. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/static/js/webauthn.js +0 -0
  164. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/_admin_tabs.html +0 -0
  165. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/_app_nav_header.html +0 -0
  166. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/_bg_canvas.html +0 -0
  167. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/_theme_early.html +0 -0
  168. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/_theme_login_corner.html +0 -0
  169. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin_audit.html +0 -0
  170. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin_ldap.html +0 -0
  171. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin_login.html +0 -0
  172. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin_policies.html +0 -0
  173. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin_tags.html +0 -0
  174. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin_user_attributes.html +0 -0
  175. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/admin_users.html +0 -0
  176. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/directory.html +0 -0
  177. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/edit.html +0 -0
  178. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/error.html +0 -0
  179. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/file.html +0 -0
  180. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/ldap_config_create.html +0 -0
  181. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/ldap_config_edit.html +0 -0
  182. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/login.html +0 -0
  183. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/media_view.html +0 -0
  184. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/p2p_transfer.html +0 -0
  185. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/profile.html +0 -0
  186. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/shared_list.html +0 -0
  187. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/super_search.html +0 -0
  188. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/tagged_files.html +0 -0
  189. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/token_verification.html +0 -0
  190. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/user_create.html +0 -0
  191. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/templates/user_edit.html +0 -0
  192. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/utils/__init__.py +0 -0
  193. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird/utils/util.py +0 -0
  194. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird.egg-info/dependency_links.txt +0 -0
  195. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird.egg-info/entry_points.txt +0 -0
  196. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/aird.egg-info/top_level.txt +0 -0
  197. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/setup.cfg +0 -0
  198. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/__init__.py +0 -0
  199. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/conftest.py +0 -0
  200. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/handler_helpers.py +0 -0
  201. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_api_handlers.py +0 -0
  202. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_architecture_conformance.py +0 -0
  203. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_auth_handlers.py +0 -0
  204. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_auth_handlers_extended.py +0 -0
  205. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_base_handler.py +0 -0
  206. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_base_handler_pep.py +0 -0
  207. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_cli.py +0 -0
  208. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_cloud.py +0 -0
  209. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_config.py +0 -0
  210. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_core_file_operations.py +0 -0
  211. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_database_db.py +0 -0
  212. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_database_feature_flags.py +0 -0
  213. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_database_ldap.py +0 -0
  214. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_database_shares.py +0 -0
  215. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_database_users.py +0 -0
  216. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_database_users_hashing.py +0 -0
  217. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_db.py +0 -0
  218. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_email_service.py +0 -0
  219. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_file_op_handlers.py +0 -0
  220. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_filter_expression.py +0 -0
  221. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_folder_size.py +0 -0
  222. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_http_range.py +0 -0
  223. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_main.py +0 -0
  224. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_mmap_handler.py +0 -0
  225. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_multi_user.py +0 -0
  226. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_p2p_handlers.py +0 -0
  227. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_password_hashing.py +0 -0
  228. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_policy_service.py +0 -0
  229. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_rate_limit.py +0 -0
  230. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_secret_storage.py +0 -0
  231. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_security.py +0 -0
  232. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_security_comprehensive.py +0 -0
  233. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_server_runtime.py +0 -0
  234. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_share_handlers.py +0 -0
  235. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_share_ownership.py +0 -0
  236. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_super_search_handler.py +0 -0
  237. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_tag_service.py +0 -0
  238. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_transfer_ws_handlers.py +0 -0
  239. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_util.py +0 -0
  240. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_webauthn_handlers.py +0 -0
  241. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_websocket_manager.py +0 -0
  242. {aird-0.4.23.dev22 → aird-0.4.25.dev1}/tests/test_wheel_static_assets.py +0 -0
  243. {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.23.dev22
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 at startup)
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
- # Files below this use single POST /upload; at or above use Content-Range API
70
- LARGE_FILE_THRESHOLD_BYTES = 500 * 1024 * 1024
71
- RANGE_CHUNK_BYTES = 16 * 1024 * 1024
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