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.
Files changed (209) hide show
  1. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/MANIFEST.in +1 -0
  2. {aird-0.4.23.dev11/aird.egg-info → aird-0.4.23.dev17}/PKG-INFO +1 -1
  3. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/cli/session.py +10 -71
  4. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/config.py +30 -4
  5. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/constants/__init__.py +45 -5
  6. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/events.py +2 -0
  7. aird-0.4.23.dev17/aird/core/folder_size.py +101 -0
  8. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/mmap_handler.py +67 -26
  9. aird-0.4.23.dev17/aird/core/zip_download.py +116 -0
  10. aird-0.4.23.dev17/aird/email/__init__.py +6 -0
  11. aird-0.4.23.dev17/aird/email/brevo.py +83 -0
  12. aird-0.4.23.dev17/aird/email/resolve.py +36 -0
  13. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/api_handlers.py +64 -5
  14. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/base_handler.py +1 -1
  15. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/file_op_handlers.py +140 -466
  16. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/share_handlers.py +6 -2
  17. aird-0.4.23.dev17/aird/handlers/transfer_ws_handlers.py +382 -0
  18. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/view_handlers.py +16 -5
  19. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/main.py +14 -0
  20. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/__init__.py +4 -0
  21. aird-0.4.23.dev17/aird/services/email_service.py +97 -0
  22. aird-0.4.23.dev17/aird/services/email_subscriber.py +52 -0
  23. aird-0.4.23.dev17/aird/static/css/app.css +2 -0
  24. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/browse/app.js +448 -401
  25. aird-0.4.23.dev17/aird/static/js/download-manager.js +151 -0
  26. aird-0.4.23.dev17/aird/static/js/feature-flags-live.js +44 -0
  27. aird-0.4.23.dev17/aird/static/js/file-transfer-ws.js +260 -0
  28. aird-0.4.23.dev17/aird/static/js/folder-size-scan.js +188 -0
  29. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/share/app.js +46 -12
  30. aird-0.4.23.dev17/aird/static/js/transfer-tracker.js +315 -0
  31. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/_app_nav_header.html +32 -3
  32. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/_bg_canvas.html +1 -1
  33. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin.html +1 -1
  34. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin_audit.html +4 -4
  35. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin_ldap.html +3 -3
  36. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin_login.html +4 -4
  37. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin_network_shares.html +3 -3
  38. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin_policies.html +4 -4
  39. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin_tags.html +4 -4
  40. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin_user_attributes.html +4 -4
  41. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/admin_users.html +3 -3
  42. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/browse.html +40 -59
  43. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/directory.html +3 -3
  44. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/edit.html +4 -4
  45. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/error.html +3 -3
  46. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/file.html +4 -4
  47. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/ldap_config_create.html +97 -97
  48. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/ldap_config_edit.html +99 -99
  49. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/login.html +4 -4
  50. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/media_view.html +3 -3
  51. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/p2p_transfer.html +11 -11
  52. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/profile.html +3 -3
  53. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/share.html +54 -39
  54. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/shared_list.html +20 -7
  55. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/super_search.html +3 -3
  56. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/tagged_files.html +33 -9
  57. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/token_verification.html +3 -3
  58. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/user_create.html +75 -75
  59. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/user_edit.html +116 -116
  60. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/utils/util.py +26 -5
  61. {aird-0.4.23.dev11 → aird-0.4.23.dev17/aird.egg-info}/PKG-INFO +1 -1
  62. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird.egg-info/SOURCES.txt +17 -1
  63. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/setup.py +1 -1
  64. aird-0.4.23.dev17/tests/test_email_service.py +76 -0
  65. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_file_op_handlers.py +7 -183
  66. aird-0.4.23.dev17/tests/test_folder_size.py +45 -0
  67. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_mmap_handler.py +42 -4
  68. aird-0.4.23.dev17/tests/test_transfer_ws_handlers.py +101 -0
  69. aird-0.4.23.dev17/tests/test_wheel_static_assets.py +112 -0
  70. aird-0.4.23.dev17/tests/test_zip_download.py +43 -0
  71. aird-0.4.23.dev11/aird/static/css/app.css +0 -2
  72. aird-0.4.23.dev11/aird/static/js/feature-flags-live.js +0 -43
  73. aird-0.4.23.dev11/tests/test_wheel_static_assets.py +0 -48
  74. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/LICENSE +0 -0
  75. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/README.md +0 -0
  76. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/__init__.py +0 -0
  77. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/__main__.py +0 -0
  78. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/app_context.py +0 -0
  79. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/cli/__init__.py +0 -0
  80. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/cli/__main__.py +0 -0
  81. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/cli/authelia.py +0 -0
  82. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/cli/config.py +0 -0
  83. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/cli/main.py +0 -0
  84. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/cloud/__init__.py +0 -0
  85. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/constants/admin.py +0 -0
  86. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/constants/file_ops.py +0 -0
  87. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/constants/input_limits.py +0 -0
  88. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/constants/media.py +0 -0
  89. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/__init__.py +0 -0
  90. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/file_operations.py +0 -0
  91. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/filter_expression.py +0 -0
  92. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/input_validation.py +0 -0
  93. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/security.py +0 -0
  94. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/share_root.py +0 -0
  95. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/core/websocket_manager.py +0 -0
  96. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/database/__init__.py +0 -0
  97. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/database/db.py +0 -0
  98. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/database/feature_flags.py +0 -0
  99. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/database/ldap.py +0 -0
  100. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/__init__.py +0 -0
  101. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/audit.py +0 -0
  102. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/config.py +0 -0
  103. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/favorites.py +0 -0
  104. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/network_shares.py +0 -0
  105. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/policies.py +0 -0
  106. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/policy_decisions.py +0 -0
  107. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/policy_seeds.py +0 -0
  108. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/quota.py +0 -0
  109. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/resource_tags.py +0 -0
  110. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/schema.py +0 -0
  111. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/shares.py +0 -0
  112. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/user_attributes.py +0 -0
  113. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/db/users.py +0 -0
  114. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/domain/__init__.py +0 -0
  115. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/domain/contracts.py +0 -0
  116. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/domain/models.py +0 -0
  117. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/__init__.py +0 -0
  118. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/abac_handlers.py +0 -0
  119. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/admin_handlers.py +0 -0
  120. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/auth_handlers.py +0 -0
  121. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/constants.py +0 -0
  122. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/health_handler.py +0 -0
  123. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/handlers/p2p_handlers.py +0 -0
  124. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/network_share_manager.py +0 -0
  125. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/server_runtime.py +0 -0
  126. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/audit_service.py +0 -0
  127. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/config_service.py +0 -0
  128. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/event_subscribers.py +0 -0
  129. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/favorites_service.py +0 -0
  130. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/network_share_service.py +0 -0
  131. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/p2p_service.py +0 -0
  132. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/policy_service.py +0 -0
  133. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/quota_service.py +0 -0
  134. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/share_service.py +0 -0
  135. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/tag_service.py +0 -0
  136. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/services/user_service.py +0 -0
  137. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/sql_identifiers.py +0 -0
  138. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/favicon.png +0 -0
  139. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/favicon.svg +0 -0
  140. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/img/logo-icon.png +0 -0
  141. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/img/logo-mark.svg +0 -0
  142. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/img/logo-text.png +0 -0
  143. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/img/logo.png +0 -0
  144. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/aird-core.js +0 -0
  145. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/bg-canvas.js +0 -0
  146. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/common/command-palette.js +0 -0
  147. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/components/folder-picker.js +0 -0
  148. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/login-ui.js +0 -0
  149. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/media-view.js +0 -0
  150. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/p2p/app.js +0 -0
  151. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/p2p/mediator.js +0 -0
  152. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/p2p/qr-adapter.js +0 -0
  153. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/p2p/signaling-service.js +0 -0
  154. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/p2p/state-machine.js +0 -0
  155. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/p2p/transfer-service.js +0 -0
  156. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/pages/p2p-page.js +0 -0
  157. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/pages/super-search.js +0 -0
  158. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/theme.js +0 -0
  159. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/vendor/pdf.min.js +0 -0
  160. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/vendor/pdf.worker.min.js +0 -0
  161. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/static/js/vendor/qrcode-browser.js +0 -0
  162. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/_admin_tabs.html +0 -0
  163. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/_theme_early.html +0 -0
  164. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/templates/_theme_login_corner.html +0 -0
  165. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird/utils/__init__.py +0 -0
  166. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird.egg-info/dependency_links.txt +0 -0
  167. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird.egg-info/entry_points.txt +0 -0
  168. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird.egg-info/requires.txt +0 -0
  169. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/aird.egg-info/top_level.txt +0 -0
  170. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/setup.cfg +0 -0
  171. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/__init__.py +0 -0
  172. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/conftest.py +0 -0
  173. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/handler_helpers.py +0 -0
  174. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_admin_handlers.py +0 -0
  175. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_api_handlers.py +0 -0
  176. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_architecture_conformance.py +0 -0
  177. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_auth_handlers.py +0 -0
  178. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_auth_handlers_extended.py +0 -0
  179. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_base_handler.py +0 -0
  180. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_base_handler_pep.py +0 -0
  181. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_cli.py +0 -0
  182. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_cloud.py +0 -0
  183. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_config.py +0 -0
  184. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_core_file_operations.py +0 -0
  185. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_database_db.py +0 -0
  186. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_database_feature_flags.py +0 -0
  187. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_database_ldap.py +0 -0
  188. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_database_shares.py +0 -0
  189. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_database_users.py +0 -0
  190. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_database_users_hashing.py +0 -0
  191. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_db.py +0 -0
  192. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_filter_expression.py +0 -0
  193. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_main.py +0 -0
  194. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_multi_user.py +0 -0
  195. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_network_shares.py +0 -0
  196. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_p2p_handlers.py +0 -0
  197. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_password_hashing.py +0 -0
  198. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_policy_service.py +0 -0
  199. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_rate_limit.py +0 -0
  200. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_security.py +0 -0
  201. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_security_comprehensive.py +0 -0
  202. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_server_runtime.py +0 -0
  203. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_share_handlers.py +0 -0
  204. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_share_ownership.py +0 -0
  205. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_super_search_handler.py +0 -0
  206. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_tag_service.py +0 -0
  207. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_util.py +0 -0
  208. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_view_handlers.py +0 -0
  209. {aird-0.4.23.dev11 → aird-0.4.23.dev17}/tests/test_websocket_manager.py +0 -0
