django-nativemojo 0.1.10__py3-none-any.whl

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 (194) hide show
  1. django_nativemojo-0.1.10.dist-info/LICENSE +19 -0
  2. django_nativemojo-0.1.10.dist-info/METADATA +96 -0
  3. django_nativemojo-0.1.10.dist-info/NOTICE +8 -0
  4. django_nativemojo-0.1.10.dist-info/RECORD +194 -0
  5. django_nativemojo-0.1.10.dist-info/WHEEL +4 -0
  6. mojo/__init__.py +3 -0
  7. mojo/apps/account/__init__.py +1 -0
  8. mojo/apps/account/admin.py +91 -0
  9. mojo/apps/account/apps.py +16 -0
  10. mojo/apps/account/migrations/0001_initial.py +77 -0
  11. mojo/apps/account/migrations/0002_user_is_email_verified_user_is_phone_verified.py +23 -0
  12. mojo/apps/account/migrations/0003_group_mojo_secrets_user_mojo_secrets.py +23 -0
  13. mojo/apps/account/migrations/__init__.py +0 -0
  14. mojo/apps/account/models/__init__.py +3 -0
  15. mojo/apps/account/models/group.py +98 -0
  16. mojo/apps/account/models/member.py +95 -0
  17. mojo/apps/account/models/pkey.py +18 -0
  18. mojo/apps/account/models/user.py +211 -0
  19. mojo/apps/account/rest/__init__.py +3 -0
  20. mojo/apps/account/rest/group.py +25 -0
  21. mojo/apps/account/rest/user.py +47 -0
  22. mojo/apps/account/utils/__init__.py +0 -0
  23. mojo/apps/account/utils/jwtoken.py +72 -0
  24. mojo/apps/account/utils/passkeys.py +54 -0
  25. mojo/apps/fileman/README.md +549 -0
  26. mojo/apps/fileman/__init__.py +0 -0
  27. mojo/apps/fileman/apps.py +15 -0
  28. mojo/apps/fileman/backends/__init__.py +117 -0
  29. mojo/apps/fileman/backends/base.py +319 -0
  30. mojo/apps/fileman/backends/filesystem.py +397 -0
  31. mojo/apps/fileman/backends/s3.py +398 -0
  32. mojo/apps/fileman/examples/configurations.py +378 -0
  33. mojo/apps/fileman/examples/usage_example.py +665 -0
  34. mojo/apps/fileman/management/__init__.py +1 -0
  35. mojo/apps/fileman/management/commands/__init__.py +1 -0
  36. mojo/apps/fileman/management/commands/cleanup_expired_uploads.py +222 -0
  37. mojo/apps/fileman/models/__init__.py +7 -0
  38. mojo/apps/fileman/models/file.py +292 -0
  39. mojo/apps/fileman/models/manager.py +227 -0
  40. mojo/apps/fileman/models/render.py +0 -0
  41. mojo/apps/fileman/rest/__init__ +0 -0
  42. mojo/apps/fileman/rest/__init__.py +23 -0
  43. mojo/apps/fileman/rest/fileman.py +13 -0
  44. mojo/apps/fileman/rest/upload.py +92 -0
  45. mojo/apps/fileman/utils/__init__.py +19 -0
  46. mojo/apps/fileman/utils/upload.py +616 -0
  47. mojo/apps/incident/__init__.py +1 -0
  48. mojo/apps/incident/handlers/__init__.py +3 -0
  49. mojo/apps/incident/handlers/event_handlers.py +142 -0
  50. mojo/apps/incident/migrations/0001_initial.py +83 -0
  51. mojo/apps/incident/migrations/0002_rename_bundle_ruleset_bundle_minutes_event_hostname_and_more.py +44 -0
  52. mojo/apps/incident/migrations/0003_alter_event_model_id.py +18 -0
  53. mojo/apps/incident/migrations/0004_alter_incident_model_id.py +18 -0
  54. mojo/apps/incident/migrations/__init__.py +0 -0
  55. mojo/apps/incident/models/__init__.py +3 -0
  56. mojo/apps/incident/models/event.py +135 -0
  57. mojo/apps/incident/models/incident.py +33 -0
  58. mojo/apps/incident/models/rule.py +247 -0
  59. mojo/apps/incident/parsers/__init__.py +0 -0
  60. mojo/apps/incident/parsers/ossec/__init__.py +1 -0
  61. mojo/apps/incident/parsers/ossec/core.py +82 -0
  62. mojo/apps/incident/parsers/ossec/parsed.py +23 -0
  63. mojo/apps/incident/parsers/ossec/rules.py +124 -0
  64. mojo/apps/incident/parsers/ossec/utils.py +169 -0
  65. mojo/apps/incident/reporter.py +42 -0
  66. mojo/apps/incident/rest/__init__.py +2 -0
  67. mojo/apps/incident/rest/event.py +23 -0
  68. mojo/apps/incident/rest/ossec.py +22 -0
  69. mojo/apps/logit/__init__.py +0 -0
  70. mojo/apps/logit/admin.py +37 -0
  71. mojo/apps/logit/migrations/0001_initial.py +32 -0
  72. mojo/apps/logit/migrations/0002_log_duid_log_payload_log_username.py +28 -0
  73. mojo/apps/logit/migrations/0003_log_level.py +18 -0
  74. mojo/apps/logit/migrations/__init__.py +0 -0
  75. mojo/apps/logit/models/__init__.py +1 -0
  76. mojo/apps/logit/models/log.py +57 -0
  77. mojo/apps/logit/rest.py +9 -0
  78. mojo/apps/metrics/README.md +79 -0
  79. mojo/apps/metrics/__init__.py +12 -0
  80. mojo/apps/metrics/redis_metrics.py +331 -0
  81. mojo/apps/metrics/rest/__init__.py +1 -0
  82. mojo/apps/metrics/rest/base.py +152 -0
  83. mojo/apps/metrics/rest/db.py +0 -0
  84. mojo/apps/metrics/utils.py +227 -0
  85. mojo/apps/notify/README.md +91 -0
  86. mojo/apps/notify/README_NOTIFICATIONS.md +566 -0
  87. mojo/apps/notify/__init__.py +0 -0
  88. mojo/apps/notify/admin.py +52 -0
  89. mojo/apps/notify/handlers/__init__.py +0 -0
  90. mojo/apps/notify/handlers/example_handlers.py +516 -0
  91. mojo/apps/notify/handlers/ses/__init__.py +25 -0
  92. mojo/apps/notify/handlers/ses/bounce.py +0 -0
  93. mojo/apps/notify/handlers/ses/complaint.py +25 -0
  94. mojo/apps/notify/handlers/ses/message.py +86 -0
  95. mojo/apps/notify/management/__init__.py +0 -0
  96. mojo/apps/notify/management/commands/__init__.py +1 -0
  97. mojo/apps/notify/management/commands/process_notifications.py +370 -0
  98. mojo/apps/notify/mod +0 -0
  99. mojo/apps/notify/models/__init__.py +12 -0
  100. mojo/apps/notify/models/account.py +128 -0
  101. mojo/apps/notify/models/attachment.py +24 -0
  102. mojo/apps/notify/models/bounce.py +68 -0
  103. mojo/apps/notify/models/complaint.py +40 -0
  104. mojo/apps/notify/models/inbox.py +113 -0
  105. mojo/apps/notify/models/inbox_message.py +173 -0
  106. mojo/apps/notify/models/outbox.py +129 -0
  107. mojo/apps/notify/models/outbox_message.py +288 -0
  108. mojo/apps/notify/models/template.py +30 -0
  109. mojo/apps/notify/providers/__init__.py +0 -0
  110. mojo/apps/notify/providers/aws.py +73 -0
  111. mojo/apps/notify/rest/__init__.py +0 -0
  112. mojo/apps/notify/rest/ses.py +0 -0
  113. mojo/apps/notify/utils/__init__.py +2 -0
  114. mojo/apps/notify/utils/notifications.py +404 -0
  115. mojo/apps/notify/utils/parsing.py +202 -0
  116. mojo/apps/notify/utils/render.py +144 -0
  117. mojo/apps/tasks/README.md +118 -0
  118. mojo/apps/tasks/__init__.py +11 -0
  119. mojo/apps/tasks/manager.py +489 -0
  120. mojo/apps/tasks/rest/__init__.py +2 -0
  121. mojo/apps/tasks/rest/hooks.py +0 -0
  122. mojo/apps/tasks/rest/tasks.py +62 -0
  123. mojo/apps/tasks/runner.py +174 -0
  124. mojo/apps/tasks/tq_handlers.py +14 -0
  125. mojo/decorators/__init__.py +3 -0
  126. mojo/decorators/auth.py +25 -0
  127. mojo/decorators/cron.py +31 -0
  128. mojo/decorators/http.py +132 -0
  129. mojo/decorators/validate.py +14 -0
  130. mojo/errors.py +88 -0
  131. mojo/helpers/__init__.py +0 -0
  132. mojo/helpers/aws/__init__.py +0 -0
  133. mojo/helpers/aws/client.py +8 -0
  134. mojo/helpers/aws/s3.py +268 -0
  135. mojo/helpers/aws/setup_email.py +0 -0
  136. mojo/helpers/cron.py +79 -0
  137. mojo/helpers/crypto/__init__.py +4 -0
  138. mojo/helpers/crypto/aes.py +60 -0
  139. mojo/helpers/crypto/hash.py +59 -0
  140. mojo/helpers/crypto/privpub/__init__.py +1 -0
  141. mojo/helpers/crypto/privpub/hybrid.py +97 -0
  142. mojo/helpers/crypto/privpub/rsa.py +104 -0
  143. mojo/helpers/crypto/sign.py +36 -0
  144. mojo/helpers/crypto/too.l.py +25 -0
  145. mojo/helpers/crypto/utils.py +26 -0
  146. mojo/helpers/daemon.py +94 -0
  147. mojo/helpers/dates.py +69 -0
  148. mojo/helpers/dns/__init__.py +0 -0
  149. mojo/helpers/dns/godaddy.py +62 -0
  150. mojo/helpers/filetypes.py +128 -0
  151. mojo/helpers/logit.py +310 -0
  152. mojo/helpers/modules.py +95 -0
  153. mojo/helpers/paths.py +63 -0
  154. mojo/helpers/redis.py +10 -0
  155. mojo/helpers/request.py +89 -0
  156. mojo/helpers/request_parser.py +269 -0
  157. mojo/helpers/response.py +14 -0
  158. mojo/helpers/settings.py +146 -0
  159. mojo/helpers/sysinfo.py +140 -0
  160. mojo/helpers/ua.py +0 -0
  161. mojo/middleware/__init__.py +0 -0
  162. mojo/middleware/auth.py +26 -0
  163. mojo/middleware/logging.py +55 -0
  164. mojo/middleware/mojo.py +21 -0
  165. mojo/migrations/0001_initial.py +32 -0
  166. mojo/migrations/__init__.py +0 -0
  167. mojo/models/__init__.py +2 -0
  168. mojo/models/meta.py +262 -0
  169. mojo/models/rest.py +538 -0
  170. mojo/models/secrets.py +59 -0
  171. mojo/rest/__init__.py +1 -0
  172. mojo/rest/info.py +26 -0
  173. mojo/serializers/__init__.py +0 -0
  174. mojo/serializers/models.py +165 -0
  175. mojo/serializers/openapi.py +188 -0
  176. mojo/urls.py +38 -0
  177. mojo/ws4redis/README.md +174 -0
  178. mojo/ws4redis/__init__.py +2 -0
  179. mojo/ws4redis/client.py +283 -0
  180. mojo/ws4redis/connection.py +327 -0
  181. mojo/ws4redis/exceptions.py +32 -0
  182. mojo/ws4redis/redis.py +183 -0
  183. mojo/ws4redis/servers/__init__.py +0 -0
  184. mojo/ws4redis/servers/base.py +86 -0
  185. mojo/ws4redis/servers/django.py +171 -0
  186. mojo/ws4redis/servers/uwsgi.py +63 -0
  187. mojo/ws4redis/settings.py +45 -0
  188. mojo/ws4redis/utf8validator.py +128 -0
  189. mojo/ws4redis/websocket.py +403 -0
  190. testit/__init__.py +0 -0
  191. testit/client.py +147 -0
  192. testit/faker.py +20 -0
  193. testit/helpers.py +198 -0
  194. testit/runner.py +262 -0
