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.
- {stackport-0.2.5/stackport.egg-info → stackport-0.2.6}/PKG-INFO +1 -1
- stackport-0.2.6/backend/routes/s3.py +666 -0
- stackport-0.2.6/backend/schemas/s3.py +103 -0
- {stackport-0.2.5 → stackport-0.2.6}/pyproject.toml +1 -1
- {stackport-0.2.5 → stackport-0.2.6/stackport.egg-info}/PKG-INFO +1 -1
- {stackport-0.2.5 → stackport-0.2.6}/stackport.egg-info/SOURCES.txt +4 -4
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_s3_routes.py +228 -0
- stackport-0.2.6/ui/dist/assets/index-C-DyQfZs.css +1 -0
- stackport-0.2.6/ui/dist/assets/index-DYRN3E1e.js +614 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/index.html +2 -2
- stackport-0.2.5/backend/routes/s3.py +0 -343
- stackport-0.2.5/backend/schemas/s3.py +0 -30
- stackport-0.2.5/ui/dist/assets/index-BXF-C5Pz.js +0 -614
- stackport-0.2.5/ui/dist/assets/index-MwTr-ebs.css +0 -1
- {stackport-0.2.5 → stackport-0.2.6}/LICENSE +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/MANIFEST.in +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/README.md +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/__init__.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/aws_client.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/cache.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/cli.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/config.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/main.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/routes/__init__.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/routes/common.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/routes/dynamodb.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/routes/ec2.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/routes/endpoints.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/routes/iam.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/routes/lambda_svc.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/routes/logs.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/routes/resources.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/routes/secretsmanager.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/routes/sqs.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/routes/stats.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/routes/tags.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/schemas/__init__.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/schemas/dynamodb.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/schemas/sqs.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/schemas/tags.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/backend/websocket.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/setup.cfg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/stackport.egg-info/dependency_links.txt +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/stackport.egg-info/entry_points.txt +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/stackport.egg-info/requires.txt +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/stackport.egg-info/top_level.txt +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_cache.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_cli.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_client.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_config.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_dynamodb_routes.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_ec2_routes.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_endpoints.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_iam_routes.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_lambda_routes.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_logs_routes.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_multi_endpoint.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_readonly_middleware.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_registries.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_routes.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_s3_upload_limit_env.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_secretsmanager_routes.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_sqs_routes.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_tags_routes.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/tests/test_websocket.py +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/acm.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/apigateway.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/appsync.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/athena.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/cloudformation.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/cloudfront.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/cognito-idp.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/dynamodb.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/ec2.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/ecr.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/ecs.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/elasticache.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/events.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/firehose.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/glue.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/iam.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/kinesis.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/kms.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/lambda.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/logs.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/monitoring.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/rds.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/route53.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/s3.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/secretsmanager.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/ses.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/sns.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/sqs.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/ssm.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/stepfunctions.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/aws-icons/wafv2.svg +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/favicon.png +0 -0
- {stackport-0.2.5 → stackport-0.2.6}/ui/dist/favicon.svg +0 -0
|
@@ -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
|