@@ -4,3 +4,4 @@ recursive-include aird/static/js *.js
4
4
  recursive-include aird/static/img *
5
5
  include aird/static/favicon.png
6
6
  include aird/static/favicon.ico
7
+ include aird/static/favicon.svg
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aird
3
- Version: 0.4.23.dev11
3
+ Version: 0.4.23.dev17
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
@@ -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 _upload_chunk(
284
- self,
285
- upload_id: str,
286
- remote_dir: str,
287
- filename: str,
288
- data: bytes,
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=params,
305
- data=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=300,
294
+ timeout=600,
311
295
  )
312
296
  if r.status_code >= 400:
313
297
  raise AirdAPIError(
314
- f"Upload chunk failed for {filename} (HTTP {r.status_code})",
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
- UPLOAD_MAX_PARALLEL_CHUNKS = _UPLOAD_MAX_PARALLEL_CHUNKS
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
@@ -46,6 +46,8 @@ class ShareCreatedEvent:
46
46
  creator: str
47
47
  path_count: int
48
48
  created_at: float
49
+ allowed_users: tuple[str, ...] = ()
50
+ modify_users: tuple[str, ...] = ()
49
51
 
50
52
 
51
53
  @dataclass(frozen=True)
@@ -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
- def _read_chunks_mmap(
29
- file_path: str, start: int, end: int | None, file_size: int, chunk_size: int
30
- ) -> list[bytes]:
31
- """Read file chunks using mmap."""
32
- chunks = []
33
- with open(file_path, "rb") as f:
34
- with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
35
- actual_end = min(end or file_size - 1, file_size - 1)
36
- current = start
37
- while current <= actual_end:
38
- chunk_end = min(current + chunk_size, actual_end + 1)
39
- chunks.append(mm[current:chunk_end])
40
- current = chunk_end
41
- return chunks
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 using mmap for efficient memory usage"""
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
- # Use mmap for large files (run in thread pool - mmap requires sync fd)
74
- chunks = await asyncio.to_thread(
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
- for chunk in chunks:
78
- yield chunk
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
- chunks = await asyncio.to_thread(
84
- _read_chunks_sync, file_path, start, end, file_size, chunk_size
117
+ reader = _SyncChunkReader(
118
+ file_path, start, end, file_size, chunk_size, use_mmap=False
85
119
  )
86
- for chunk in chunks:
87
- yield chunk
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
@@ -0,0 +1,6 @@
1
+ """Outbound email (Brevo transactional API)."""
2
+
3
+ from aird.email.brevo import BrevoClient
4
+ from aird.email.resolve import resolve_user_email
5
+
6
+ __all__ = ["BrevoClient", "resolve_user_email"]