@@ -0,0 +1,398 @@
1
+ import boto3
2
+ from botocore.exceptions import ClientError, NoCredentialsError
3
+ from botocore.client import Config
4
+ from typing import Dict, Any, Optional, Tuple, List
5
+ from datetime import datetime, timedelta
6
+ import os
7
+ import uuid
8
+ import hashlib
9
+
10
+ from .base import StorageBackend
11
+
12
+
13
+ class S3StorageBackend(StorageBackend):
14
+ """
15
+ AWS S3 storage backend implementation
16
+ """
17
+
18
+ def __init__(self, file_manager, **kwargs):
19
+ super().__init__(file_manager, **kwargs)
20
+
21
+ # S3 configuration
22
+ self.bucket_name = self.get_setting('bucket_name')
23
+ self.region_name = self.get_setting('region_name', 'us-east-1')
24
+ self.access_key_id = self.get_setting('access_key_id')
25
+ self.secret_access_key = self.get_setting('secret_access_key')
26
+ self.endpoint_url = self.get_setting('endpoint_url') # For S3-compatible services
27
+ self.signature_version = self.get_setting('signature_version', 's3v4')
28
+ self.addressing_style = self.get_setting('addressing_style', 'auto')
29
+
30
+ # Upload configuration
31
+ self.upload_expires_in = self.get_setting('upload_expires_in', 3600) # 1 hour
32
+ self.download_expires_in = self.get_setting('download_expires_in', 3600) # 1 hour
33
+ self.multipart_threshold = self.get_setting('multipart_threshold', 8 * 1024 * 1024) # 8MB
34
+ self.max_concurrency = self.get_setting('max_concurrency', 10)
35
+
36
+ # Security settings
37
+ self.server_side_encryption = self.get_setting('server_side_encryption')
38
+ self.kms_key_id = self.get_setting('kms_key_id')
39
+
40
+ # Initialize S3 client
41
+ self._client = None
42
+ self._resource = None
43
+
44
+ @property
45
+ def client(self):
46
+ """Lazy initialization of S3 client"""
47
+ if self._client is None:
48
+ session = boto3.Session(
49
+ aws_access_key_id=self.access_key_id,
50
+ aws_secret_access_key=self.secret_access_key,
51
+ region_name=self.region_name
52
+ )
53
+
54
+ config = Config(
55
+ signature_version=self.signature_version,
56
+ s3={
57
+ 'addressing_style': self.addressing_style
58
+ },
59
+ retries={
60
+ 'max_attempts': 3,
61
+ 'mode': 'adaptive'
62
+ }
63
+ )
64
+
65
+ self._client = session.client(
66
+ 's3',
67
+ endpoint_url=self.endpoint_url,
68
+ config=config
69
+ )
70
+
71
+ return self._client
72
+
73
+ @property
74
+ def resource(self):
75
+ """Lazy initialization of S3 resource"""
76
+ if self._resource is None:
77
+ session = boto3.Session(
78
+ aws_access_key_id=self.access_key_id,
79
+ aws_secret_access_key=self.secret_access_key,
80
+ region_name=self.region_name
81
+ )
82
+
83
+ self._resource = session.resource(
84
+ 's3',
85
+ endpoint_url=self.endpoint_url
86
+ )
87
+
88
+ return self._resource
89
+
90
+ def save(self, file_obj, filename: str, **kwargs) -> str:
91
+ """Save a file to S3"""
92
+ try:
93
+ # Generate file path
94
+ file_path = self.generate_file_path(filename, kwargs.get('group_id'))
95
+
96
+ # Prepare upload parameters
97
+ upload_params = {
98
+ 'Bucket': self.bucket_name,
99
+ 'Key': file_path,
100
+ 'Body': file_obj
101
+ }
102
+
103
+ # Add content type if provided
104
+ content_type = kwargs.get('content_type')
105
+ if content_type:
106
+ upload_params['ContentType'] = content_type
107
+
108
+ # Add server-side encryption if configured
109
+ if self.server_side_encryption:
110
+ upload_params['ServerSideEncryption'] = self.server_side_encryption
111
+ if self.kms_key_id:
112
+ upload_params['SSEKMSKeyId'] = self.kms_key_id
113
+
114
+ # Add metadata
115
+ metadata = kwargs.get('metadata', {})
116
+ if metadata:
117
+ upload_params['Metadata'] = {k: str(v) for k, v in metadata.items()}
118
+
119
+ # Upload the file
120
+ self.client.put_object(**upload_params)
121
+
122
+ return file_path
123
+
124
+ except ClientError as e:
125
+ raise Exception(f"Failed to save file to S3: {e}")
126
+
127
+ def delete(self, file_path: str) -> bool:
128
+ """Delete a file from S3"""
129
+ try:
130
+ self.client.delete_object(
131
+ Bucket=self.bucket_name,
132
+ Key=file_path
133
+ )
134
+ return True
135
+ except ClientError:
136
+ return False
137
+
138
+ def exists(self, file_path: str) -> bool:
139
+ """Check if a file exists in S3"""
140
+ try:
141
+ self.client.head_object(
142
+ Bucket=self.bucket_name,
143
+ Key=file_path
144
+ )
145
+ return True
146
+ except ClientError:
147
+ return False
148
+
149
+ def get_file_size(self, file_path: str) -> Optional[int]:
150
+ """Get the size of a file in S3"""
151
+ try:
152
+ response = self.client.head_object(
153
+ Bucket=self.bucket_name,
154
+ Key=file_path
155
+ )
156
+ return response['ContentLength']
157
+ except ClientError:
158
+ return None
159
+
160
+ def get_url(self, file_path: str, expires_in: Optional[int] = None) -> str:
161
+ """Get a pre-signed URL to access the file"""
162
+ if expires_in is None:
163
+ expires_in = self.download_expires_in
164
+
165
+ try:
166
+ url = self.client.generate_presigned_url(
167
+ 'get_object',
168
+ Params={
169
+ 'Bucket': self.bucket_name,
170
+ 'Key': file_path
171
+ },
172
+ ExpiresIn=expires_in
173
+ )
174
+ return url
175
+ except ClientError as e:
176
+ raise Exception(f"Failed to generate download URL: {e}")
177
+
178
+ def generate_upload_url(self, file_path: str, content_type: str,
179
+ file_size: Optional[int] = None,
180
+ expires_in: int = 3600) -> Dict[str, Any]:
181
+ """Generate a pre-signed URL for direct upload to S3"""
182
+ try:
183
+ # Conditions for the upload
184
+ conditions = []
185
+
186
+ # Content type condition
187
+ if content_type:
188
+ conditions.append({"Content-Type": content_type})
189
+
190
+ # File size conditions
191
+ if file_size:
192
+ # Allow some variance in file size (±1KB)
193
+ conditions.append(["content-length-range", max(0, file_size - 1024), file_size + 1024])
194
+
195
+ # Server-side encryption conditions
196
+ if self.server_side_encryption:
197
+ conditions.append({"x-amz-server-side-encryption": self.server_side_encryption})
198
+ if self.kms_key_id:
199
+ conditions.append({"x-amz-server-side-encryption-aws-kms-key-id": self.kms_key_id})
200
+
201
+ # Generate the presigned POST
202
+ response = self.client.generate_presigned_post(
203
+ Bucket=self.bucket_name,
204
+ Key=file_path,
205
+ Fields={
206
+ 'Content-Type': content_type,
207
+ },
208
+ Conditions=conditions,
209
+ ExpiresIn=expires_in
210
+ )
211
+
212
+ # Add server-side encryption fields if configured
213
+ if self.server_side_encryption:
214
+ response['fields']['x-amz-server-side-encryption'] = self.server_side_encryption
215
+ if self.kms_key_id:
216
+ response['fields']['x-amz-server-side-encryption-aws-kms-key-id'] = self.kms_key_id
217
+
218
+ return {
219
+ 'upload_url': response['url'],
220
+ 'method': 'POST',
221
+ 'fields': response['fields'],
222
+ 'headers': {
223
+ 'Content-Type': content_type
224
+ }
225
+ }
226
+
227
+ except ClientError as e:
228
+ raise Exception(f"Failed to generate upload URL: {e}")
229
+
230
+ def get_file_checksum(self, file_path: str, algorithm: str = 'md5') -> Optional[str]:
231
+ """Get file checksum from S3 metadata or calculate it"""
232
+ try:
233
+ # First try to get ETag (which is MD5 for non-multipart uploads)
234
+ response = self.client.head_object(
235
+ Bucket=self.bucket_name,
236
+ Key=file_path
237
+ )
238
+
239
+ if algorithm.lower() == 'md5':
240
+ etag = response.get('ETag', '').strip('"')
241
+ # ETag is MD5 only for non-multipart uploads (no hyphens)
242
+ if etag and '-' not in etag:
243
+ return etag
244
+
245
+ # If ETag is not usable, download and calculate checksum
246
+ return super().get_file_checksum(file_path, algorithm)
247
+
248
+ except ClientError:
249
+ return None
250
+
251
+ def open(self, file_path: str, mode: str = 'rb'):
252
+ """Open a file from S3"""
253
+ if 'w' in mode or 'a' in mode:
254
+ raise ValueError("S3 backend only supports read-only file access")
255
+
256
+ try:
257
+ obj = self.resource.Object(self.bucket_name, file_path)
258
+ return obj.get()['Body']
259
+ except ClientError as e:
260
+ raise FileNotFoundError(f"File not found in S3: {e}")
261
+
262
+ def list_files(self, path_prefix: str = "", limit: int = 1000) -> List[str]:
263
+ """List files in S3 with optional path prefix"""
264
+ try:
265
+ paginator = self.client.get_paginator('list_objects_v2')
266
+
267
+ page_iterator = paginator.paginate(
268
+ Bucket=self.bucket_name,
269
+ Prefix=path_prefix,
270
+ PaginationConfig={'MaxItems': limit}
271
+ )
272
+
273
+ files = []
274
+ for page in page_iterator:
275
+ if 'Contents' in page:
276
+ for obj in page['Contents']:
277
+ files.append(obj['Key'])
278
+
279
+ return files
280
+
281
+ except ClientError:
282
+ return []
283
+
284
+ def copy_file(self, source_path: str, dest_path: str) -> bool:
285
+ """Copy a file within S3"""
286
+ try:
287
+ copy_source = {
288
+ 'Bucket': self.bucket_name,
289
+ 'Key': source_path
290
+ }
291
+
292
+ self.client.copy_object(
293
+ CopySource=copy_source,
294
+ Bucket=self.bucket_name,
295
+ Key=dest_path
296
+ )
297
+
298
+ return True
299
+
300
+ except ClientError:
301
+ return False
302
+
303
+ def move_file(self, source_path: str, dest_path: str) -> bool:
304
+ """Move a file within S3"""
305
+ if self.copy_file(source_path, dest_path):
306
+ return self.delete(source_path)
307
+ return False
308
+
309
+ def get_file_metadata(self, file_path: str) -> Dict[str, Any]:
310
+ """Get comprehensive metadata for a file in S3"""
311
+ try:
312
+ response = self.client.head_object(
313
+ Bucket=self.bucket_name,
314
+ Key=file_path
315
+ )
316
+
317
+ metadata = {
318
+ 'exists': True,
319
+ 'size': response.get('ContentLength'),
320
+ 'path': file_path,
321
+ 'last_modified': response.get('LastModified'),
322
+ 'content_type': response.get('ContentType'),
323
+ 'etag': response.get('ETag', '').strip('"'),
324
+ 'storage_class': response.get('StorageClass', 'STANDARD'),
325
+ 'metadata': response.get('Metadata', {}),
326
+ 'server_side_encryption': response.get('ServerSideEncryption'),
327
+ 'version_id': response.get('VersionId')
328
+ }
329
+
330
+ return metadata
331
+
332
+ except ClientError:
333
+ return {'exists': False, 'path': file_path}
334
+
335
+ def cleanup_expired_uploads(self, before_date: Optional[datetime] = None):
336
+ """Clean up incomplete multipart uploads"""
337
+ if before_date is None:
338
+ before_date = datetime.now() - timedelta(days=1)
339
+
340
+ try:
341
+ paginator = self.client.get_paginator('list_multipart_uploads')
342
+
343
+ page_iterator = paginator.paginate(Bucket=self.bucket_name)
344
+
345
+ for page in page_iterator:
346
+ if 'Uploads' in page:
347
+ for upload in page['Uploads']:
348
+ if upload['Initiated'].replace(tzinfo=None) < before_date:
349
+ self.client.abort_multipart_upload(
350
+ Bucket=self.bucket_name,
351
+ Key=upload['Key'],
352
+ UploadId=upload['UploadId']
353
+ )
354
+
355
+ except ClientError:
356
+ pass # Silently ignore cleanup errors
357
+
358
+ def get_available_space(self) -> Optional[int]:
359
+ """S3 has virtually unlimited space"""
360
+ return None
361
+
362
+ def generate_file_path(self, filename: str, group_id: Optional[int] = None) -> str:
363
+ """Generate an S3 key for the file"""
364
+ # Use the base implementation but ensure S3-compatible paths
365
+ path = super().generate_file_path(filename, group_id)
366
+
367
+ # Ensure no leading slash for S3 keys
368
+ return path.lstrip('/')
369
+
370
+ def validate_configuration(self) -> Tuple[bool, List[str]]:
371
+ """Validate S3 configuration"""
372
+ errors = []
373
+
374
+ if not self.bucket_name:
375
+ errors.append("S3 bucket name is required")
376
+
377
+ if not self.access_key_id:
378
+ errors.append("AWS access key ID is required")
379
+
380
+ if not self.secret_access_key:
381
+ errors.append("AWS secret access key is required")
382
+
383
+ # Test connection if configuration looks valid
384
+ if not errors:
385
+ try:
386
+ self.client.head_bucket(Bucket=self.bucket_name)
387
+ except NoCredentialsError:
388
+ errors.append("Invalid AWS credentials")
389
+ except ClientError as e:
390
+ error_code = e.response['Error']['Code']
391
+ if error_code == '404':
392
+ errors.append(f"S3 bucket '{self.bucket_name}' does not exist")
393
+ elif error_code == '403':
394
+ errors.append(f"Access denied to S3 bucket '{self.bucket_name}'")
395
+ else:
396
+ errors.append(f"S3 connection error: {e}")
397
+
398
+ return len(errors) == 0, errors