aird 0.4.22__tar.gz → 0.4.23.dev2__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 (182) hide show
  1. {aird-0.4.22 → aird-0.4.23.dev2}/PKG-INFO +1 -1
  2. {aird-0.4.22 → aird-0.4.23.dev2}/aird/config.py +6 -2
  3. {aird-0.4.22 → aird-0.4.23.dev2}/aird/constants/__init__.py +4 -2
  4. {aird-0.4.22 → aird-0.4.23.dev2}/aird/constants/file_ops.py +5 -0
  5. {aird-0.4.22 → aird-0.4.23.dev2}/aird/handlers/admin_handlers.py +1 -3
  6. {aird-0.4.22 → aird-0.4.23.dev2}/aird/handlers/file_op_handlers.py +328 -17
  7. {aird-0.4.22 → aird-0.4.23.dev2}/aird/handlers/share_handlers.py +21 -5
  8. {aird-0.4.22 → aird-0.4.23.dev2}/aird/handlers/view_handlers.py +2 -0
  9. {aird-0.4.22 → aird-0.4.23.dev2}/aird/main.py +6 -6
  10. aird-0.4.23.dev2/aird/static/css/app.css +2 -0
  11. aird-0.4.23.dev2/aird/static/favicon.png +0 -0
  12. aird-0.4.23.dev2/aird/static/favicon.svg +12 -0
  13. aird-0.4.23.dev2/aird/static/img/logo-icon.png +0 -0
  14. aird-0.4.23.dev2/aird/static/img/logo-mark.svg +15 -0
  15. aird-0.4.23.dev2/aird/static/img/logo-text.png +0 -0
  16. aird-0.4.23.dev2/aird/static/img/logo.png +0 -0
  17. aird-0.4.23.dev2/aird/static/js/aird-core.js +227 -0
  18. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/bg-canvas.js +1 -0
  19. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/browse/app.js +641 -319
  20. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/common/command-palette.js +11 -3
  21. aird-0.4.23.dev2/aird/static/js/components/folder-picker.js +163 -0
  22. aird-0.4.23.dev2/aird/static/js/feature-flags-live.js +43 -0
  23. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/login-ui.js +6 -4
  24. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/pages/p2p-page.js +22 -18
  25. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/pages/super-search.js +2 -1
  26. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/share/app.js +162 -169
  27. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/theme.js +34 -1
  28. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/_app_nav_header.html +4 -3
  29. aird-0.4.23.dev2/aird/templates/_theme_early.html +29 -0
  30. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/admin.html +6 -5
  31. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/admin_audit.html +12 -16
  32. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/admin_ldap.html +4 -3
  33. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/admin_login.html +9 -3
  34. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/admin_network_shares.html +4 -3
  35. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/admin_policies.html +7 -5
  36. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/admin_tags.html +9 -6
  37. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/admin_user_attributes.html +6 -4
  38. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/admin_users.html +4 -3
  39. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/browse.html +170 -152
  40. aird-0.4.23.dev2/aird/templates/directory.html +46 -0
  41. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/edit.html +4 -3
  42. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/error.html +4 -3
  43. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/file.html +15 -11
  44. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/ldap_config_create.html +3 -2
  45. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/ldap_config_edit.html +3 -2
  46. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/login.html +9 -3
  47. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/p2p_transfer.html +4 -4
  48. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/profile.html +4 -3
  49. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/share.html +10 -10
  50. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/shared_list.html +110 -6
  51. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/super_search.html +8 -7
  52. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/tagged_files.html +5 -4
  53. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/token_verification.html +4 -3
  54. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/user_create.html +3 -2
  55. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/user_edit.html +3 -2
  56. {aird-0.4.22 → aird-0.4.23.dev2}/aird.egg-info/PKG-INFO +1 -1
  57. {aird-0.4.22 → aird-0.4.23.dev2}/aird.egg-info/SOURCES.txt +9 -3
  58. {aird-0.4.22 → aird-0.4.23.dev2}/setup.py +4 -1
  59. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_admin_handlers.py +3 -3
  60. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_file_op_handlers.py +155 -0
  61. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_main.py +2 -2
  62. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_share_handlers.py +59 -0
  63. aird-0.4.22/aird/static/css/app.css +0 -2
  64. aird-0.4.22/aird/static/js/aird-core.js +0 -107
  65. aird-0.4.22/aird/static/js/common/ui-utils.js +0 -40
  66. aird-0.4.22/aird/static/js/vendor/marked.umd.min.js +0 -60
  67. aird-0.4.22/aird/static/js/vendor/purify.min.js +0 -3
  68. aird-0.4.22/aird/templates/directory.html +0 -33
  69. {aird-0.4.22 → aird-0.4.23.dev2}/LICENSE +0 -0
  70. {aird-0.4.22 → aird-0.4.23.dev2}/README.md +0 -0
  71. {aird-0.4.22 → aird-0.4.23.dev2}/aird/__init__.py +0 -0
  72. {aird-0.4.22 → aird-0.4.23.dev2}/aird/__main__.py +0 -0
  73. {aird-0.4.22 → aird-0.4.23.dev2}/aird/app_context.py +0 -0
  74. {aird-0.4.22 → aird-0.4.23.dev2}/aird/cloud/__init__.py +0 -0
  75. {aird-0.4.22 → aird-0.4.23.dev2}/aird/constants/admin.py +0 -0
  76. {aird-0.4.22 → aird-0.4.23.dev2}/aird/constants/input_limits.py +0 -0
  77. {aird-0.4.22 → aird-0.4.23.dev2}/aird/constants/media.py +0 -0
  78. {aird-0.4.22 → aird-0.4.23.dev2}/aird/core/__init__.py +0 -0
  79. {aird-0.4.22 → aird-0.4.23.dev2}/aird/core/events.py +0 -0
  80. {aird-0.4.22 → aird-0.4.23.dev2}/aird/core/file_operations.py +0 -0
  81. {aird-0.4.22 → aird-0.4.23.dev2}/aird/core/filter_expression.py +0 -0
  82. {aird-0.4.22 → aird-0.4.23.dev2}/aird/core/input_validation.py +0 -0
  83. {aird-0.4.22 → aird-0.4.23.dev2}/aird/core/mmap_handler.py +0 -0
  84. {aird-0.4.22 → aird-0.4.23.dev2}/aird/core/security.py +0 -0
  85. {aird-0.4.22 → aird-0.4.23.dev2}/aird/core/share_root.py +0 -0
  86. {aird-0.4.22 → aird-0.4.23.dev2}/aird/core/websocket_manager.py +0 -0
  87. {aird-0.4.22 → aird-0.4.23.dev2}/aird/database/__init__.py +0 -0
  88. {aird-0.4.22 → aird-0.4.23.dev2}/aird/database/db.py +0 -0
  89. {aird-0.4.22 → aird-0.4.23.dev2}/aird/database/feature_flags.py +0 -0
  90. {aird-0.4.22 → aird-0.4.23.dev2}/aird/database/ldap.py +0 -0
  91. {aird-0.4.22 → aird-0.4.23.dev2}/aird/db/__init__.py +0 -0
  92. {aird-0.4.22 → aird-0.4.23.dev2}/aird/db/audit.py +0 -0
  93. {aird-0.4.22 → aird-0.4.23.dev2}/aird/db/config.py +0 -0
  94. {aird-0.4.22 → aird-0.4.23.dev2}/aird/db/favorites.py +0 -0
  95. {aird-0.4.22 → aird-0.4.23.dev2}/aird/db/network_shares.py +0 -0
  96. {aird-0.4.22 → aird-0.4.23.dev2}/aird/db/policies.py +0 -0
  97. {aird-0.4.22 → aird-0.4.23.dev2}/aird/db/policy_decisions.py +0 -0
  98. {aird-0.4.22 → aird-0.4.23.dev2}/aird/db/policy_seeds.py +0 -0
  99. {aird-0.4.22 → aird-0.4.23.dev2}/aird/db/quota.py +0 -0
  100. {aird-0.4.22 → aird-0.4.23.dev2}/aird/db/resource_tags.py +0 -0
  101. {aird-0.4.22 → aird-0.4.23.dev2}/aird/db/schema.py +0 -0
  102. {aird-0.4.22 → aird-0.4.23.dev2}/aird/db/shares.py +0 -0
  103. {aird-0.4.22 → aird-0.4.23.dev2}/aird/db/user_attributes.py +0 -0
  104. {aird-0.4.22 → aird-0.4.23.dev2}/aird/db/users.py +0 -0
  105. {aird-0.4.22 → aird-0.4.23.dev2}/aird/domain/__init__.py +0 -0
  106. {aird-0.4.22 → aird-0.4.23.dev2}/aird/domain/contracts.py +0 -0
  107. {aird-0.4.22 → aird-0.4.23.dev2}/aird/domain/models.py +0 -0
  108. {aird-0.4.22 → aird-0.4.23.dev2}/aird/handlers/__init__.py +0 -0
  109. {aird-0.4.22 → aird-0.4.23.dev2}/aird/handlers/abac_handlers.py +0 -0
  110. {aird-0.4.22 → aird-0.4.23.dev2}/aird/handlers/api_handlers.py +0 -0
  111. {aird-0.4.22 → aird-0.4.23.dev2}/aird/handlers/auth_handlers.py +0 -0
  112. {aird-0.4.22 → aird-0.4.23.dev2}/aird/handlers/base_handler.py +0 -0
  113. {aird-0.4.22 → aird-0.4.23.dev2}/aird/handlers/constants.py +0 -0
  114. {aird-0.4.22 → aird-0.4.23.dev2}/aird/handlers/health_handler.py +0 -0
  115. {aird-0.4.22 → aird-0.4.23.dev2}/aird/handlers/p2p_handlers.py +0 -0
  116. {aird-0.4.22 → aird-0.4.23.dev2}/aird/network_share_manager.py +0 -0
  117. {aird-0.4.22 → aird-0.4.23.dev2}/aird/services/__init__.py +0 -0
  118. {aird-0.4.22 → aird-0.4.23.dev2}/aird/services/audit_service.py +0 -0
  119. {aird-0.4.22 → aird-0.4.23.dev2}/aird/services/config_service.py +0 -0
  120. {aird-0.4.22 → aird-0.4.23.dev2}/aird/services/event_subscribers.py +0 -0
  121. {aird-0.4.22 → aird-0.4.23.dev2}/aird/services/favorites_service.py +0 -0
  122. {aird-0.4.22 → aird-0.4.23.dev2}/aird/services/network_share_service.py +0 -0
  123. {aird-0.4.22 → aird-0.4.23.dev2}/aird/services/p2p_service.py +0 -0
  124. {aird-0.4.22 → aird-0.4.23.dev2}/aird/services/policy_service.py +0 -0
  125. {aird-0.4.22 → aird-0.4.23.dev2}/aird/services/quota_service.py +0 -0
  126. {aird-0.4.22 → aird-0.4.23.dev2}/aird/services/share_service.py +0 -0
  127. {aird-0.4.22 → aird-0.4.23.dev2}/aird/services/tag_service.py +0 -0
  128. {aird-0.4.22 → aird-0.4.23.dev2}/aird/services/user_service.py +0 -0
  129. {aird-0.4.22 → aird-0.4.23.dev2}/aird/sql_identifiers.py +0 -0
  130. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/p2p/app.js +0 -0
  131. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/p2p/mediator.js +0 -0
  132. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/p2p/qr-adapter.js +0 -0
  133. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/p2p/signaling-service.js +0 -0
  134. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/p2p/state-machine.js +0 -0
  135. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/p2p/transfer-service.js +0 -0
  136. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/vendor/pdf.min.js +0 -0
  137. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/vendor/pdf.worker.min.js +0 -0
  138. {aird-0.4.22 → aird-0.4.23.dev2}/aird/static/js/vendor/qrcode-browser.js +0 -0
  139. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/_admin_tabs.html +0 -0
  140. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/_bg_canvas.html +0 -0
  141. {aird-0.4.22 → aird-0.4.23.dev2}/aird/templates/_theme_login_corner.html +0 -0
  142. {aird-0.4.22 → aird-0.4.23.dev2}/aird/utils/__init__.py +0 -0
  143. {aird-0.4.22 → aird-0.4.23.dev2}/aird/utils/util.py +0 -0
  144. {aird-0.4.22 → aird-0.4.23.dev2}/aird.egg-info/dependency_links.txt +0 -0
  145. {aird-0.4.22 → aird-0.4.23.dev2}/aird.egg-info/entry_points.txt +0 -0
  146. {aird-0.4.22 → aird-0.4.23.dev2}/aird.egg-info/requires.txt +0 -0
  147. {aird-0.4.22 → aird-0.4.23.dev2}/aird.egg-info/top_level.txt +0 -0
  148. {aird-0.4.22 → aird-0.4.23.dev2}/setup.cfg +0 -0
  149. {aird-0.4.22 → aird-0.4.23.dev2}/tests/__init__.py +0 -0
  150. {aird-0.4.22 → aird-0.4.23.dev2}/tests/conftest.py +0 -0
  151. {aird-0.4.22 → aird-0.4.23.dev2}/tests/handler_helpers.py +0 -0
  152. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_api_handlers.py +0 -0
  153. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_architecture_conformance.py +0 -0
  154. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_auth_handlers.py +0 -0
  155. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_auth_handlers_extended.py +0 -0
  156. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_base_handler.py +0 -0
  157. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_base_handler_pep.py +0 -0
  158. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_cloud.py +0 -0
  159. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_config.py +0 -0
  160. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_core_file_operations.py +0 -0
  161. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_database_db.py +0 -0
  162. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_database_feature_flags.py +0 -0
  163. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_database_ldap.py +0 -0
  164. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_database_shares.py +0 -0
  165. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_database_users.py +0 -0
  166. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_database_users_hashing.py +0 -0
  167. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_db.py +0 -0
  168. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_filter_expression.py +0 -0
  169. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_mmap_handler.py +0 -0
  170. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_multi_user.py +0 -0
  171. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_network_shares.py +0 -0
  172. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_p2p_handlers.py +0 -0
  173. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_password_hashing.py +0 -0
  174. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_policy_service.py +0 -0
  175. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_rate_limit.py +0 -0
  176. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_security.py +0 -0
  177. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_security_comprehensive.py +0 -0
  178. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_super_search_handler.py +0 -0
  179. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_tag_service.py +0 -0
  180. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_util.py +0 -0
  181. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_view_handlers.py +0 -0
  182. {aird-0.4.22 → aird-0.4.23.dev2}/tests/test_websocket_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aird
