stackport 0.2.5__tar.gz → 0.2.6__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 (101) hide show
  1. {stackport-0.2.5/stackport.egg-info → stackport-0.2.6}/PKG-INFO +1 -1
  2. stackport-0.2.6/backend/routes/s3.py +666 -0
  3. stackport-0.2.6/backend/schemas/s3.py +103 -0
  4. {stackport-0.2.5 → stackport-0.2.6}/pyproject.toml +1 -1
  5. {stackport-0.2.5 → stackport-0.2.6/stackport.egg-info}/PKG-INFO +1 -1
  6. {stackport-0.2.5 → stackport-0.2.6}/stackport.egg-info/SOURCES.txt +4 -4
  7. {stackport-0.2.5 → stackport-0.2.6}/tests/test_s3_routes.py +228 -0
  8. stackport-0.2.6/ui/dist/assets/index-C-DyQfZs.css +1 -0
  9. stackport-0.2.6/ui/dist/assets/index-DYRN3E1e.js +614 -0
  10. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/index.html +2 -2
  11. stackport-0.2.5/backend/routes/s3.py +0 -343
  12. stackport-0.2.5/backend/schemas/s3.py +0 -30
  13. stackport-0.2.5/ui/dist/assets/index-BXF-C5Pz.js +0 -614
  14. stackport-0.2.5/ui/dist/assets/index-MwTr-ebs.css +0 -1
  15. {stackport-0.2.5 → stackport-0.2.6}/LICENSE +0 -0
  16. {stackport-0.2.5 → stackport-0.2.6}/MANIFEST.in +0 -0
  17. {stackport-0.2.5 → stackport-0.2.6}/README.md +0 -0
  18. {stackport-0.2.5 → stackport-0.2.6}/backend/__init__.py +0 -0
  19. {stackport-0.2.5 → stackport-0.2.6}/backend/aws_client.py +0 -0
  20. {stackport-0.2.5 → stackport-0.2.6}/backend/cache.py +0 -0
  21. {stackport-0.2.5 → stackport-0.2.6}/backend/cli.py +0 -0
  22. {stackport-0.2.5 → stackport-0.2.6}/backend/config.py +0 -0
  23. {stackport-0.2.5 → stackport-0.2.6}/backend/main.py +0 -0
  24. {stackport-0.2.5 → stackport-0.2.6}/backend/routes/__init__.py +0 -0
  25. {stackport-0.2.5 → stackport-0.2.6}/backend/routes/common.py +0 -0
  26. {stackport-0.2.5 → stackport-0.2.6}/backend/routes/dynamodb.py +0 -0
  27. {stackport-0.2.5 → stackport-0.2.6}/backend/routes/ec2.py +0 -0
  28. {stackport-0.2.5 → stackport-0.2.6}/backend/routes/endpoints.py +0 -0
  29. {stackport-0.2.5 → stackport-0.2.6}/backend/routes/iam.py +0 -0
  30. {stackport-0.2.5 → stackport-0.2.6}/backend/routes/lambda_svc.py +0 -0
  31. {stackport-0.2.5 → stackport-0.2.6}/backend/routes/logs.py +0 -0
  32. {stackport-0.2.5 → stackport-0.2.6}/backend/routes/resources.py +0 -0
  33. {stackport-0.2.5 → stackport-0.2.6}/backend/routes/secretsmanager.py +0 -0
  34. {stackport-0.2.5 → stackport-0.2.6}/backend/routes/sqs.py +0 -0
  35. {stackport-0.2.5 → stackport-0.2.6}/backend/routes/stats.py +0 -0
  36. {stackport-0.2.5 → stackport-0.2.6}/backend/routes/tags.py +0 -0
  37. {stackport-0.2.5 → stackport-0.2.6}/backend/schemas/__init__.py +0 -0
  38. {stackport-0.2.5 → stackport-0.2.6}/backend/schemas/dynamodb.py +0 -0
  39. {stackport-0.2.5 → stackport-0.2.6}/backend/schemas/sqs.py +0 -0
  40. {stackport-0.2.5 → stackport-0.2.6}/backend/schemas/tags.py +0 -0
  41. {stackport-0.2.5 → stackport-0.2.6}/backend/websocket.py +0 -0
  42. {stackport-0.2.5 → stackport-0.2.6}/setup.cfg +0 -0
  43. {stackport-0.2.5 → stackport-0.2.6}/stackport.egg-info/dependency_links.txt +0 -0
  44. {stackport-0.2.5 → stackport-0.2.6}/stackport.egg-info/entry_points.txt +0 -0
  45. {stackport-0.2.5 → stackport-0.2.6}/stackport.egg-info/requires.txt +0 -0
  46. {stackport-0.2.5 → stackport-0.2.6}/stackport.egg-info/top_level.txt +0 -0
  47. {stackport-0.2.5 → stackport-0.2.6}/tests/test_cache.py +0 -0
  48. {stackport-0.2.5 → stackport-0.2.6}/tests/test_cli.py +0 -0
  49. {stackport-0.2.5 → stackport-0.2.6}/tests/test_client.py +0 -0
  50. {stackport-0.2.5 → stackport-0.2.6}/tests/test_config.py +0 -0
  51. {stackport-0.2.5 → stackport-0.2.6}/tests/test_dynamodb_routes.py +0 -0
  52. {stackport-0.2.5 → stackport-0.2.6}/tests/test_ec2_routes.py +0 -0
  53. {stackport-0.2.5 → stackport-0.2.6}/tests/test_endpoints.py +0 -0
  54. {stackport-0.2.5 → stackport-0.2.6}/tests/test_iam_routes.py +0 -0
  55. {stackport-0.2.5 → stackport-0.2.6}/tests/test_lambda_routes.py +0 -0
  56. {stackport-0.2.5 → stackport-0.2.6}/tests/test_logs_routes.py +0 -0
  57. {stackport-0.2.5 → stackport-0.2.6}/tests/test_multi_endpoint.py +0 -0
  58. {stackport-0.2.5 → stackport-0.2.6}/tests/test_readonly_middleware.py +0 -0
  59. {stackport-0.2.5 → stackport-0.2.6}/tests/test_registries.py +0 -0
  60. {stackport-0.2.5 → stackport-0.2.6}/tests/test_routes.py +0 -0
  61. {stackport-0.2.5 → stackport-0.2.6}/tests/test_s3_upload_limit_env.py +0 -0
  62. {stackport-0.2.5 → stackport-0.2.6}/tests/test_secretsmanager_routes.py +0 -0
  63. {stackport-0.2.5 → stackport-0.2.6}/tests/test_sqs_routes.py +0 -0
  64. {stackport-0.2.5 → stackport-0.2.6}/tests/test_tags_routes.py +0 -0
  65. {stackport-0.2.5 → stackport-0.2.6}/tests/test_websocket.py +0 -0
  66. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/acm.svg +0 -0
  67. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/apigateway.svg +0 -0
  68. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/appsync.svg +0 -0
  69. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/athena.svg +0 -0
  70. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/cloudformation.svg +0 -0
  71. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/cloudfront.svg +0 -0
  72. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/cognito-idp.svg +0 -0
  73. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/dynamodb.svg +0 -0
  74. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/ec2.svg +0 -0
  75. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/ecr.svg +0 -0
  76. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/ecs.svg +0 -0
  77. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/elasticache.svg +0 -0
  78. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
  79. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
  80. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
  81. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/events.svg +0 -0
  82. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/firehose.svg +0 -0
  83. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/glue.svg +0 -0
  84. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/iam.svg +0 -0
  85. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/kinesis.svg +0 -0
  86. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/kms.svg +0 -0
  87. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/lambda.svg +0 -0
  88. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/logs.svg +0 -0
  89. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/monitoring.svg +0 -0
  90. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/rds.svg +0 -0
  91. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/route53.svg +0 -0
  92. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/s3.svg +0 -0
  93. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/secretsmanager.svg +0 -0
  94. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/ses.svg +0 -0
  95. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/sns.svg +0 -0
  96. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/sqs.svg +0 -0
  97. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/ssm.svg +0 -0
  98. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/stepfunctions.svg +0 -0
  99. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/wafv2.svg +0 -0
  100. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/favicon.png +0 -0
  101. {stackport-0.2.5 → stackport-0.2.6}/ui/dist/favicon.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -0,0 +1,666 @@
