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.
- django_nativemojo-0.1.10.dist-info/LICENSE +19 -0
- django_nativemojo-0.1.10.dist-info/METADATA +96 -0
- django_nativemojo-0.1.10.dist-info/NOTICE +8 -0
- django_nativemojo-0.1.10.dist-info/RECORD +194 -0
- django_nativemojo-0.1.10.dist-info/WHEEL +4 -0
- mojo/__init__.py +3 -0
- mojo/apps/account/__init__.py +1 -0
- mojo/apps/account/admin.py +91 -0
- mojo/apps/account/apps.py +16 -0
- mojo/apps/account/migrations/0001_initial.py +77 -0
- mojo/apps/account/migrations/0002_user_is_email_verified_user_is_phone_verified.py +23 -0
- mojo/apps/account/migrations/0003_group_mojo_secrets_user_mojo_secrets.py +23 -0
- mojo/apps/account/migrations/__init__.py +0 -0
- mojo/apps/account/models/__init__.py +3 -0
- mojo/apps/account/models/group.py +98 -0
- mojo/apps/account/models/member.py +95 -0
- mojo/apps/account/models/pkey.py +18 -0
- mojo/apps/account/models/user.py +211 -0
- mojo/apps/account/rest/__init__.py +3 -0
- mojo/apps/account/rest/group.py +25 -0
- mojo/apps/account/rest/user.py +47 -0
- mojo/apps/account/utils/__init__.py +0 -0
- mojo/apps/account/utils/jwtoken.py +72 -0
- mojo/apps/account/utils/passkeys.py +54 -0
- mojo/apps/fileman/README.md +549 -0
- mojo/apps/fileman/__init__.py +0 -0
- mojo/apps/fileman/apps.py +15 -0
- mojo/apps/fileman/backends/__init__.py +117 -0
- mojo/apps/fileman/backends/base.py +319 -0
- mojo/apps/fileman/backends/filesystem.py +397 -0
- mojo/apps/fileman/backends/s3.py +398 -0
- mojo/apps/fileman/examples/configurations.py +378 -0
- mojo/apps/fileman/examples/usage_example.py +665 -0
- mojo/apps/fileman/management/__init__.py +1 -0
- mojo/apps/fileman/management/commands/__init__.py +1 -0
- mojo/apps/fileman/management/commands/cleanup_expired_uploads.py +222 -0
- mojo/apps/fileman/models/__init__.py +7 -0
- mojo/apps/fileman/models/file.py +292 -0
- mojo/apps/fileman/models/manager.py +227 -0
- mojo/apps/fileman/models/render.py +0 -0
- mojo/apps/fileman/rest/__init__ +0 -0
- mojo/apps/fileman/rest/__init__.py +23 -0
- mojo/apps/fileman/rest/fileman.py +13 -0
- mojo/apps/fileman/rest/upload.py +92 -0
- mojo/apps/fileman/utils/__init__.py +19 -0
- mojo/apps/fileman/utils/upload.py +616 -0
- mojo/apps/incident/__init__.py +1 -0
- mojo/apps/incident/handlers/__init__.py +3 -0
- mojo/apps/incident/handlers/event_handlers.py +142 -0
- mojo/apps/incident/migrations/0001_initial.py +83 -0
- mojo/apps/incident/migrations/0002_rename_bundle_ruleset_bundle_minutes_event_hostname_and_more.py +44 -0
- mojo/apps/incident/migrations/0003_alter_event_model_id.py +18 -0
- mojo/apps/incident/migrations/0004_alter_incident_model_id.py +18 -0
- mojo/apps/incident/migrations/__init__.py +0 -0
- mojo/apps/incident/models/__init__.py +3 -0
- mojo/apps/incident/models/event.py +135 -0
- mojo/apps/incident/models/incident.py +33 -0
- mojo/apps/incident/models/rule.py +247 -0
- mojo/apps/incident/parsers/__init__.py +0 -0
- mojo/apps/incident/parsers/ossec/__init__.py +1 -0
- mojo/apps/incident/parsers/ossec/core.py +82 -0
- mojo/apps/incident/parsers/ossec/parsed.py +23 -0
- mojo/apps/incident/parsers/ossec/rules.py +124 -0
- mojo/apps/incident/parsers/ossec/utils.py +169 -0
- mojo/apps/incident/reporter.py +42 -0
- mojo/apps/incident/rest/__init__.py +2 -0
- mojo/apps/incident/rest/event.py +23 -0
- mojo/apps/incident/rest/ossec.py +22 -0
- mojo/apps/logit/__init__.py +0 -0
- mojo/apps/logit/admin.py +37 -0
- mojo/apps/logit/migrations/0001_initial.py +32 -0
- mojo/apps/logit/migrations/0002_log_duid_log_payload_log_username.py +28 -0
- mojo/apps/logit/migrations/0003_log_level.py +18 -0
- mojo/apps/logit/migrations/__init__.py +0 -0
- mojo/apps/logit/models/__init__.py +1 -0
- mojo/apps/logit/models/log.py +57 -0
- mojo/apps/logit/rest.py +9 -0
- mojo/apps/metrics/README.md +79 -0
- mojo/apps/metrics/__init__.py +12 -0
- mojo/apps/metrics/redis_metrics.py +331 -0
- mojo/apps/metrics/rest/__init__.py +1 -0
- mojo/apps/metrics/rest/base.py +152 -0
- mojo/apps/metrics/rest/db.py +0 -0
- mojo/apps/metrics/utils.py +227 -0
- mojo/apps/notify/README.md +91 -0
- mojo/apps/notify/README_NOTIFICATIONS.md +566 -0
- mojo/apps/notify/__init__.py +0 -0
- mojo/apps/notify/admin.py +52 -0
- mojo/apps/notify/handlers/__init__.py +0 -0
- mojo/apps/notify/handlers/example_handlers.py +516 -0
- mojo/apps/notify/handlers/ses/__init__.py +25 -0
- mojo/apps/notify/handlers/ses/bounce.py +0 -0
- mojo/apps/notify/handlers/ses/complaint.py +25 -0
- mojo/apps/notify/handlers/ses/message.py +86 -0
- mojo/apps/notify/management/__init__.py +0 -0
- mojo/apps/notify/management/commands/__init__.py +1 -0
- mojo/apps/notify/management/commands/process_notifications.py +370 -0
- mojo/apps/notify/mod +0 -0
- mojo/apps/notify/models/__init__.py +12 -0
- mojo/apps/notify/models/account.py +128 -0
- mojo/apps/notify/models/attachment.py +24 -0
- mojo/apps/notify/models/bounce.py +68 -0
- mojo/apps/notify/models/complaint.py +40 -0
- mojo/apps/notify/models/inbox.py +113 -0
- mojo/apps/notify/models/inbox_message.py +173 -0
- mojo/apps/notify/models/outbox.py +129 -0
- mojo/apps/notify/models/outbox_message.py +288 -0
- mojo/apps/notify/models/template.py +30 -0
- mojo/apps/notify/providers/__init__.py +0 -0
- mojo/apps/notify/providers/aws.py +73 -0
- mojo/apps/notify/rest/__init__.py +0 -0
- mojo/apps/notify/rest/ses.py +0 -0
- mojo/apps/notify/utils/__init__.py +2 -0
- mojo/apps/notify/utils/notifications.py +404 -0
- mojo/apps/notify/utils/parsing.py +202 -0
- mojo/apps/notify/utils/render.py +144 -0
- mojo/apps/tasks/README.md +118 -0
- mojo/apps/tasks/__init__.py +11 -0
- mojo/apps/tasks/manager.py +489 -0
- mojo/apps/tasks/rest/__init__.py +2 -0
- mojo/apps/tasks/rest/hooks.py +0 -0
- mojo/apps/tasks/rest/tasks.py +62 -0
- mojo/apps/tasks/runner.py +174 -0
- mojo/apps/tasks/tq_handlers.py +14 -0
- mojo/decorators/__init__.py +3 -0
- mojo/decorators/auth.py +25 -0
- mojo/decorators/cron.py +31 -0
- mojo/decorators/http.py +132 -0
- mojo/decorators/validate.py +14 -0
- mojo/errors.py +88 -0
- mojo/helpers/__init__.py +0 -0
- mojo/helpers/aws/__init__.py +0 -0
- mojo/helpers/aws/client.py +8 -0
- mojo/helpers/aws/s3.py +268 -0
- mojo/helpers/aws/setup_email.py +0 -0
- mojo/helpers/cron.py +79 -0
- mojo/helpers/crypto/__init__.py +4 -0
- mojo/helpers/crypto/aes.py +60 -0
- mojo/helpers/crypto/hash.py +59 -0
- mojo/helpers/crypto/privpub/__init__.py +1 -0
- mojo/helpers/crypto/privpub/hybrid.py +97 -0
- mojo/helpers/crypto/privpub/rsa.py +104 -0
- mojo/helpers/crypto/sign.py +36 -0
- mojo/helpers/crypto/too.l.py +25 -0
- mojo/helpers/crypto/utils.py +26 -0
- mojo/helpers/daemon.py +94 -0
- mojo/helpers/dates.py +69 -0
- mojo/helpers/dns/__init__.py +0 -0
- mojo/helpers/dns/godaddy.py +62 -0
- mojo/helpers/filetypes.py +128 -0
- mojo/helpers/logit.py +310 -0
- mojo/helpers/modules.py +95 -0
- mojo/helpers/paths.py +63 -0
- mojo/helpers/redis.py +10 -0
- mojo/helpers/request.py +89 -0
- mojo/helpers/request_parser.py +269 -0
- mojo/helpers/response.py +14 -0
- mojo/helpers/settings.py +146 -0
- mojo/helpers/sysinfo.py +140 -0
- mojo/helpers/ua.py +0 -0
- mojo/middleware/__init__.py +0 -0
- mojo/middleware/auth.py +26 -0
- mojo/middleware/logging.py +55 -0
- mojo/middleware/mojo.py +21 -0
- mojo/migrations/0001_initial.py +32 -0
- mojo/migrations/__init__.py +0 -0
- mojo/models/__init__.py +2 -0
- mojo/models/meta.py +262 -0
- mojo/models/rest.py +538 -0
- mojo/models/secrets.py +59 -0
- mojo/rest/__init__.py +1 -0
- mojo/rest/info.py +26 -0
- mojo/serializers/__init__.py +0 -0
- mojo/serializers/models.py +165 -0
- mojo/serializers/openapi.py +188 -0
- mojo/urls.py +38 -0
- mojo/ws4redis/README.md +174 -0
- mojo/ws4redis/__init__.py +2 -0
- mojo/ws4redis/client.py +283 -0
- mojo/ws4redis/connection.py +327 -0
- mojo/ws4redis/exceptions.py +32 -0
- mojo/ws4redis/redis.py +183 -0
- mojo/ws4redis/servers/__init__.py +0 -0
- mojo/ws4redis/servers/base.py +86 -0
- mojo/ws4redis/servers/django.py +171 -0
- mojo/ws4redis/servers/uwsgi.py +63 -0
- mojo/ws4redis/settings.py +45 -0
- mojo/ws4redis/utf8validator.py +128 -0
- mojo/ws4redis/websocket.py +403 -0
- testit/__init__.py +0 -0
- testit/client.py +147 -0
- testit/faker.py +20 -0
- testit/helpers.py +198 -0
- 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
|