3
- Version: 0.4.22
3
+ Version: 0.4.23.dev2
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
@@ -16,7 +16,9 @@ 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
- MAX_UPLOAD_FILE_SIZE_HARD_LIMIT as _MAX_UPLOAD_FILE_SIZE_HARD_LIMIT,
19
+ UPLOAD_CHUNK_SIZE_BYTES as _UPLOAD_CHUNK_SIZE_BYTES,
20
+ UPLOAD_REQUEST_MAX_BODY_SIZE as _UPLOAD_REQUEST_MAX_BODY_SIZE,
21
+ UPLOAD_MAX_PARALLEL_CHUNKS as _UPLOAD_MAX_PARALLEL_CHUNKS,
20
22
  )
21
23
 
22
24
  # Module-level variables to hold configuration
@@ -46,7 +48,9 @@ MAX_READABLE_FILE_SIZE = _MAX_READABLE_FILE_SIZE
46
48
  ALLOWED_UPLOAD_EXTENSIONS = _ALLOWED_UPLOAD_EXTENSIONS
47
49
  MMAP_MIN_SIZE = _MMAP_MIN_SIZE
48
50
  CHUNK_SIZE = _CHUNK_SIZE
49
- MAX_UPLOAD_FILE_SIZE_HARD_LIMIT = _MAX_UPLOAD_FILE_SIZE_HARD_LIMIT
51
+ UPLOAD_CHUNK_SIZE_BYTES = _UPLOAD_CHUNK_SIZE_BYTES
52
+ UPLOAD_REQUEST_MAX_BODY_SIZE = _UPLOAD_REQUEST_MAX_BODY_SIZE
53
+ UPLOAD_MAX_PARALLEL_CHUNKS = _UPLOAD_MAX_PARALLEL_CHUNKS
50
54
 