1
+ import logging
2
+ import mimetypes
3
+ import os
4
+ from typing import Annotated
5
+
6
+ from botocore.exceptions import ClientError
7
+ from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
8
+ from fastapi.responses import StreamingResponse
9
+ from backend.aws_client import get_client
10
+ from backend.cache import cache
11
+ from backend.config import AWS_REGION, S3_MAX_UPLOAD_BYTES, is_local_endpoint
12
+ from backend.routes.common import get_endpoint_url
13
+ from backend.schemas.s3 import (
14
+ CreateFolderBody,
15
+ DeleteBatchBody,
16
+ PutVersioningBody,
17
+ PutLifecycleBody,
18
+ PutNotificationsBody,
19
+ PutBucketTagsBody,
20
+ PutCORSBody,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ router = APIRouter()
26
+
27
+
28
+ def _is_s3_not_found(err: ClientError) -> bool:
29
+ code = err.response.get("Error", {}).get("Code", "")
30
+ status = err.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
31
+ return code in ("404", "NoSuchKey", "NotFound") or status == 404
32
+
33
+
34
+ def _invalidate_bucket_stats(bucket_name: str, endpoint_url: str | None) -> None:
35
+ cache.delete(f"{endpoint_url}:s3:bucket_stats:{bucket_name}")
36
+
37
+
38
+ def _validate_key_component(name: str) -> str:
39
+ base = os.path.basename(name.replace("\\", "/"))
40
+ if not base or base in (".", ".."):
41
+ raise HTTPException(status_code=400, detail="Invalid file name")
42
+ return base
43
+
44
+
45
+ def _validate_prefix_path(prefix: str) -> str:
46
+ if ".." in prefix or prefix.startswith("/"):
47
+ raise HTTPException(status_code=400, detail="Invalid prefix")
48
+ return prefix
49
+
50
+
51
+ def _compose_object_key(prefix: str, filename: str) -> str:
52
+ prefix = _validate_prefix_path(prefix or "")
53
+ fn = _validate_key_component(filename)
54
+ if prefix and not prefix.endswith("/"):
55
+ prefix = prefix + "/"
56
+ return prefix + fn
57
+
58
+
59
+ def _resolve_upload_content_type(filename: str, browser_type: str | None) -> str:
60
+ guessed, _ = mimetypes.guess_type(filename)
61
+ bt = (browser_type or "").strip() or None
62
+ if not bt or bt == "application/octet-stream":
63
+ return guessed or "application/octet-stream"
64
+ if guessed and guessed != bt:
65
+ return guessed
66
+ return bt or guessed or "application/octet-stream"
67
+
68
+
69
+ @router.get("/upload-config")
70
+ def s3_upload_config():
71
+ return {"max_upload_bytes": S3_MAX_UPLOAD_BYTES}
72
+
73
+
74
+ def _get_bucket_stats(bucket_name: str, endpoint_url: str | None) -> tuple[int, int]:
75
+ """Return (object_count, total_size_bytes) for a bucket. Cached 30s.
76
+
77
+ On real AWS this enumerates every object, which can be very slow for large
78
+ buckets. Only perform the full scan when targeting a local emulator.
79
+ """
80
+ if not is_local_endpoint(endpoint_url):
81
+ return (0, 0)
82
+
83
+ cache_key = f"{endpoint_url}:s3:bucket_stats:{bucket_name}"
84
+ cached = cache.get(cache_key)
85
+ if cached is not None:
86
+ return cached
87
+
88
+ s3 = get_client("s3", endpoint_url)
89
+ paginator = s3.get_paginator("list_objects_v2")
90
+ obj_count = 0
91
+ total_size = 0
92
+
93
+ try:
94
+ for page in paginator.paginate(Bucket=bucket_name):
95
+ for obj in page.get("Contents", []):
96
+ obj_count += 1
97
+ total_size += obj.get("Size", 0)
98
+ except Exception:
99
+ logger.debug("Failed to get bucket stats for %s", bucket_name, exc_info=True)
100
+
101
+ result = (obj_count, total_size)
102
+ cache.set(cache_key, result, ttl=30)
103
+ return result
104
+
105
+
106
+ @router.get("/buckets")
107
+ def list_buckets(endpoint_url: str | None = Depends(get_endpoint_url)):
108
+ s3 = get_client("s3", endpoint_url)
109
+ response = s3.list_buckets()
110
+ buckets = []
111
+ local = is_local_endpoint(endpoint_url)
112
+
113
+ for b in response.get("Buckets", []):
114
+ name = b["Name"]
115
+ obj_count, total_size = _get_bucket_stats(name, endpoint_url)
116
+
117
+ versioning = "Disabled"
118
+ encryption = "Disabled"
119
+ tags: dict[str, str] = {}
120
+
121
+ if local:
122
+ try:
123
+ ver = s3.get_bucket_versioning(Bucket=name)
124
+ versioning = ver.get("Status", "Disabled")
125
+ except Exception:
126
+ logger.debug("Failed to get versioning for %s", name, exc_info=True)
127
+
128
+ try:
129
+ s3.get_bucket_encryption(Bucket=name)
130
+ encryption = "Enabled"
131
+ except Exception:
132
+ logger.debug("Failed to get encryption for %s", name, exc_info=True)
133
+
134
+ try:
135
+ tag_resp = s3.get_bucket_tagging(Bucket=name)
136
+ tags = {t["Key"]: t["Value"] for t in tag_resp.get("TagSet", [])}
137
+ except Exception:
138
+ logger.debug("Failed to get tags for %s", name, exc_info=True)
139
+
140
+ buckets.append(
141
+ {
142
+ "name": name,
143
+ "created": b["CreationDate"].isoformat(),
144
+ "region": AWS_REGION,
145
+ "object_count": obj_count,
146
+ "total_size": total_size,
147
+ "versioning": versioning,
148
+ "encryption": encryption,
149
+ "tags": tags,
150
+ }
151
+ )
152
+
153
+ return {"buckets": buckets}
154
+
155
+
156
+ @router.get("/buckets/{name}/objects")
157
+ def list_objects(
158
+ name: str,
159
+ prefix: str = Query(default="", description="Key prefix filter"),
160
+ delimiter: str = Query(default="/", description="Hierarchy delimiter"),
161
+ endpoint_url: str | None = Depends(get_endpoint_url),
162
+ ):
163
+ s3 = get_client("s3", endpoint_url)
164
+ paginator = s3.get_paginator("list_objects_v2")
165
+
166
+ folders: list[str] = []
167
+ files: list[dict] = []
168
+
169
+ paginate_params: dict = {"Bucket": name, "Prefix": prefix}
170
+ if delimiter:
171
+ paginate_params["Delimiter"] = delimiter
172
+
173
+ for page in paginator.paginate(**paginate_params):
174
+ for cp in page.get("CommonPrefixes", []):
175
+ folders.append(cp["Prefix"])
176
+ for obj in page.get("Contents", []):
177
+ key = obj["Key"]
178
+ if key == prefix:
179
+ continue
180
+ file_name = key[len(prefix) :] if prefix else key
181
+ files.append(
182
+ {
183
+ "key": key,
184
+ "name": file_name,
185
+ "size": obj["Size"],
186
+ "content_type": "application/octet-stream",
187
+ "etag": obj["ETag"].strip('"'),
188
+ "last_modified": obj["LastModified"].isoformat(),
189
+ }
190
+ )
191
+
192
+ return {
193
+ "bucket": name,
194
+ "prefix": prefix,
195
+ "delimiter": delimiter,
196
+ "folders": folders,
197
+ "files": files,
198
+ }
199
+
200
+
201
+
202
+ def _validate_object_key(key: str) -> None:
203
+ if ".." in key or key.startswith("/"):
204
+ raise HTTPException(status_code=400, detail="Invalid key")
205
+
206
+
207
+ @router.post("/buckets/{name}/objects/delete-batch")
208
+ def delete_objects_batch(name: str, body: DeleteBatchBody, endpoint_url: str | None = Depends(get_endpoint_url)):
209
+ """Delete multiple objects by key list or all keys under a prefix."""
210
+ s3 = get_client("s3", endpoint_url)
211
+
212
+ keys_to_delete: list[str]
213
+ if body.prefix:
214
+ p = body.prefix
215
+ if not p.endswith("/"):
216
+ p = p + "/"
217
+ _validate_prefix_path(p.rstrip("/") or "")
218
+ keys_to_delete = []
219
+ paginator = s3.get_paginator("list_objects_v2")
220
+ for page in paginator.paginate(Bucket=name, Prefix=p):
221
+ for obj in page.get("Contents", []):
222
+ keys_to_delete.append(obj["Key"])
223
+ if not keys_to_delete:
224
+ _invalidate_bucket_stats(name, endpoint_url)
225
+ return {"bucket": name, "deleted": 0, "keys": []}
226
+ else:
227
+ keys_to_delete = list(body.keys or [])
228
+ for k in keys_to_delete:
229
+ _validate_object_key(k)
230
+
231
+ deleted = 0
232
+ for i in range(0, len(keys_to_delete), 1000):
233
+ chunk = keys_to_delete[i : i + 1000]
234
+ resp = s3.delete_objects(
235
+ Bucket=name,
236
+ Delete={"Objects": [{"Key": k} for k in chunk], "Quiet": True},
237
+ )
238
+ deleted += len(chunk) - len(resp.get("Errors", []))
239
+ if resp.get("Errors"):
240
+ for err in resp["Errors"]:
241
+ logger.warning("S3 delete error: %s", err)
242
+
243
+ _invalidate_bucket_stats(name, endpoint_url)
244
+ return {"bucket": name, "deleted": deleted, "keys": keys_to_delete}
245
+
246
+
247
+ @router.post("/buckets/{name}/folders")
248
+ def create_folder(name: str, body: CreateFolderBody, endpoint_url: str | None = Depends(get_endpoint_url)):
249
+ """Create a folder marker (zero-byte object with trailing /)."""
250
+ prefix = body.prefix
251
+ _validate_prefix_path(prefix.rstrip("/"))
252
+ s3 = get_client("s3", endpoint_url)
253
+ s3.put_object(Bucket=name, Key=prefix, Body=b"", ContentType="application/x-directory")
254
+ _invalidate_bucket_stats(name, endpoint_url)
255
+ return {"bucket": name, "prefix": prefix}
256
+
257
+
258
+ @router.post("/buckets/{name}/objects")
259
+ def upload_object(
260
+ name: str,
261
+ prefix: Annotated[str, Query(description="Key prefix for uploaded object")] = "",
262
+ file: UploadFile = File(..., description="File to upload"),
263
+ endpoint_url: str | None = Depends(get_endpoint_url),
264
+ ):
265
+ filename = file.filename or "object"
266
+ object_key = _compose_object_key(prefix, filename)
267
+
268
+ body = file.file.read(S3_MAX_UPLOAD_BYTES + 1)
269
+ if len(body) > S3_MAX_UPLOAD_BYTES:
270
+ raise HTTPException(
271
+ status_code=413,
272
+ detail=f"File exceeds maximum size of {S3_MAX_UPLOAD_BYTES} bytes",
273
+ )
274
+
275
+ content_type = _resolve_upload_content_type(filename, file.content_type)
276
+
277
+ s3 = get_client("s3", endpoint_url)
278
+ s3.put_object(
279
+ Bucket=name,
280
+ Key=object_key,
281
+ Body=body,
282
+ ContentType=content_type,
283
+ )
284
+ _invalidate_bucket_stats(name, endpoint_url)
285
+ return {
286
+ "bucket": name,
287
+ "key": object_key,
288
+ "size": len(body),
289
+ "content_type": content_type,
290
+ }
291
+
292
+
293
+ @router.delete("/buckets/{name}/objects/{key:path}")
294
+ def delete_object(name: str, key: str, endpoint_url: str | None = Depends(get_endpoint_url)):
295
+ _validate_object_key(key)
296
+ s3 = get_client("s3", endpoint_url)
297
+ s3.delete_object(Bucket=name, Key=key)
298
+ _invalidate_bucket_stats(name, endpoint_url)
299
+ return {"bucket": name, "deleted": True, "key": key}
300
+
301
+
302
+ @router.get("/buckets/{name}/objects/{key:path}")
303
+ def get_object_detail(
304
+ name: str,
305
+ key: str,
306
+ download: int = Query(default=0, description="Set to 1 to download the object"),
307
+ endpoint_url: str | None = Depends(get_endpoint_url),
308
+ ):
309
+ s3 = get_client("s3", endpoint_url)
310
+
311
+ if download == 1:
312
+ try:
313
+ resp = s3.get_object(Bucket=name, Key=key)
314
+ except ClientError as e:
315
+ if _is_s3_not_found(e):
316
+ raise HTTPException(status_code=404, detail="Object not found") from e
317
+ raise
318
+ filename = key.rsplit("/", 1)[-1] or key
319
+ return StreamingResponse(
320
+ resp["Body"],
321
+ media_type=resp.get("ContentType", "application/octet-stream"),
322
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
323
+ )
324
+
325
+ try:
326
+ resp = s3.head_object(Bucket=name, Key=key)
327
+ except ClientError as e:
328
+ if _is_s3_not_found(e):
329
+ raise HTTPException(status_code=404, detail="Object not found") from e
330
+ raise
331
+
332
+ tags: dict[str, str] = {}
333
+ try:
334
+ tag_resp = s3.get_object_tagging(Bucket=name, Key=key)
335
+ tags = {t["Key"]: t["Value"] for t in tag_resp.get("TagSet", [])}
336
+ except Exception:
337
+ logger.debug("Failed to get object tags for %s/%s", name, key, exc_info=True)
338
+
339
+ return {
340
+ "bucket": name,
341
+ "key": key,
342
+ "size": resp["ContentLength"],
343
+ "content_type": resp.get("ContentType", "application/octet-stream"),
344
+ "content_encoding": resp.get("ContentEncoding"),
345
+ "etag": resp["ETag"].strip('"'),
346
+ "last_modified": resp["LastModified"].isoformat(),
347
+ "version_id": resp.get("VersionId"),
348
+ "metadata": resp.get("Metadata", {}),
349
+ "preserved_headers": {},
350
+ "tags": tags,
351
+ }
352
+
353
+
354
+ @router.get("/buckets/{name}/versioning")
355
+ def get_bucket_versioning(name: str, endpoint_url: str | None = Depends(get_endpoint_url)):
356
+ """Get bucket versioning status."""
357
+ s3 = get_client("s3", endpoint_url)
358
+ try:
359
+ resp = s3.get_bucket_versioning(Bucket=name)
360
+ status = resp.get("Status", "Disabled")
361
+ mfa_delete = resp.get("MFADelete", "Disabled")
362
+ return {"bucket": name, "status": status, "mfa_delete": mfa_delete}
363
+ except ClientError as e:
364
+ code = e.response.get("Error", {}).get("Code", "")
365
+ if code in ("NotImplemented", "MethodNotAllowed"):
366
+ raise HTTPException(status_code=501, detail="Versioning not supported by this endpoint") from e
367
+ raise
368
+
369
+
370
+ @router.put("/buckets/{name}/versioning")
371
+ def put_bucket_versioning(
372
+ name: str,
373
+ body: PutVersioningBody,
374
+ endpoint_url: str | None = Depends(get_endpoint_url),
375
+ ):
376
+ """Enable or suspend bucket versioning."""
377
+ s3 = get_client("s3", endpoint_url)
378
+ try:
379
+ s3.put_bucket_versioning(
380
+ Bucket=name,
381
+ VersioningConfiguration={"Status": body.status},
382
+ )
383
+ return {"bucket": name, "status": body.status}
384
+ except ClientError as e:
385
+ code = e.response.get("Error", {}).get("Code", "")
386
+ if code in ("NotImplemented", "MethodNotAllowed"):
387
+ raise HTTPException(status_code=501, detail="Versioning not supported by this endpoint") from e
388
+ raise
389
+
390
+
391
+ @router.get("/buckets/{name}/lifecycle")
392
+ def get_bucket_lifecycle(name: str, endpoint_url: str | None = Depends(get_endpoint_url)):
393
+ """Get bucket lifecycle configuration."""
394
+ s3 = get_client("s3", endpoint_url)
395
+ try:
396
+ resp = s3.get_bucket_lifecycle_configuration(Bucket=name)
397
+ rules = []
398
+ for rule in resp.get("Rules", []):
399
+ rules.append({
400
+ "id": rule["ID"],
401
+ "prefix": rule.get("Filter", {}).get("Prefix", ""),
402
+ "expiration_days": rule.get("Expiration", {}).get("Days", 0),
403
+ "enabled": rule["Status"] == "Enabled",
404
+ })
405
+ return {"bucket": name, "rules": rules}
406
+ except ClientError as e:
407
+ code = e.response.get("Error", {}).get("Code", "")
408
+ if code in ("NoSuchLifecycleConfiguration", "404"):
409
+ return {"bucket": name, "rules": []}
410
+ if code in ("NotImplemented", "MethodNotAllowed"):
411
+ raise HTTPException(status_code=501, detail="Lifecycle not supported by this endpoint") from e
412
+ raise
413
+
414
+
415
+ @router.put("/buckets/{name}/lifecycle")
416
+ def put_bucket_lifecycle(
417
+ name: str,
418
+ body: PutLifecycleBody,
419
+ endpoint_url: str | None = Depends(get_endpoint_url),
420
+ ):
421
+ """Set bucket lifecycle configuration."""
422
+ s3 = get_client("s3", endpoint_url)
423
+ rules = []
424
+ for rule in body.rules:
425
+ lifecycle_rule = {
426
+ "ID": rule.id,
427
+ "Status": "Enabled" if rule.enabled else "Disabled",
428
+ "Filter": {"Prefix": rule.prefix},
429
+ "Expiration": {"Days": rule.expiration_days},
430
+ }
431
+ rules.append(lifecycle_rule)
432
+
433
+ try:
434
+ s3.put_bucket_lifecycle_configuration(
435
+ Bucket=name,
436
+ LifecycleConfiguration={"Rules": rules},
437
+ )
438
+ return {"bucket": name, "rules_count": len(rules)}
439
+ except ClientError as e:
440
+ code = e.response.get("Error", {}).get("Code", "")
441
+ if code in ("NotImplemented", "MethodNotAllowed"):
442
+ raise HTTPException(status_code=501, detail="Lifecycle not supported by this endpoint") from e
443
+ raise
444
+
445
+
446
+ @router.delete("/buckets/{name}/lifecycle")
447
+ def delete_bucket_lifecycle(name: str, endpoint_url: str | None = Depends(get_endpoint_url)):
448
+ """Delete bucket lifecycle configuration."""
449
+ s3 = get_client("s3", endpoint_url)
450
+ try:
451
+ s3.delete_bucket_lifecycle(Bucket=name)
452
+ return {"bucket": name, "deleted": True}
453
+ except ClientError as e:
454
+ code = e.response.get("Error", {}).get("Code", "")
455
+ if code in ("NoSuchLifecycleConfiguration", "404"):
456
+ return {"bucket": name, "deleted": False, "reason": "No lifecycle configuration"}
457
+ if code in ("NotImplemented", "MethodNotAllowed"):
458
+ raise HTTPException(status_code=501, detail="Lifecycle not supported by this endpoint") from e
459
+ raise
460
+
461
+
462
+ @router.get("/buckets/{name}/notifications")
463
+ def get_bucket_notifications(name: str, endpoint_url: str | None = Depends(get_endpoint_url)):
464
+ """Get bucket notification configuration."""
465
+ s3 = get_client("s3", endpoint_url)
466
+ try:
467
+ resp = s3.get_bucket_notification_configuration(Bucket=name)
468
+ configurations = []
469
+
470
+ for config in resp.get("LambdaFunctionConfigurations", []):
471
+ configurations.append({
472
+ "id": config["Id"],
473
+ "destination_type": "Lambda",
474
+ "destination_arn": config["LambdaFunctionArn"],
475
+ "events": config["Events"],
476
+ "filter_prefix": config.get("Filter", {}).get("Key", {}).get("FilterRules", [{}])[0].get("Value", "") if config.get("Filter") else "",
477
+ "filter_suffix": "",
478
+ })
479
+
480
+ for config in resp.get("QueueConfigurations", []):
481
+ configurations.append({
482
+ "id": config["Id"],
483
+ "destination_type": "SQS",
484
+ "destination_arn": config["QueueArn"],
485
+ "events": config["Events"],
486
+ "filter_prefix": config.get("Filter", {}).get("Key", {}).get("FilterRules", [{}])[0].get("Value", "") if config.get("Filter") else "",
487
+ "filter_suffix": "",
488
+ })
489
+
490
+ for config in resp.get("TopicConfigurations", []):
491
+ configurations.append({
492
+ "id": config["Id"],
493
+ "destination_type": "SNS",
494
+ "destination_arn": config["TopicArn"],
495
+ "events": config["Events"],
496
+ "filter_prefix": config.get("Filter", {}).get("Key", {}).get("FilterRules", [{}])[0].get("Value", "") if config.get("Filter") else "",
497
+ "filter_suffix": "",
498
+ })
499
+
500
+ return {"bucket": name, "configurations": configurations}
501
+ except ClientError as e:
502
+ code = e.response.get("Error", {}).get("Code", "")
503
+ if code in ("NotImplemented", "MethodNotAllowed"):
504
+ raise HTTPException(status_code=501, detail="Notifications not supported by this endpoint") from e
505
+ raise
506
+
507
+
508
+ @router.put("/buckets/{name}/notifications")
509
+ def put_bucket_notifications(
510
+ name: str,
511
+ body: PutNotificationsBody,
512
+ endpoint_url: str | None = Depends(get_endpoint_url),
513
+ ):
514
+ """Set bucket notification configuration."""
515
+ s3 = get_client("s3", endpoint_url)
516
+
517
+ lambda_configs = []
518
+ queue_configs = []
519
+ topic_configs = []
520
+
521
+ for config in body.configurations:
522
+ filter_rules = []
523
+ if config.filter_prefix:
524
+ filter_rules.append({"Name": "prefix", "Value": config.filter_prefix})
525
+ if config.filter_suffix:
526
+ filter_rules.append({"Name": "suffix", "Value": config.filter_suffix})
527
+
528
+ notification_config = {
529
+ "Id": config.id,
530
+ "Events": config.events,
531
+ }
532
+ if filter_rules:
533
+ notification_config["Filter"] = {"Key": {"FilterRules": filter_rules}}
534
+
535
+ if config.destination_type == "Lambda":
536
+ notification_config["LambdaFunctionArn"] = config.destination_arn
537
+ lambda_configs.append(notification_config)
538
+ elif config.destination_type == "SQS":
539
+ notification_config["QueueArn"] = config.destination_arn
540
+ queue_configs.append(notification_config)
541
+ elif config.destination_type == "SNS":
542
+ notification_config["TopicArn"] = config.destination_arn
543
+ topic_configs.append(notification_config)
544
+
545
+ notification_configuration = {}
546
+ if lambda_configs:
547
+ notification_configuration["LambdaFunctionConfigurations"] = lambda_configs
548
+ if queue_configs:
549
+ notification_configuration["QueueConfigurations"] = queue_configs
550
+ if topic_configs:
551
+ notification_configuration["TopicConfigurations"] = topic_configs
552
+
553
+ try:
554
+ s3.put_bucket_notification_configuration(
555
+ Bucket=name,
556
+ NotificationConfiguration=notification_configuration,
557
+ )
558
+ return {"bucket": name, "configurations_count": len(body.configurations)}
559
+ except ClientError as e:
560
+ code = e.response.get("Error", {}).get("Code", "")
561
+ if code in ("NotImplemented", "MethodNotAllowed"):
562
+ raise HTTPException(status_code=501, detail="Notifications not supported by this endpoint") from e
563
+ raise
564
+
565
+
566
+ @router.get("/buckets/{name}/tags")
567
+ def get_bucket_tags(name: str, endpoint_url: str | None = Depends(get_endpoint_url)):
568
+ """Get bucket tags."""
569
+ s3 = get_client("s3", endpoint_url)
570
+ try:
571
+ resp = s3.get_bucket_tagging(Bucket=name)
572
+ tags = {t["Key"]: t["Value"] for t in resp.get("TagSet", [])}
573
+ return {"bucket": name, "tags": tags}
574
+ except ClientError as e:
575
+ code = e.response.get("Error", {}).get("Code", "")
576
+ if code in ("NoSuchTagSet", "404"):
577
+ return {"bucket": name, "tags": {}}
578
+ if code in ("NotImplemented", "MethodNotAllowed"):
579
+ raise HTTPException(status_code=501, detail="Tags not supported by this endpoint") from e
580
+ raise
581
+
582
+
583
+ @router.put("/buckets/{name}/tags")
584
+ def put_bucket_tags(
585
+ name: str,
586
+ body: PutBucketTagsBody,
587
+ endpoint_url: str | None = Depends(get_endpoint_url),
588
+ ):
589
+ """Set bucket tags."""
590
+ s3 = get_client("s3", endpoint_url)
591
+ tag_set = [{"Key": k, "Value": v} for k, v in body.tags.items()]
592
+
593
+ try:
594
+ if tag_set:
595
+ s3.put_bucket_tagging(Bucket=name, Tagging={"TagSet": tag_set})
596
+ else:
597
+ s3.delete_bucket_tagging(Bucket=name)
598
+ return {"bucket": name, "tags": body.tags}
599
+ except ClientError as e:
600
+ code = e.response.get("Error", {}).get("Code", "")
601
+ if code in ("NotImplemented", "MethodNotAllowed"):
602
+ raise HTTPException(status_code=501, detail="Tags not supported by this endpoint") from e
603
+ raise
604
+
605
+
606
+ @router.get("/buckets/{name}/cors")
607
+ def get_bucket_cors(name: str, endpoint_url: str | None = Depends(get_endpoint_url)):
608
+ """Get bucket CORS configuration."""
609
+ s3 = get_client("s3", endpoint_url)
610
+ try:
611
+ resp = s3.get_bucket_cors(Bucket=name)
612
+ rules = []
613
+ for rule in resp.get("CORSRules", []):
614
+ rules.append({
615
+ "id": rule.get("ID"),
616
+ "allowed_origins": rule["AllowedOrigins"],
617
+ "allowed_methods": rule["AllowedMethods"],
618
+ "allowed_headers": rule.get("AllowedHeaders", []),
619
+ "expose_headers": rule.get("ExposeHeaders", []),
620
+ "max_age_seconds": rule.get("MaxAgeSeconds"),
621
+ })
622
+ return {"bucket": name, "rules": rules}
623
+ except ClientError as e:
624
+ code = e.response.get("Error", {}).get("Code", "")
625
+ if code in ("NoSuchCORSConfiguration", "404"):
626
+ return {"bucket": name, "rules": []}
627
+ if code in ("NotImplemented", "MethodNotAllowed"):
628
+ raise HTTPException(status_code=501, detail="CORS not supported by this endpoint") from e
629
+ raise
630
+
631
+
632
+ @router.put("/buckets/{name}/cors")
633
+ def put_bucket_cors(
634
+ name: str,
635
+ body: PutCORSBody,
636
+ endpoint_url: str | None = Depends(get_endpoint_url),
637
+ ):
638
+ """Set bucket CORS configuration."""
639
+ s3 = get_client("s3", endpoint_url)
640
+ rules = []
641
+ for rule in body.rules:
642
+ cors_rule: dict = {
643
+ "AllowedOrigins": rule.allowed_origins,
644
+ "AllowedMethods": rule.allowed_methods,
645
+ }
646
+ if rule.id:
647
+ cors_rule["ID"] = rule.id
648
+ if rule.allowed_headers:
649
+ cors_rule["AllowedHeaders"] = rule.allowed_headers
650
+ if rule.expose_headers:
651
+ cors_rule["ExposeHeaders"] = rule.expose_headers
652
+ if rule.max_age_seconds is not None:
653
+ cors_rule["MaxAgeSeconds"] = rule.max_age_seconds
654
+ rules.append(cors_rule)
655
+
656
+ try:
657
+ if rules:
658
+ s3.put_bucket_cors(Bucket=name, CORSConfiguration={"CORSRules": rules})
659
+ else:
660
+ s3.delete_bucket_cors(Bucket=name)
661
+ return {"bucket": name, "rules_count": len(rules)}
662
+ except ClientError as e:
663
+ code = e.response.get("Error", {}).get("Code", "")
664
+ if code in ("NotImplemented", "MethodNotAllowed"):
665
+ raise HTTPException(status_code=501, detail="CORS not supported by this endpoint") from e
666
+ raise