51
55
 
52
56
  def _configure_google_drive(cloud_config: dict) -> None:
@@ -59,8 +59,10 @@ UPLOAD_CONFIG = {
59
59
  "allow_all_file_types": 0, # 0 = use whitelist below, 1 = allow any extension
60
60
  }
61
61
 
62
- # Hard ceiling for Tornado server admin cannot exceed this
63
- MAX_UPLOAD_FILE_SIZE_HARD_LIMIT = 10 * 1024 * 1024 * 1024 # 10 GB
62
+ # Per-request body limit (Cloudflare-compatible chunked uploads; admin sets total file cap separately)
63
+ UPLOAD_CHUNK_SIZE_BYTES = 90 * 1024 * 1024 # 90 MiB per HTTP request
64
+ UPLOAD_REQUEST_MAX_BODY_SIZE = UPLOAD_CHUNK_SIZE_BYTES + (1024 * 1024) # chunk + slack
65
+ UPLOAD_MAX_PARALLEL_CHUNKS = 5 # max concurrent chunk requests from the browser
64
66
 
65
67
  # File operation constants (derived from UPLOAD_CONFIG at startup)
66
68
  MAX_FILE_SIZE = UPLOAD_CONFIG["max_file_size_mb"] * 1024 * 1024
@@ -35,6 +35,11 @@ UNSUPPORTED_FILE_TYPE = (
35
35
  )
36
36
  UPLOAD_SAVE_FAILED = "Failed to save upload. Please try again."
37
37
  UPLOAD_SUCCESSFUL = "Upload successful"
38
+ MISSING_UPLOAD_CHUNK_HEADERS = "Missing chunked upload headers"
39
+ INVALID_UPLOAD_CHUNK_HEADERS = "Invalid chunked upload headers"
40
+ UPLOAD_CHUNK_OUT_OF_ORDER = "Upload chunk out of order"
41
+ UPLOAD_SESSION_NOT_FOUND = "Upload session not found or expired"
42
+ UPLOAD_CHUNK_RECEIVED = '{"status":"chunk_received"}'
38
43
 
39
44
  # CreateFolderHandler
40
45
  FOLDER_CREATE_DISABLED = (
@@ -202,9 +202,7 @@ class AdminHandler(BaseHandler):
202
202
 
203
203
  # Update upload configuration
204
204
  try:
205
- max_file_size_mb = max(
206
- 1, min(10240, int(self.get_argument("max_file_size_mb", "512")))
207
- )
205
+ max_file_size_mb = max(1, int(self.get_argument("max_file_size_mb", "512")))
208
206
  UPLOAD_CONFIG["max_file_size_mb"] = max_file_size_mb
209
207
  constants_module.MAX_FILE_SIZE = max_file_size_mb * 1024 * 1024
210
208
  UPLOAD_CONFIG["allow_all_file_types"] = (
@@ -5,6 +5,9 @@ import tempfile
5
5
  import json
6
6
  import logging
7
7
  import pathlib
8
+ import hashlib
9
+ import re
10
+ import secrets
8
11
  from collections import deque
9
12
  from urllib.parse import unquote
10
13
  import asyncio
@@ -81,6 +84,11 @@ from aird.constants.file_ops import (
81
84
  UPLOAD_SAVE_FAILED,
82
85
  UPLOAD_SUCCESSFUL,
83
86
  MISSING_UPLOAD_FILENAME_HEADER,
87
+ MISSING_UPLOAD_CHUNK_HEADERS,
88
+ INVALID_UPLOAD_CHUNK_HEADERS,
89
+ UPLOAD_CHUNK_OUT_OF_ORDER,
90
+ UPLOAD_SESSION_NOT_FOUND,
91
+ UPLOAD_CHUNK_RECEIVED,
84
92
  )
85
93
  from aird.config import (
86
94
  ALLOWED_UPLOAD_EXTENSIONS,
@@ -125,6 +133,182 @@ def _validate_upload_destination(upload_dir, filename, root_dir):
125
133
  return (final_path_abs, None)
126
134
 
127
135
 
136
+ _UPLOAD_ID_RE = re.compile(r"^[a-zA-Z0-9\-]{1,64}$")
137
+
138
+
139
+ def _upload_parts_dir() -> str:
140
+ path = os.path.join(tempfile.gettempdir(), "aird_upload_parts")
141
+ os.makedirs(path, exist_ok=True)
142
+ return path
143
+
144
+
145
+ def _upload_session_dir(username: str, upload_id: str) -> str:
146
+ user_hash = hashlib.sha256(username.encode("utf-8")).hexdigest()[:16]
147
+ return os.path.join(_upload_parts_dir(), f"{user_hash}_{upload_id}")
148
+
149
+
150
+ def _chunk_file_path(session_dir: str, offset: int) -> str:
151
+ return os.path.join(session_dir, f"{offset}.part")
152
+
153
+
154
+ def _iter_expected_chunks(total_size: int) -> list[tuple[int, int]]:
155
+ """Return (byte_offset, chunk_length) for each upload part."""
156
+ chunk_size = constants_module.UPLOAD_CHUNK_SIZE_BYTES
157
+ parts: list[tuple[int, int]] = []
158
+ offset = 0
159
+ while offset < total_size:
160
+ length = min(chunk_size, total_size - offset)
161
+ parts.append((offset, length))
162
+ offset += length
163
+ return parts
164
+
165
+
166
+ def _upload_chunks_complete(session_dir: str, total_size: int) -> bool:
167
+ for offset, expected_len in _iter_expected_chunks(total_size):
168
+ path = _chunk_file_path(session_dir, offset)
169
+ if not os.path.isfile(path):
170
+ return False
171
+ try:
172
+ if os.path.getsize(path) != expected_len:
173
+ return False
174
+ except OSError:
175
+ return False
176
+ return True
177
+
178
+
179
+ def _stitch_upload_session(session_dir: str, dest_path: str, total_size: int) -> None:
180
+ """Concatenate per-offset chunk files into one file."""
181
+ with open(dest_path, "wb") as out:
182
+ for offset, _length in _iter_expected_chunks(total_size):
183
+ part_path = _chunk_file_path(session_dir, offset)
184
+ with open(part_path, "rb") as part_file:
185
+ shutil.copyfileobj(part_file, out)
186
+
187
+
188
+ def _stitch_lock_path(session_dir: str) -> str:
189
+ return os.path.join(session_dir, ".stitching")
190
+
191
+
192
+ def _try_acquire_stitch_lock(session_dir: str) -> bool:
193
+ try:
194
+ fd = os.open(_stitch_lock_path(session_dir), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
195
+ os.close(fd)
196
+ return True
197
+ except FileExistsError:
198
+ return False
199
+
200
+
201
+ def _release_stitch_lock(session_dir: str) -> None:
202
+ try:
203
+ os.remove(_stitch_lock_path(session_dir))
204
+ except OSError:
205
+ logging.debug("stitch lock remove failed", exc_info=True)
206
+
207
+
208
+ def _remove_upload_session(session_dir: str) -> None:
209
+ if not session_dir or not os.path.isdir(session_dir):
210
+ return
211
+ try:
212
+ shutil.rmtree(session_dir)
213
+ except OSError:
214
+ logging.debug("upload session remove failed", exc_info=True)
215
+
216
+
217
+ def _sanitize_upload_id(upload_id: str | None) -> str | None:
218
+ if not upload_id or not _UPLOAD_ID_RE.fullmatch(upload_id):
219
+ return None
220
+ return upload_id
221
+
222
+
223
+ def _parse_positive_int(value: str | None) -> int | None:
224
+ if value is None or value == "":
225
+ return None
226
+ try:
227
+ parsed = int(value)
228
+ except ValueError:
229
+ return None
230
+ if parsed < 0:
231
+ return None
232
+ return parsed
233
+
234
+
235
+ def _query_arg(query_args: dict, name: str) -> str | None:
236
+ """First value for a Tornado query argument name."""
237
+ if not query_args:
238
+ return None
239
+ raw_list = query_args.get(name)
240
+ if raw_list is None:
241
+ raw_list = query_args.get(name.encode("utf-8"))
242
+ if not raw_list:
243
+ return None
244
+ raw = raw_list[0]
245
+ if isinstance(raw, bytes):
246
+ return raw.decode("utf-8", errors="replace")
247
+ return str(raw)
248
+
249
+
250
+ def _chunk_field(
251
+ headers, query_args: dict, header_name: str, query_name: str
252
+ ) -> str | None:
253
+ """Read chunked-upload metadata from header with query-string fallback (Cloudflare-safe)."""
254
+ value = headers.get(header_name)
255
+ if value:
256
+ return value
257
+ return _query_arg(query_args, query_name)
258
+
259
+
260
+ def _parse_chunk_headers(
261
+ headers, query_args: dict | None = None,
262
+ ) -> tuple[dict | None, str | None]:
263
+ """Return (chunk_info, error_message). chunk_info keys: upload_id, offset, total_size, is_last."""
264
+ query_args = query_args or {}
265
+ upload_id = _sanitize_upload_id(
266
+ _chunk_field(headers, query_args, "X-Upload-Id", "upload_id")
267
+ )
268
+ offset = _parse_positive_int(
269
+ _chunk_field(headers, query_args, "X-Chunk-Offset", "chunk_offset")
270
+ )
271
+ total_size = _parse_positive_int(
272
+ _chunk_field(headers, query_args, "X-Upload-Total-Size", "total_size")
273
+ )
274
+ last_raw = (
275
+ _chunk_field(headers, query_args, "X-Chunk-Last", "chunk_last") or ""
276
+ ).strip().lower()
277
+ def _has_query(name: str) -> bool:
278
+ return name in query_args or name.encode("utf-8") in query_args
279
+
280
+ has_chunk_request = any(
281
+ [
282
+ headers.get("X-Upload-Id"),
283
+ headers.get("X-Chunk-Offset"),
284
+ headers.get("X-Upload-Total-Size"),
285
+ headers.get("X-Chunk-Last"),
286
+ _has_query("upload_id"),
287
+ _has_query("chunk_offset"),
288
+ _has_query("total_size"),
289
+ _has_query("chunk_last"),
290
+ ]
291
+ )
292
+ if not has_chunk_request:
293
+ return (None, None)
294
+ if not upload_id or offset is None or total_size is None or not last_raw:
295
+ return (None, MISSING_UPLOAD_CHUNK_HEADERS)
296
+ if total_size == 0:
297
+ return (None, INVALID_UPLOAD_CHUNK_HEADERS)
298
+ is_last = last_raw in ("1", "true", "yes")
299
+ if offset >= total_size and not is_last:
300
+ return (None, INVALID_UPLOAD_CHUNK_HEADERS)
301
+ return (
302
+ {
303
+ "upload_id": upload_id,
304
+ "offset": offset,
305
+ "total_size": total_size,
306
+ "is_last": is_last,
307
+ },
308
+ None,
309
+ )
310
+
311
+
128
312
  # ---------------------------------------------------------------------------
129
313
  # Helpers for bulk actions (reduce cognitive complexity)
130
314
  # ---------------------------------------------------------------------------
@@ -275,6 +459,22 @@ def _process_bulk_action(
275
459
 
276
460
  @tornado.web.stream_request_body
277
461
  class UploadHandler(BaseHandler):
462
+ def check_xsrf_cookie(self) -> None:
463
+ """Streamed uploads send raw body; accept X-XSRFToken header or ?_xsrf= query param."""
464
+ cookie_token = self.get_cookie("_xsrf")
465
+ if not cookie_token:
466
+ raise tornado.web.HTTPError(403, "'_xsrf' cookie missing")
467
+ provided = self.request.headers.get("X-XSRFToken")
468
+ if not provided:
469
+ provided = _query_arg(self.request.arguments, "_xsrf")
470
+ if not provided or not secrets.compare_digest(provided, cookie_token):
471
+ raise tornado.web.HTTPError(403, "XSRF validation failed")
472
+
473
+ def _request_body_limit(self) -> int:
474
+ if getattr(self, "_chunk_mode", False):
475
+ return constants_module.UPLOAD_CHUNK_SIZE_BYTES
476
+ return constants_module.MAX_FILE_SIZE
477
+
278
478
  async def prepare(self):
279
479
  # Defaults for safety
280
480
  self._reject: bool = False
@@ -287,6 +487,10 @@ class UploadHandler(BaseHandler):
287
487
  self._moved: bool = False
288
488
  self._bytes_received: int = 0
289
489
  self._too_large: bool = False
490
+ self._chunk_mode: bool = False
491
+ self._keep_session: bool = False
492
+ self._chunk_info: dict | None = None
493
+ self._session_dir: str | None = None
290
494
 
291
495
  # Feature flag check (using SQLite-backed flags)
292
496
  # Deferred to post() for clear response, but avoid heavy work if disabled
@@ -295,9 +499,17 @@ class UploadHandler(BaseHandler):
295
499
  self._reject_reason = FILE_UPLOAD_DISABLED
296
500
  return
297
501
 
298
- # Read and URL-decode headers provided by client (frontend uses encodeURIComponent)
299
- raw_dir = self.request.headers.get("X-Upload-Dir") or ""
300
- raw_filename = self.request.headers.get("X-Upload-Filename") or ""
502
+ # Headers with query fallback (proxies such as Cloudflare may strip custom X-* headers)
503
+ raw_dir = (
504
+ self.request.headers.get("X-Upload-Dir")
505
+ or _query_arg(self.request.arguments, "upload_dir")
506
+ or ""
507
+ )
508
+ raw_filename = (
509
+ self.request.headers.get("X-Upload-Filename")
510
+ or _query_arg(self.request.arguments, "upload_filename")
511
+ or ""
512
+ )
301
513
  self.upload_dir = unquote(raw_dir)
302
514
  self.filename = unquote(raw_filename)
303
515
 
@@ -307,23 +519,52 @@ class UploadHandler(BaseHandler):
307
519
  self._reject_reason = MISSING_UPLOAD_FILENAME_HEADER
308
520
  return
309
521
 
310
- # Create temporary file for streamed writes
522
+ chunk_info, chunk_err = _parse_chunk_headers(
523
+ self.request.headers, self.request.arguments
524
+ )
525
+ if chunk_err:
526
+ self._reject = True
527
+ self._reject_reason = chunk_err
528
+ return
529
+ if chunk_info:
530
+ if not self.get_current_user():
531
+ self._reject = True
532
+ self._reject_reason = ACCESS_DENIED
533
+ return
534
+ if chunk_info["total_size"] > constants_module.MAX_FILE_SIZE:
535
+ self._reject = True
536
+ self._reject_reason = FILE_TOO_LARGE
537
+ return
538
+ self._chunk_mode = True
539
+ self._chunk_info = chunk_info
540
+ username = self.get_display_username()
541
+ self._session_dir = _upload_session_dir(username, chunk_info["upload_id"])
542
+ offset = chunk_info["offset"]
543
+ if offset == 0 and os.path.isdir(self._session_dir):
544
+ _remove_upload_session(self._session_dir)
545
+ os.makedirs(self._session_dir, exist_ok=True)
546
+ self._temp_path = _chunk_file_path(self._session_dir, offset)
547
+ self._aiofile = await aiofiles.open(self._temp_path, "wb")
548
+ return
549
+
550
+ # Single-request upload (legacy / small files)
311
551
  fd, self._temp_path = tempfile.mkstemp(prefix="aird_upload_")
312
- # Close the low-level fd; we'll use aiofiles on the path
313
552
  os.close(fd)
314
553
  self._aiofile = await aiofiles.open(self._temp_path, "wb")
315
554
 
316
555
  def data_received(self, chunk: bytes) -> None:
317
556
  if self._reject:
318
557
  return
319
- # Track size to enforce limit at the end
320
558
  self._bytes_received += len(chunk)
321
- if self._bytes_received > constants_module.MAX_FILE_SIZE:
559
+ if self._bytes_received > self._request_body_limit():
322
560
  self._too_large = True
323
- # We still accept the stream but won't persist it
324
561
  return
562
+ if self._chunk_mode and self._chunk_info:
563
+ end_offset = self._chunk_info["offset"] + self._bytes_received
564
+ if end_offset > self._chunk_info["total_size"]:
565
+ self._too_large = True
566
+ return
325
567
 
326
- # Queue the chunk and ensure a writer task is draining
327
568
  self._buffer.append(chunk)
328
569
  if not self._writing:
329
570
  self._writing = True
@@ -351,7 +592,7 @@ class UploadHandler(BaseHandler):
351
592
  except Exception:
352
593
  logging.debug("upload aiofile close failed", exc_info=True)
353
594
 
354
- def _check_quota_exceeded(self) -> bool:
595
+ def _check_quota_exceeded(self, upload_bytes: int) -> bool:
355
596
  """Return True and set error response if storage quota would be exceeded."""
356
597
  if not is_feature_enabled("storage_quotas", False):
357
598
  return False
@@ -359,13 +600,47 @@ class UploadHandler(BaseHandler):
359
600
  quota = self.get_service("quota_service").get_quota(self.db_conn, username)
360
601
  if (
361
602
  quota["quota_bytes"] is not None
362
- and quota["used_bytes"] + self._bytes_received > quota["quota_bytes"]
603
+ and quota["used_bytes"] + upload_bytes > quota["quota_bytes"]
363
604
  ):
364
605
  self.set_status(413)
365
606
  self.write("Storage quota exceeded")
366
607
  return True
367
608
  return False
368
609
 
610
+ def _remove_upload_session_dir(self) -> None:
611
+ if getattr(self, "_session_dir", None):
612
+ _remove_upload_session(self._session_dir)
613
+
614
+ async def _finalize_chunked_upload(self) -> bool:
615
+ """Stitch chunk files when complete. Return True if upload finished."""
616
+ session_dir = self._session_dir
617
+ total_size = self._chunk_info["total_size"]
618
+ if not _upload_chunks_complete(session_dir, total_size):
619
+ self._keep_session = True
620
+ self.set_status(200)
621
+ self.write(UPLOAD_CHUNK_RECEIVED)
622
+ return False
623
+ if not _try_acquire_stitch_lock(session_dir):
624
+ self._keep_session = True
625
+ self.set_status(200)
626
+ self.write(UPLOAD_CHUNK_RECEIVED)
627
+ return False
628
+ stitched_path = os.path.join(session_dir, "assembled.part")
629
+ try:
630
+ await asyncio.to_thread(
631
+ _stitch_upload_session, session_dir, stitched_path, total_size
632
+ )
633
+ except Exception:
634
+ logging.exception("Upload stitch failed")
635
+ self.set_status(500)
636
+ self.write(UPLOAD_SAVE_FAILED)
637
+ _remove_upload_session(session_dir)
638
+ return False
639
+ finally:
640
+ _release_stitch_lock(session_dir)
641
+ self._temp_path = stitched_path
642
+ return True
643
+
369
644
  @tornado.web.authenticated
370
645
  @require_action("file.write")
371
646
  @require_modify_access()
@@ -383,15 +658,45 @@ class UploadHandler(BaseHandler):
383
658
 
384
659
  await self._finalize_stream()
385
660
 
386
- # Enforce size limit
387
661
  if self._too_large:
388
662
  limit_mb = constants_module.UPLOAD_CONFIG.get("max_file_size_mb", 512)
389
663
  self.set_status(413)
390
664
  self.write(FILE_TOO_LARGE_TEMPLATE.format(limit_mb=limit_mb))
665
+ if self._chunk_mode:
666
+ self._remove_upload_session_dir()
391
667
  return
392
668
 
393
- # Check storage quota if enabled
394
- if self._check_quota_exceeded():
669
+ if self._chunk_mode and self._chunk_info:
670
+ chunk_end = self._chunk_info["offset"] + self._bytes_received
671
+ if chunk_end > self._chunk_info["total_size"]:
672
+ self.set_status(400)
673
+ self.write(INVALID_UPLOAD_CHUNK_HEADERS)
674
+ self._remove_upload_session_dir()
675
+ return
676
+ try:
677
+ chunk_size = os.path.getsize(self._temp_path)
678
+ except OSError:
679
+ chunk_size = -1
680
+ if chunk_size != self._bytes_received:
681
+ self.set_status(400)
682
+ self.write(UPLOAD_CHUNK_OUT_OF_ORDER)
683
+ self._remove_upload_session_dir()
684
+ return
685
+ finished = await self._finalize_chunked_upload()
686
+ if not finished:
687
+ return
688
+ upload_bytes = self._chunk_info["total_size"]
689
+ else:
690
+ if self._bytes_received > constants_module.MAX_FILE_SIZE:
691
+ limit_mb = constants_module.UPLOAD_CONFIG.get("max_file_size_mb", 512)
692
+ self.set_status(413)
693
+ self.write(FILE_TOO_LARGE_TEMPLATE.format(limit_mb=limit_mb))
694
+ return
695
+ upload_bytes = self._bytes_received
696
+
697
+ if self._check_quota_exceeded(upload_bytes):
698
+ if self._chunk_mode:
699
+ self._remove_upload_session_dir()
395
700
  return
396
701
 
397
702
  final_path_abs, upload_err = _validate_upload_destination(
@@ -400,6 +705,8 @@ class UploadHandler(BaseHandler):
400
705
  if upload_err is not None:
401
706
  self.set_status(upload_err[0])
402
707
  self.write(upload_err[1])
708
+ if self._chunk_mode:
709
+ self._remove_upload_session_dir()
403
710
  return
404
711
 
405
712
  os.makedirs(os.path.dirname(final_path_abs), exist_ok=True)
@@ -407,16 +714,19 @@ class UploadHandler(BaseHandler):
407
714
  try:
408
715
  shutil.move(self._temp_path, final_path_abs)
409
716
  self._moved = True
717
+ if self._chunk_mode and self._session_dir:
718
+ _remove_upload_session(self._session_dir)
410
719
  except Exception:
411
720
  logging.exception("Upload save failed")
412
721
  self.set_status(500)
413
722
  self.write(UPLOAD_SAVE_FAILED)
723
+ if self._chunk_mode:
724
+ self._remove_upload_session_dir()
414
725
  return
415
726
 
416
- # Update used bytes
417
727
  if is_feature_enabled("storage_quotas", False):
418
728
  self.get_service("quota_service").update_used_bytes(
419
- self.db_conn, self.get_display_username(), self._bytes_received
729
+ self.db_conn, self.get_display_username(), upload_bytes
420
730
  )
421
731
 
422
732
  self.get_service("audit_service").log(
@@ -430,8 +740,9 @@ class UploadHandler(BaseHandler):
430
740
  self.write(UPLOAD_SUCCESSFUL)
431
741
 
432
742
  def on_finish(self) -> None:
433
- # Clean up temp file on failures
434
743
  try:
744
+ if getattr(self, "_keep_session", False) or getattr(self, "_chunk_mode", False):
745
+ return
435
746
  if getattr(self, "_temp_path", None) and not getattr(self, "_moved", False):
436
747
  if os.path.exists(self._temp_path):
437
748
  try:
@@ -76,10 +76,15 @@ def _add_local_path(ap, path_str, share_type, valid_paths, dynamic_folders):
76
76
  else:
77
77
  try:
78
78
  all_files = get_all_files_recursive(ap, path_str)
79
- valid_paths.extend(all_files)
80
- logging.debug(
81
- "Added %s files from directory: %s", len(all_files), path_str
82
- )
79
+ if all_files:
80
+ valid_paths.extend(all_files)
81
+ logging.debug(
82
+ "Added %s files from directory: %s", len(all_files), path_str
83
+ )
84
+ else:
85
+ # Keep empty directories so static shares can be created for them.
86
+ valid_paths.append(path_str)
87
+ logging.debug("Added empty directory: %s", path_str)
83
88
  except Exception:
84
89
  logging.exception("Error scanning directory %s", path_str)
85
90
 
@@ -304,7 +309,18 @@ def _get_share_file_list(share, db_conn=None):
304
309
  )
305
310
  return filter_files_by_patterns(dynamic_files, allow_list, avoid_list)
306
311
 
307
- return filter_files_by_patterns(share.get("paths") or [], allow_list, avoid_list)
312
+ static_paths = []
313
+ for rel_path in share.get("paths") or []:
314
+ try:
315
+ full_path = os.path.abspath(os.path.join(root, rel_path))
316
+ if os.path.isdir(full_path):
317
+ continue
318
+ except Exception:
319
+ logging.debug(
320
+ "Skipping static share path check for %r", rel_path, exc_info=True
321
+ )
322
+ static_paths.append(rel_path)
323
+ return filter_files_by_patterns(static_paths, allow_list, avoid_list)
308
324
 
309
325
 
310
326
  def _is_path_in_share(share, path, db_conn=None):
@@ -196,6 +196,8 @@ class MainHandler(BaseHandler):
196
196
  get_file_icon=get_file_icon,
197
197
  features=flags_for_template,
198
198
  max_file_size=constants_module.MAX_FILE_SIZE,
199
+ upload_chunk_size=constants_module.UPLOAD_CHUNK_SIZE_BYTES,
200
+ upload_max_parallel=constants_module.UPLOAD_MAX_PARALLEL_CHUNKS,
199
201
  user_favorites=user_favorites,
200
202
  file_tags_map=file_tags_map,
201
203
  )
@@ -154,8 +154,8 @@ def make_app(
154
154
  settings.setdefault("static_url_prefix", "/static/")
155
155
  # Limit request size to avoid Tornado rejecting large uploads with
156
156
  # "Content-Length too long" before our handler can respond.
157
- settings.setdefault("max_body_size", constants.MAX_UPLOAD_FILE_SIZE_HARD_LIMIT)
158
- settings.setdefault("max_buffer_size", constants.MAX_UPLOAD_FILE_SIZE_HARD_LIMIT)
157
+ settings.setdefault("max_body_size", constants.UPLOAD_REQUEST_MAX_BODY_SIZE)
158
+ settings.setdefault("max_buffer_size", constants.UPLOAD_REQUEST_MAX_BODY_SIZE)
159
159
 
160
160
  if ldap_enabled:
161
161
  settings["ldap_server"] = ldap_server
@@ -495,8 +495,8 @@ def _start_server(app, ssl_options, port: int, hostname: str) -> None:
495
495
  app.listen(
496
496
  port,
497
497
  ssl_options=ssl_options,
498
- max_body_size=constants.MAX_UPLOAD_FILE_SIZE_HARD_LIMIT,
499
- max_buffer_size=constants.MAX_UPLOAD_FILE_SIZE_HARD_LIMIT,
498
+ max_body_size=constants.UPLOAD_REQUEST_MAX_BODY_SIZE,
499
+ max_buffer_size=constants.UPLOAD_REQUEST_MAX_BODY_SIZE,
500
500
  )
501
501
  logger.info(
502
502
  f"Serving HTTPS on 0.0.0.0 port {port} ({proto}://0.0.0.0:{port}/) ..."
@@ -505,8 +505,8 @@ def _start_server(app, ssl_options, port: int, hostname: str) -> None:
505
505
  proto = "http"
506
506
  app.listen(
507
507
  port,
508
- max_body_size=constants.MAX_UPLOAD_FILE_SIZE_HARD_LIMIT,
509
- max_buffer_size=constants.MAX_UPLOAD_FILE_SIZE_HARD_LIMIT,
508
+ max_body_size=constants.UPLOAD_REQUEST_MAX_BODY_SIZE,
509
+ max_buffer_size=constants.UPLOAD_REQUEST_MAX_BODY_SIZE,
510
510
  )
511
511
  logger.info(
512
512
  f"Serving HTTP on 0.0.0.0 port {port} ({proto}://0.0.0.0:{port}/) ..."