leblob 0.4.0.dev1__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.
- leblob/__init__.py +1 -0
- leblob/asgi.py +376 -0
- leblob/blobstore.py +594 -0
- leblob/cli.py +499 -0
- leblob/handlers.py +1119 -0
- leblob/maintenance.py +444 -0
- leblob/meta.py +2327 -0
- leblob/metrics.py +226 -0
- leblob/model.py +339 -0
- leblob/s3/__init__.py +1 -0
- leblob/s3/access.py +110 -0
- leblob/s3/chunked.py +175 -0
- leblob/s3/conditions.py +156 -0
- leblob/s3/cors.py +80 -0
- leblob/s3/errors.py +144 -0
- leblob/s3/lifecycle.py +119 -0
- leblob/s3/listing.py +196 -0
- leblob/s3/multipart.py +184 -0
- leblob/s3/payload.py +328 -0
- leblob/s3/post_form.py +344 -0
- leblob/s3/routing.py +443 -0
- leblob/s3/sigv4.py +333 -0
- leblob/s3/sse.py +229 -0
- leblob/s3/tagging.py +48 -0
- leblob/s3/xml.py +436 -0
- leblob/s3/xml_base.py +64 -0
- leblob/s3/xml_config.py +431 -0
- leblob/streams.py +179 -0
- leblob-0.4.0.dev1.dist-info/METADATA +109 -0
- leblob-0.4.0.dev1.dist-info/RECORD +33 -0
- leblob-0.4.0.dev1.dist-info/WHEEL +4 -0
- leblob-0.4.0.dev1.dist-info/entry_points.txt +3 -0
- leblob-0.4.0.dev1.dist-info/licenses/LICENSE +202 -0
leblob/handlers.py
ADDED
|
@@ -0,0 +1,1119 @@
|
|
|
1
|
+
"""One async function per S3 operation: the orchestration of everything else."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import aclosing
|
|
10
|
+
from dataclasses import dataclass, replace
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
from urllib.parse import quote, urlencode
|
|
13
|
+
|
|
14
|
+
from leblob import blobstore, meta, streams
|
|
15
|
+
from leblob.model import (
|
|
16
|
+
NULL_VERSION_ID,
|
|
17
|
+
ByteSpan,
|
|
18
|
+
ObjectRecord,
|
|
19
|
+
ObjectRef,
|
|
20
|
+
UploadExtras,
|
|
21
|
+
UploadRef,
|
|
22
|
+
)
|
|
23
|
+
from leblob.s3 import (
|
|
24
|
+
access,
|
|
25
|
+
conditions,
|
|
26
|
+
cors,
|
|
27
|
+
errors,
|
|
28
|
+
listing,
|
|
29
|
+
multipart,
|
|
30
|
+
payload,
|
|
31
|
+
post_form,
|
|
32
|
+
routing,
|
|
33
|
+
sigv4,
|
|
34
|
+
sse,
|
|
35
|
+
tagging,
|
|
36
|
+
xml,
|
|
37
|
+
xml_config,
|
|
38
|
+
)
|
|
39
|
+
from leblob.s3.access import Level
|
|
40
|
+
from leblob.s3.errors import S3Error
|
|
41
|
+
from leblob.s3.routing import Operation
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
from leblob.blobstore import BlobStore
|
|
49
|
+
from leblob.meta import Database
|
|
50
|
+
from leblob.model import (
|
|
51
|
+
Blob,
|
|
52
|
+
Checksum,
|
|
53
|
+
CorsRule,
|
|
54
|
+
DeleteOutcome,
|
|
55
|
+
)
|
|
56
|
+
from leblob.s3.access import Scope
|
|
57
|
+
from leblob.s3.sigv4 import StreamSeed
|
|
58
|
+
|
|
59
|
+
MAX_XML_BYTES = 4 * 1024 * 1024
|
|
60
|
+
MAX_METADATA_BYTES = 2048
|
|
61
|
+
|
|
62
|
+
_STORED_HEADERS = (
|
|
63
|
+
"cache-control",
|
|
64
|
+
"content-disposition",
|
|
65
|
+
"content-language",
|
|
66
|
+
"content-type",
|
|
67
|
+
"expires",
|
|
68
|
+
)
|
|
69
|
+
_DEFAULT_CONTENT_TYPE = "binary/octet-stream"
|
|
70
|
+
_META_PREFIX = "x-amz-meta-"
|
|
71
|
+
_XML_CONTENT_TYPE = "application/xml"
|
|
72
|
+
# Tab and printable Latin-1. Anything else couldn't be sent as a header.
|
|
73
|
+
_VALID_HEADER_VALUE = re.compile(r"[\t\x20-\x7e\x80-\xff]*")
|
|
74
|
+
_UPLOAD_ID_BYTES = 24
|
|
75
|
+
_RESPONSE_OVERRIDES = {
|
|
76
|
+
"response-cache-control": "cache-control",
|
|
77
|
+
"response-content-disposition": "content-disposition",
|
|
78
|
+
"response-content-encoding": "content-encoding",
|
|
79
|
+
"response-content-language": "content-language",
|
|
80
|
+
"response-content-type": "content-type",
|
|
81
|
+
"response-expires": "expires",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class _CopySource:
|
|
87
|
+
"""What a copy reads: the object, where it is, and the version named, if any."""
|
|
88
|
+
|
|
89
|
+
bucket: str
|
|
90
|
+
version_id: str | None
|
|
91
|
+
record: ObjectRecord
|
|
92
|
+
key: sse.CustomerKey | None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class Context:
|
|
97
|
+
db: Database
|
|
98
|
+
blobs: BlobStore
|
|
99
|
+
data_dir: Path
|
|
100
|
+
lifecycle_day_ms: int = meta.DAY_MS
|
|
101
|
+
sse_over_http: bool = False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class Request:
|
|
106
|
+
"""A request that has passed authentication."""
|
|
107
|
+
|
|
108
|
+
headers: Mapping[str, str]
|
|
109
|
+
query: Mapping[str, str]
|
|
110
|
+
receive: Callable[[], Awaitable[Mapping[str, Any]]]
|
|
111
|
+
stream_seed: StreamSeed | None # None when it carries no signature.
|
|
112
|
+
scope: Scope
|
|
113
|
+
secure: bool = False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, slots=True)
|
|
117
|
+
class Response:
|
|
118
|
+
status: int = 200
|
|
119
|
+
headers: tuple[tuple[str, str], ...] = ()
|
|
120
|
+
body: bytes | AsyncGenerator[bytes] = b""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
#
|
|
124
|
+
# Service and buckets
|
|
125
|
+
#
|
|
126
|
+
async def list_buckets(ctx: Context, request: Request) -> Response:
|
|
127
|
+
buckets = await meta.list_buckets(ctx.db)
|
|
128
|
+
visible = [bucket for bucket in buckets if request.scope.sees(bucket.name)]
|
|
129
|
+
return _xml_response(xml.render_buckets(visible))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def create_bucket(ctx: Context, request: Request, bucket: str) -> Response:
|
|
133
|
+
# The body only names a region, and a single store has one location.
|
|
134
|
+
await _read_small_body(request)
|
|
135
|
+
await meta.create_bucket(
|
|
136
|
+
ctx.db, bucket, public=request.headers.get("x-amz-acl") == "public-read"
|
|
137
|
+
)
|
|
138
|
+
return Response(headers=(("location", f"/{bucket}"),))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def delete_bucket(ctx: Context, _request: Request, bucket: str) -> Response:
|
|
142
|
+
await meta.delete_bucket(ctx.db, bucket)
|
|
143
|
+
return Response(status=204)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def head_bucket(ctx: Context, _request: Request, bucket: str) -> Response:
|
|
147
|
+
await meta.require_bucket(ctx.db, bucket)
|
|
148
|
+
return Response()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def get_bucket_location(ctx: Context, _request: Request, bucket: str) -> Response:
|
|
152
|
+
await meta.require_bucket(ctx.db, bucket)
|
|
153
|
+
return _xml_response(xml.render_location())
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def get_bucket_versioning(
|
|
157
|
+
ctx: Context, _request: Request, bucket: str
|
|
158
|
+
) -> Response:
|
|
159
|
+
return _xml_response(
|
|
160
|
+
xml.render_versioning(await meta.get_versioning(ctx.db, bucket))
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def put_bucket_versioning(
|
|
165
|
+
ctx: Context, request: Request, bucket: str
|
|
166
|
+
) -> Response:
|
|
167
|
+
state = xml.parse_versioning(await _read_small_body(request))
|
|
168
|
+
if state is None:
|
|
169
|
+
await meta.require_bucket(ctx.db, bucket)
|
|
170
|
+
else:
|
|
171
|
+
await meta.set_versioning(ctx.db, bucket, state)
|
|
172
|
+
return Response()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def get_bucket_lifecycle(
|
|
176
|
+
ctx: Context, _request: Request, bucket: str
|
|
177
|
+
) -> Response:
|
|
178
|
+
configuration = await meta.get_lifecycle(ctx.db, bucket)
|
|
179
|
+
return _xml_response(configuration.encode())
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def put_bucket_lifecycle(ctx: Context, request: Request, bucket: str) -> Response:
|
|
183
|
+
rules = xml_config.parse_lifecycle(await _read_small_body(request))
|
|
184
|
+
await meta.set_lifecycle(
|
|
185
|
+
ctx.db, bucket, xml_config.render_lifecycle(rules).decode()
|
|
186
|
+
)
|
|
187
|
+
return Response()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def delete_bucket_lifecycle(
|
|
191
|
+
ctx: Context, _request: Request, bucket: str
|
|
192
|
+
) -> Response:
|
|
193
|
+
await meta.set_lifecycle(ctx.db, bucket, None)
|
|
194
|
+
return Response(status=204)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def get_bucket_acl(ctx: Context, _request: Request, bucket: str) -> Response:
|
|
198
|
+
await meta.require_bucket(ctx.db, bucket)
|
|
199
|
+
public = await meta.bucket_is_public(ctx.db, bucket)
|
|
200
|
+
return _xml_response(xml_config.render_acl(public=public))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def put_bucket_acl(ctx: Context, request: Request, bucket: str) -> Response:
|
|
204
|
+
await meta.set_bucket_public(
|
|
205
|
+
ctx.db, bucket, public=await _public_in_acl_request(request)
|
|
206
|
+
)
|
|
207
|
+
return Response()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def _public_in_acl_request(request: Request) -> bool:
|
|
211
|
+
"""Read an ACL from its header, or if there is none, from the body."""
|
|
212
|
+
canned = request.headers.get("x-amz-acl")
|
|
213
|
+
if canned is not None:
|
|
214
|
+
return canned == "public-read"
|
|
215
|
+
return xml_config.parse_acl(await _read_small_body(request))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def get_bucket_cors(ctx: Context, _request: Request, bucket: str) -> Response:
|
|
219
|
+
await meta.require_bucket(ctx.db, bucket)
|
|
220
|
+
configuration = await meta.get_cors(ctx.db, bucket)
|
|
221
|
+
if configuration is None:
|
|
222
|
+
raise S3Error(errors.NO_SUCH_CORS)
|
|
223
|
+
return _xml_response(configuration.encode())
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def put_bucket_cors(ctx: Context, request: Request, bucket: str) -> Response:
|
|
227
|
+
rules = xml_config.parse_cors(await _read_small_body(request))
|
|
228
|
+
await meta.set_cors(ctx.db, bucket, xml_config.render_cors(rules).decode())
|
|
229
|
+
return Response()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def delete_bucket_cors(ctx: Context, _request: Request, bucket: str) -> Response:
|
|
233
|
+
await meta.set_cors(ctx.db, bucket, None)
|
|
234
|
+
return Response(status=204)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def cors_preflight(
|
|
238
|
+
ctx: Context, headers: Mapping[str, str], bucket: str
|
|
239
|
+
) -> Response:
|
|
240
|
+
"""Answer a browser asking whether it may send a request. It can't sign this."""
|
|
241
|
+
origin = headers.get("origin")
|
|
242
|
+
method = headers.get("access-control-request-method")
|
|
243
|
+
if origin is None or method is None:
|
|
244
|
+
raise S3Error(errors.INVALID_REQUEST, "This isn't a CORS preflight request.")
|
|
245
|
+
wanted = cors.split_header_names(headers.get("access-control-request-headers", ""))
|
|
246
|
+
rule = cors.find_rule(await _cors_rules(ctx, bucket), origin, method, wanted)
|
|
247
|
+
if rule is None:
|
|
248
|
+
raise S3Error(errors.ACCESS_FORBIDDEN)
|
|
249
|
+
return Response(headers=cors.preflight_headers(rule, origin, wanted))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
async def cors_headers_for(
|
|
253
|
+
ctx: Context, method: str, origin: str, bucket: str
|
|
254
|
+
) -> tuple[tuple[str, str], ...]:
|
|
255
|
+
"""What a browser needs to be allowed to read the answer to a request.
|
|
256
|
+
|
|
257
|
+
With rules, an answer depends on the origin, allowed or not, so it says so to
|
|
258
|
+
caches. Otherwise a cache could give one origin what was meant for another.
|
|
259
|
+
"""
|
|
260
|
+
rules = await _cors_rules(ctx, bucket)
|
|
261
|
+
if not rules:
|
|
262
|
+
return ()
|
|
263
|
+
rule = cors.find_rule(rules, origin, method)
|
|
264
|
+
return cors.VARY if rule is None else cors.response_headers(rule, origin)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def _cors_rules(ctx: Context, bucket: str) -> tuple[CorsRule, ...]:
|
|
268
|
+
configuration = await meta.get_cors(ctx.db, bucket)
|
|
269
|
+
return (
|
|
270
|
+
() if configuration is None else xml_config.parse_cors(configuration.encode())
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def list_objects_v1(ctx: Context, request: Request, bucket: str) -> Response:
|
|
275
|
+
query = request.query
|
|
276
|
+
list_query = _list_query(query, query.get("marker", ""))
|
|
277
|
+
page = await meta.list_objects(ctx.db, bucket, list_query)
|
|
278
|
+
return _xml_response(xml.render_list_v1(bucket, query, page, list_query.max_keys))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
async def list_objects_v2(ctx: Context, request: Request, bucket: str) -> Response:
|
|
282
|
+
query = request.query
|
|
283
|
+
after = (
|
|
284
|
+
listing.decode_token(query["continuation-token"])
|
|
285
|
+
if "continuation-token" in query
|
|
286
|
+
else query.get("start-after", "")
|
|
287
|
+
)
|
|
288
|
+
list_query = _list_query(query, after)
|
|
289
|
+
page = await meta.list_objects(ctx.db, bucket, list_query)
|
|
290
|
+
next_token = (
|
|
291
|
+
listing.encode_token(page.next_marker) if page.next_marker is not None else None
|
|
292
|
+
)
|
|
293
|
+
return _xml_response(
|
|
294
|
+
xml.render_list_v2(bucket, query, page, list_query.max_keys, next_token)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def list_object_versions(ctx: Context, request: Request, bucket: str) -> Response:
|
|
299
|
+
query = request.query
|
|
300
|
+
list_query = _list_query(query, query.get("key-marker", ""))
|
|
301
|
+
page = await meta.list_versions(
|
|
302
|
+
ctx.db, bucket, list_query, query.get("version-id-marker", "")
|
|
303
|
+
)
|
|
304
|
+
return _xml_response(xml.render_versions(bucket, query, page, list_query.max_keys))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
async def delete_objects(ctx: Context, request: Request, bucket: str) -> Response:
|
|
308
|
+
targets, quiet = xml.parse_delete(await _read_small_body(request))
|
|
309
|
+
if any(target.version_id is not None for target in targets):
|
|
310
|
+
access.require(request.scope, bucket, Level.ADMIN) # As for one such delete.
|
|
311
|
+
parsed = [
|
|
312
|
+
replace(target, key=routing.parse_decoded_key(target.key.encode()))
|
|
313
|
+
for target in targets
|
|
314
|
+
]
|
|
315
|
+
outcomes = await meta.delete_objects(ctx.db, bucket, parsed)
|
|
316
|
+
return _xml_response(xml.render_deleted(outcomes, quiet=quiet))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
#
|
|
320
|
+
# Objects
|
|
321
|
+
#
|
|
322
|
+
async def put_object(ctx: Context, request: Request, bucket: str, key: str) -> Response:
|
|
323
|
+
await meta.require_bucket(ctx.db, bucket)
|
|
324
|
+
condition = conditions.parse_write_condition(request.headers)
|
|
325
|
+
headers = _headers_to_store(request.headers)
|
|
326
|
+
tags = _tags_in_header(request)
|
|
327
|
+
public = _public_on_write(request, bucket)
|
|
328
|
+
key_sent = streams.customer_key(ctx, request)
|
|
329
|
+
blob, checksum = await streams.receive_blob(ctx, request, None, key_sent)
|
|
330
|
+
record = ObjectRecord(
|
|
331
|
+
key,
|
|
332
|
+
streams.client_size(blob, key_sent),
|
|
333
|
+
blob.md5,
|
|
334
|
+
meta.now_ms(),
|
|
335
|
+
headers,
|
|
336
|
+
checksum,
|
|
337
|
+
(blob,),
|
|
338
|
+
tags=tags,
|
|
339
|
+
public=public,
|
|
340
|
+
sse_check=key_sent.check if key_sent else None,
|
|
341
|
+
)
|
|
342
|
+
version_id = await _commit_new_blob(ctx, bucket, record, condition)
|
|
343
|
+
return Response(
|
|
344
|
+
headers=(
|
|
345
|
+
*_etag_headers(blob.md5, checksum),
|
|
346
|
+
*_new_version_headers(version_id),
|
|
347
|
+
*streams.key_headers(key_sent),
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
async def copy_object(
|
|
353
|
+
ctx: Context, request: Request, bucket: str, key: str
|
|
354
|
+
) -> Response:
|
|
355
|
+
copied = await _load_copy_source(ctx, request)
|
|
356
|
+
source = copied.record
|
|
357
|
+
new_metadata = request.headers.get("x-amz-metadata-directive") == "REPLACE"
|
|
358
|
+
# Copying an older version onto its key is how one is restored.
|
|
359
|
+
restoring = copied.version_id is not None
|
|
360
|
+
onto_itself = (copied.bucket, source.key) == (bucket, key)
|
|
361
|
+
if onto_itself and not (new_metadata or restoring):
|
|
362
|
+
raise S3Error(
|
|
363
|
+
errors.INVALID_REQUEST,
|
|
364
|
+
"Copying an object onto itself must change its metadata.",
|
|
365
|
+
)
|
|
366
|
+
if source.size > streams.MAX_OBJECT_BYTES:
|
|
367
|
+
raise S3Error(errors.INVALID_REQUEST, "The source is too large for CopyObject.")
|
|
368
|
+
await meta.require_bucket(ctx.db, bucket)
|
|
369
|
+
|
|
370
|
+
condition = conditions.parse_write_condition(request.headers)
|
|
371
|
+
headers = _headers_to_store(request.headers) if new_metadata else source.headers
|
|
372
|
+
tags = _tags_of_a_copy(request, source)
|
|
373
|
+
public = _public_on_write(request, bucket) # As asked: a copy doesn't inherit it.
|
|
374
|
+
key_sent = streams.customer_key(ctx, request)
|
|
375
|
+
blob, _ = await streams.copy_to_new_blob(
|
|
376
|
+
ctx,
|
|
377
|
+
streams.plain_bytes(ctx, source, ByteSpan(0, source.size), copied.key),
|
|
378
|
+
None,
|
|
379
|
+
key_sent,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
record = ObjectRecord(
|
|
383
|
+
key,
|
|
384
|
+
streams.client_size(blob, key_sent),
|
|
385
|
+
blob.md5,
|
|
386
|
+
meta.now_ms(),
|
|
387
|
+
headers,
|
|
388
|
+
None,
|
|
389
|
+
(blob,),
|
|
390
|
+
tags=tags,
|
|
391
|
+
public=public,
|
|
392
|
+
sse_check=key_sent.check if key_sent else None,
|
|
393
|
+
)
|
|
394
|
+
version_id = await _commit_new_blob(ctx, bucket, record, condition)
|
|
395
|
+
return _xml_response(
|
|
396
|
+
xml.render_copy_result(blob.md5, record.modified_at),
|
|
397
|
+
(
|
|
398
|
+
*_new_version_headers(version_id),
|
|
399
|
+
*_copy_source_headers(copied),
|
|
400
|
+
*streams.key_headers(key_sent),
|
|
401
|
+
),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
async def get_object(ctx: Context, request: Request, bucket: str, key: str) -> Response:
|
|
406
|
+
record = await _record_to_read(ctx, request, bucket, key)
|
|
407
|
+
return _object_response(ctx, request, record, include_body=True)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
async def head_object(
|
|
411
|
+
ctx: Context, request: Request, bucket: str, key: str
|
|
412
|
+
) -> Response:
|
|
413
|
+
record = await _record_to_read(ctx, request, bucket, key)
|
|
414
|
+
return _object_response(ctx, request, record, include_body=False)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
async def _record_to_read(
|
|
418
|
+
ctx: Context, request: Request, bucket: str, key: str
|
|
419
|
+
) -> ObjectRecord:
|
|
420
|
+
"""Find what a read asks for, if whoever is asking may read it.
|
|
421
|
+
|
|
422
|
+
In one read with the object, so a private version can't slip in after the check.
|
|
423
|
+
Only those who may list a bucket learn that a key isn't in it.
|
|
424
|
+
"""
|
|
425
|
+
version_id = request.query.get("versionId")
|
|
426
|
+
if request.scope is not access.ANONYMOUS:
|
|
427
|
+
return await meta.get_object(ctx.db, bucket, key, version_id)
|
|
428
|
+
if not _RESPONSE_OVERRIDES.keys().isdisjoint(request.query):
|
|
429
|
+
# A stranger's link shouldn't choose how a browser treats someone's object.
|
|
430
|
+
raise S3Error(errors.INVALID_REQUEST, "Only a signed request can do that.")
|
|
431
|
+
try:
|
|
432
|
+
record = await meta.get_object(ctx.db, bucket, key, version_id)
|
|
433
|
+
except S3Error as error:
|
|
434
|
+
if await meta.bucket_is_public(ctx.db, bucket):
|
|
435
|
+
raise S3Error(error.error, error.message) from None
|
|
436
|
+
raise S3Error(errors.ACCESS_DENIED) from None
|
|
437
|
+
if not record.public:
|
|
438
|
+
raise S3Error(errors.ACCESS_DENIED)
|
|
439
|
+
return record
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
async def post_object(ctx: Context, request: Request, bucket: str) -> Response:
|
|
443
|
+
"""Store a browser's form. The form signs itself, so nothing is trusted before."""
|
|
444
|
+
boundary = post_form.parse_boundary(request.headers.get("content-type", ""))
|
|
445
|
+
async with aclosing(_form_events(request, boundary)) as events:
|
|
446
|
+
fields, filename = await _read_form_fields(events)
|
|
447
|
+
access_key = sigv4.post_form_access_key(fields)
|
|
448
|
+
secret, scope = await meta.get_access_key(ctx.db, access_key)
|
|
449
|
+
sigv4.verify_post_form(fields, secret)
|
|
450
|
+
access.require(scope, bucket, Level.WRITE)
|
|
451
|
+
if "key" not in fields:
|
|
452
|
+
raise S3Error(errors.INVALID_ARGUMENT, "A form must name a key.")
|
|
453
|
+
fields["key"] = fields["key"].replace("${filename}", filename)
|
|
454
|
+
limits = post_form.check_policy({**fields, "bucket": bucket}, time.time())
|
|
455
|
+
routing.refuse_unsupported_headers(fields)
|
|
456
|
+
key = routing.parse_decoded_key(fields["key"].encode())
|
|
457
|
+
public = _public_in_form(fields)
|
|
458
|
+
if public:
|
|
459
|
+
access.require(scope, bucket, Level.ADMIN)
|
|
460
|
+
await meta.require_bucket(ctx.db, bucket)
|
|
461
|
+
record = await _store_form_file(
|
|
462
|
+
ctx, ObjectRef(bucket, key), fields, events, limits
|
|
463
|
+
)
|
|
464
|
+
record = replace(record, public=public)
|
|
465
|
+
version_id = await _commit_new_blob(
|
|
466
|
+
ctx, bucket, record, conditions.WriteCondition()
|
|
467
|
+
)
|
|
468
|
+
return _post_response(fields, bucket, key, record.etag, version_id)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
async def _form_events(
|
|
472
|
+
request: Request, boundary: bytes
|
|
473
|
+
) -> AsyncGenerator[post_form.Event]:
|
|
474
|
+
parser = post_form.FormParser(boundary)
|
|
475
|
+
decoder = payload.new_decoder(payload.parse_spec({}), request.stream_seed)
|
|
476
|
+
async for chunk in streams.decoded_body(request, decoder):
|
|
477
|
+
for event in parser.feed(chunk):
|
|
478
|
+
yield event
|
|
479
|
+
parser.finish()
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
async def _read_form_fields(
|
|
483
|
+
events: AsyncGenerator[post_form.Event],
|
|
484
|
+
) -> tuple[dict[str, str], str]:
|
|
485
|
+
"""Read up to the file, and leave it unread. Returns the fields and its name."""
|
|
486
|
+
fields: dict[str, str] = {}
|
|
487
|
+
async for event in events:
|
|
488
|
+
if isinstance(event, post_form.Field):
|
|
489
|
+
if event.name in fields:
|
|
490
|
+
raise S3Error(errors.MALFORMED_POST, f"Two fields: {event.name}.")
|
|
491
|
+
fields[event.name] = event.value
|
|
492
|
+
elif isinstance(event, post_form.FileStart):
|
|
493
|
+
return fields, event.filename
|
|
494
|
+
raise S3Error(errors.INVALID_ARGUMENT, "A form must hold a file.")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _public_in_form(fields: Mapping[str, str]) -> bool:
|
|
498
|
+
acl = fields.get("acl", "private")
|
|
499
|
+
if "x-amz-acl" in fields or acl not in {"private", "public-read"}:
|
|
500
|
+
raise S3Error(errors.NOT_IMPLEMENTED, f"acl: {acl} is not implemented.")
|
|
501
|
+
return acl == "public-read"
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
async def _store_form_file(
|
|
505
|
+
ctx: Context,
|
|
506
|
+
target: ObjectRef,
|
|
507
|
+
fields: Mapping[str, str],
|
|
508
|
+
events: AsyncGenerator[post_form.Event],
|
|
509
|
+
limits: post_form.SizeLimits,
|
|
510
|
+
) -> ObjectRecord:
|
|
511
|
+
"""Make the file a blob, once the form has been seen to end properly."""
|
|
512
|
+
if any(name.startswith(sse.HEADER_PREFIX) for name in fields):
|
|
513
|
+
raise S3Error(errors.NOT_IMPLEMENTED, "A form can't be encrypted yet.")
|
|
514
|
+
tags = (
|
|
515
|
+
xml_config.parse_tagging(fields["tagging"].encode())
|
|
516
|
+
if "tagging" in fields
|
|
517
|
+
else {}
|
|
518
|
+
)
|
|
519
|
+
headers = _headers_to_store(fields)
|
|
520
|
+
spec = payload.parse_spec({
|
|
521
|
+
name: value
|
|
522
|
+
for name, value in fields.items()
|
|
523
|
+
if name == "content-md5" or name.startswith("x-amz-checksum-")
|
|
524
|
+
})
|
|
525
|
+
largest = streams.MAX_OBJECT_BYTES if limits.largest is None else limits.largest
|
|
526
|
+
|
|
527
|
+
async def file_data() -> AsyncGenerator[bytes]:
|
|
528
|
+
async for event in events:
|
|
529
|
+
if isinstance(event, post_form.FileEnd):
|
|
530
|
+
return
|
|
531
|
+
if isinstance(event, post_form.FileData):
|
|
532
|
+
yield event.data
|
|
533
|
+
|
|
534
|
+
with ctx.blobs.writer(payload.new_hashers(spec)) as writer:
|
|
535
|
+
await blobstore.write_stream(
|
|
536
|
+
writer, file_data(), min(largest, streams.MAX_OBJECT_BYTES)
|
|
537
|
+
)
|
|
538
|
+
async for _ in events: # Up to the closing delimiter, or the error of its lack.
|
|
539
|
+
pass
|
|
540
|
+
if writer.size < limits.smallest:
|
|
541
|
+
raise S3Error(
|
|
542
|
+
errors.ENTITY_TOO_SMALL, "The file is below the policy's minimum."
|
|
543
|
+
)
|
|
544
|
+
checksum = payload.verify(spec, writer.digests(), writer.size, {})
|
|
545
|
+
blob = await asyncio.to_thread(writer.commit)
|
|
546
|
+
return ObjectRecord(
|
|
547
|
+
target.key,
|
|
548
|
+
blob.size,
|
|
549
|
+
blob.md5,
|
|
550
|
+
meta.now_ms(),
|
|
551
|
+
headers,
|
|
552
|
+
checksum,
|
|
553
|
+
(blob,),
|
|
554
|
+
tags=tags,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _post_response(
|
|
559
|
+
fields: Mapping[str, str],
|
|
560
|
+
bucket: str,
|
|
561
|
+
key: str,
|
|
562
|
+
etag: str,
|
|
563
|
+
version_id: str | None,
|
|
564
|
+
) -> Response:
|
|
565
|
+
location = f"/{bucket}/{quote(key)}"
|
|
566
|
+
headers = (
|
|
567
|
+
("etag", f'"{etag}"'),
|
|
568
|
+
("location", location),
|
|
569
|
+
*_new_version_headers(version_id),
|
|
570
|
+
)
|
|
571
|
+
redirect = fields.get("success_action_redirect", "")
|
|
572
|
+
if redirect.startswith(("http://", "https://")):
|
|
573
|
+
separator = "&" if "?" in redirect else "?"
|
|
574
|
+
query = urlencode({"bucket": bucket, "key": key, "etag": f'"{etag}"'})
|
|
575
|
+
target = f"{redirect}{separator}{query}"
|
|
576
|
+
if not _VALID_HEADER_VALUE.fullmatch(target):
|
|
577
|
+
raise S3Error(errors.INVALID_ARGUMENT, "That redirect is not valid.")
|
|
578
|
+
return Response(status=303, headers=(("location", target),))
|
|
579
|
+
status = fields.get("success_action_status")
|
|
580
|
+
if status == "201":
|
|
581
|
+
body = xml.render_post_result(bucket, key, etag, location)
|
|
582
|
+
return Response(
|
|
583
|
+
status=201,
|
|
584
|
+
headers=(("content-type", _XML_CONTENT_TYPE), *headers),
|
|
585
|
+
body=body,
|
|
586
|
+
)
|
|
587
|
+
return Response(status=200 if status == "200" else 204, headers=headers)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
async def get_object_acl(
|
|
591
|
+
ctx: Context, request: Request, bucket: str, key: str
|
|
592
|
+
) -> Response:
|
|
593
|
+
record = await meta.get_object(ctx.db, bucket, key, request.query.get("versionId"))
|
|
594
|
+
return _xml_response(
|
|
595
|
+
xml_config.render_acl(public=record.public),
|
|
596
|
+
_new_version_headers(record.version_id),
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
async def put_object_acl(
|
|
601
|
+
ctx: Context, request: Request, bucket: str, key: str
|
|
602
|
+
) -> Response:
|
|
603
|
+
public = await _public_in_acl_request(request)
|
|
604
|
+
if public:
|
|
605
|
+
access.require(request.scope, bucket, Level.ADMIN)
|
|
606
|
+
version_id = await meta.set_object_public(
|
|
607
|
+
ctx.db, ObjectRef(bucket, key), request.query.get("versionId"), public=public
|
|
608
|
+
)
|
|
609
|
+
return Response(headers=_new_version_headers(version_id))
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _public_on_write(request: Request, bucket: str) -> bool:
|
|
613
|
+
"""Whether a new object is to be public, which takes more than writing it."""
|
|
614
|
+
public = request.headers.get("x-amz-acl") == "public-read"
|
|
615
|
+
if public:
|
|
616
|
+
access.require(request.scope, bucket, Level.ADMIN)
|
|
617
|
+
return public
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
async def delete_object(
|
|
621
|
+
ctx: Context, request: Request, bucket: str, key: str
|
|
622
|
+
) -> Response:
|
|
623
|
+
condition = conditions.parse_write_condition(request.headers)
|
|
624
|
+
outcome = await meta.delete_object(ctx.db, bucket, key, condition)
|
|
625
|
+
return Response(status=204, headers=_delete_headers(outcome))
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
async def delete_object_version(
|
|
629
|
+
ctx: Context, request: Request, bucket: str, key: str
|
|
630
|
+
) -> Response:
|
|
631
|
+
outcome = await meta.delete_object_version(
|
|
632
|
+
ctx.db, bucket, key, request.query["versionId"]
|
|
633
|
+
)
|
|
634
|
+
return Response(status=204, headers=_delete_headers(outcome))
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
async def get_object_tagging(
|
|
638
|
+
ctx: Context, request: Request, bucket: str, key: str
|
|
639
|
+
) -> Response:
|
|
640
|
+
record = await meta.get_object(ctx.db, bucket, key, request.query.get("versionId"))
|
|
641
|
+
return _xml_response(
|
|
642
|
+
xml_config.render_tagging(record.tags), _new_version_headers(record.version_id)
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
async def put_object_tagging(
|
|
647
|
+
ctx: Context, request: Request, bucket: str, key: str
|
|
648
|
+
) -> Response:
|
|
649
|
+
tags = xml_config.parse_tagging(await _read_small_body(request))
|
|
650
|
+
return Response(headers=await _set_tags(ctx, request, ObjectRef(bucket, key), tags))
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
async def delete_object_tagging(
|
|
654
|
+
ctx: Context, request: Request, bucket: str, key: str
|
|
655
|
+
) -> Response:
|
|
656
|
+
headers = await _set_tags(ctx, request, ObjectRef(bucket, key), {})
|
|
657
|
+
return Response(status=204, headers=headers)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
async def _set_tags(
|
|
661
|
+
ctx: Context,
|
|
662
|
+
request: Request,
|
|
663
|
+
target: ObjectRef,
|
|
664
|
+
tags: Mapping[str, str],
|
|
665
|
+
) -> tuple[tuple[str, str], ...]:
|
|
666
|
+
named = request.query.get("versionId")
|
|
667
|
+
if named is not None:
|
|
668
|
+
# A rule that filters on tags could then erase a version already kept.
|
|
669
|
+
access.require(request.scope, target.bucket, Level.ADMIN)
|
|
670
|
+
version_id = await meta.set_object_tags(
|
|
671
|
+
ctx.db, target.bucket, target.key, named, tags
|
|
672
|
+
)
|
|
673
|
+
return _new_version_headers(version_id)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _tags_in_header(request: Request) -> dict[str, str]:
|
|
677
|
+
return tagging.parse_header(request.headers.get("x-amz-tagging", ""))
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _tags_of_a_copy(request: Request, source: ObjectRecord) -> Mapping[str, str]:
|
|
681
|
+
directive = request.headers.get("x-amz-tagging-directive", "COPY")
|
|
682
|
+
if directive == "COPY":
|
|
683
|
+
return source.tags
|
|
684
|
+
if directive == "REPLACE":
|
|
685
|
+
return _tags_in_header(request)
|
|
686
|
+
raise S3Error(errors.INVALID_ARGUMENT, f"Bad tagging directive: {directive!r}.")
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
#
|
|
690
|
+
# Multipart uploads
|
|
691
|
+
#
|
|
692
|
+
async def create_multipart_upload(
|
|
693
|
+
ctx: Context, request: Request, bucket: str, key: str
|
|
694
|
+
) -> Response:
|
|
695
|
+
plan = multipart.parse_checksum_plan(request.headers)
|
|
696
|
+
upload = UploadRef(secrets.token_urlsafe(_UPLOAD_ID_BYTES), bucket, key)
|
|
697
|
+
key_sent = streams.customer_key(ctx, request)
|
|
698
|
+
await meta.create_upload(
|
|
699
|
+
ctx.db,
|
|
700
|
+
upload,
|
|
701
|
+
_headers_to_store(request.headers),
|
|
702
|
+
plan,
|
|
703
|
+
UploadExtras(
|
|
704
|
+
_tags_in_header(request),
|
|
705
|
+
_public_on_write(request, bucket),
|
|
706
|
+
key_sent.check if key_sent else None,
|
|
707
|
+
),
|
|
708
|
+
)
|
|
709
|
+
extra = (
|
|
710
|
+
(
|
|
711
|
+
("x-amz-checksum-algorithm", plan.algorithm.upper()),
|
|
712
|
+
("x-amz-checksum-type", plan.checksum_type),
|
|
713
|
+
)
|
|
714
|
+
if plan
|
|
715
|
+
else ()
|
|
716
|
+
)
|
|
717
|
+
return _xml_response(
|
|
718
|
+
xml.render_initiate(bucket, key, upload.upload_id),
|
|
719
|
+
(*extra, *streams.key_headers(key_sent)),
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
async def upload_part(
|
|
724
|
+
ctx: Context, request: Request, bucket: str, key: str
|
|
725
|
+
) -> Response:
|
|
726
|
+
upload = UploadRef(request.query["uploadId"], bucket, key)
|
|
727
|
+
number = multipart.parse_part_number(request.query.get("partNumber", ""))
|
|
728
|
+
started = await meta.require_upload(ctx.db, upload)
|
|
729
|
+
key_sent = streams.customer_key(ctx, request)
|
|
730
|
+
sse.check_key(started.sse_check, key_sent)
|
|
731
|
+
|
|
732
|
+
blob, checksum = await streams.receive_blob(
|
|
733
|
+
ctx, request, started.checksum, key_sent
|
|
734
|
+
)
|
|
735
|
+
await _commit_new_part(ctx, upload, number, blob, checksum)
|
|
736
|
+
return Response(
|
|
737
|
+
headers=(*_etag_headers(blob.md5, checksum), *streams.key_headers(key_sent))
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
async def upload_part_copy(
|
|
742
|
+
ctx: Context, request: Request, bucket: str, key: str
|
|
743
|
+
) -> Response:
|
|
744
|
+
number = multipart.parse_part_number(request.query.get("partNumber", ""))
|
|
745
|
+
# The source first: a refusal shouldn't say whether the upload exists.
|
|
746
|
+
copied = await _load_copy_source(ctx, request)
|
|
747
|
+
upload = UploadRef(request.query["uploadId"], bucket, key)
|
|
748
|
+
started = await meta.require_upload(ctx.db, upload)
|
|
749
|
+
key_sent = streams.customer_key(ctx, request)
|
|
750
|
+
sse.check_key(started.sse_check, key_sent)
|
|
751
|
+
start, length = conditions.resolve_copy_range(
|
|
752
|
+
request.headers.get("x-amz-copy-source-range"), copied.record.size
|
|
753
|
+
)
|
|
754
|
+
if length > streams.MAX_OBJECT_BYTES:
|
|
755
|
+
raise S3Error(errors.INVALID_REQUEST, "The source is too large for one part.")
|
|
756
|
+
|
|
757
|
+
blob, checksum = await streams.copy_to_new_blob(
|
|
758
|
+
ctx,
|
|
759
|
+
streams.plain_bytes(ctx, copied.record, ByteSpan(start, length), copied.key),
|
|
760
|
+
started.checksum,
|
|
761
|
+
key_sent,
|
|
762
|
+
)
|
|
763
|
+
await _commit_new_part(ctx, upload, number, blob, checksum)
|
|
764
|
+
return _xml_response(
|
|
765
|
+
xml.render_copy_part_result(blob.md5, meta.now_ms(), checksum),
|
|
766
|
+
(*_copy_source_headers(copied), *streams.key_headers(key_sent)),
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
async def complete_multipart_upload(
|
|
771
|
+
ctx: Context, request: Request, bucket: str, key: str
|
|
772
|
+
) -> Response:
|
|
773
|
+
body = await _read_small_body(
|
|
774
|
+
request, payload.without_object_checksums(request.headers)
|
|
775
|
+
)
|
|
776
|
+
completed = await meta.complete_upload(
|
|
777
|
+
ctx.db,
|
|
778
|
+
UploadRef(request.query["uploadId"], bucket, key),
|
|
779
|
+
multipart.parse_completion(xml.parse_complete(body), request.headers),
|
|
780
|
+
conditions.parse_write_condition(request.headers),
|
|
781
|
+
)
|
|
782
|
+
return _xml_response(
|
|
783
|
+
xml.render_complete(bucket, key, completed.etag, completed.checksum),
|
|
784
|
+
_new_version_headers(completed.version_id),
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
async def abort_multipart_upload(
|
|
789
|
+
ctx: Context, request: Request, bucket: str, key: str
|
|
790
|
+
) -> Response:
|
|
791
|
+
await meta.abort_upload(ctx.db, UploadRef(request.query["uploadId"], bucket, key))
|
|
792
|
+
return Response(status=204)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
async def list_parts(ctx: Context, request: Request, bucket: str, key: str) -> Response:
|
|
796
|
+
query = request.query
|
|
797
|
+
upload = UploadRef(query["uploadId"], bucket, key)
|
|
798
|
+
max_parts = listing.parse_max_keys(query, "max-parts")
|
|
799
|
+
marker_text = query.get("part-number-marker", "0")
|
|
800
|
+
if not marker_text.isdecimal():
|
|
801
|
+
raise S3Error(errors.INVALID_ARGUMENT, "part-number-marker is not valid.")
|
|
802
|
+
marker = int(marker_text)
|
|
803
|
+
parts = await meta.list_parts(ctx.db, upload, marker, max_parts + 1)
|
|
804
|
+
if (await meta.require_upload(ctx.db, upload)).sse_check is not None:
|
|
805
|
+
# A client knows a part by what it sent, not by what encrypting it takes.
|
|
806
|
+
parts = [
|
|
807
|
+
replace(part, blob=replace(part.blob, size=sse.plain_size(part.blob.size)))
|
|
808
|
+
for part in parts
|
|
809
|
+
]
|
|
810
|
+
shown = tuple(parts[:max_parts])
|
|
811
|
+
next_marker = shown[-1].number if len(parts) > max_parts and shown else None
|
|
812
|
+
return _xml_response(
|
|
813
|
+
xml.render_parts(upload, xml.PartsPage(shown, max_parts, marker, next_marker))
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
async def list_multipart_uploads(
|
|
818
|
+
ctx: Context, request: Request, bucket: str
|
|
819
|
+
) -> Response:
|
|
820
|
+
query = request.query
|
|
821
|
+
max_uploads = listing.parse_max_keys(query, "max-uploads")
|
|
822
|
+
# ponytail: no delimiter roll-up here. Add it if a client needs folders of
|
|
823
|
+
# in-progress uploads; the object listing's scan shows how.
|
|
824
|
+
uploads = await meta.list_uploads(
|
|
825
|
+
ctx.db,
|
|
826
|
+
bucket,
|
|
827
|
+
query.get("prefix", ""),
|
|
828
|
+
(query.get("key-marker", ""), query.get("upload-id-marker", "")),
|
|
829
|
+
max_uploads + 1,
|
|
830
|
+
)
|
|
831
|
+
page = uploads[:max_uploads]
|
|
832
|
+
next_position = (
|
|
833
|
+
(page[-1].key, page[-1].upload_id)
|
|
834
|
+
if len(uploads) > max_uploads and page
|
|
835
|
+
else None
|
|
836
|
+
)
|
|
837
|
+
return _xml_response(
|
|
838
|
+
xml.render_uploads(bucket, query, page, max_uploads, next_position)
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
SERVICE_HANDLERS = {Operation.LIST_BUCKETS: list_buckets}
|
|
843
|
+
BUCKET_HANDLERS = {
|
|
844
|
+
Operation.CREATE_BUCKET: create_bucket,
|
|
845
|
+
Operation.DELETE_BUCKET: delete_bucket,
|
|
846
|
+
Operation.HEAD_BUCKET: head_bucket,
|
|
847
|
+
Operation.GET_BUCKET_LOCATION: get_bucket_location,
|
|
848
|
+
Operation.GET_BUCKET_VERSIONING: get_bucket_versioning,
|
|
849
|
+
Operation.PUT_BUCKET_VERSIONING: put_bucket_versioning,
|
|
850
|
+
Operation.GET_BUCKET_LIFECYCLE: get_bucket_lifecycle,
|
|
851
|
+
Operation.PUT_BUCKET_LIFECYCLE: put_bucket_lifecycle,
|
|
852
|
+
Operation.DELETE_BUCKET_LIFECYCLE: delete_bucket_lifecycle,
|
|
853
|
+
Operation.GET_BUCKET_ACL: get_bucket_acl,
|
|
854
|
+
Operation.PUT_BUCKET_ACL: put_bucket_acl,
|
|
855
|
+
Operation.GET_BUCKET_CORS: get_bucket_cors,
|
|
856
|
+
Operation.PUT_BUCKET_CORS: put_bucket_cors,
|
|
857
|
+
Operation.DELETE_BUCKET_CORS: delete_bucket_cors,
|
|
858
|
+
Operation.LIST_OBJECTS: list_objects_v1,
|
|
859
|
+
Operation.LIST_OBJECTS_V2: list_objects_v2,
|
|
860
|
+
Operation.LIST_MULTIPART_UPLOADS: list_multipart_uploads,
|
|
861
|
+
Operation.LIST_OBJECT_VERSIONS: list_object_versions,
|
|
862
|
+
Operation.DELETE_OBJECTS: delete_objects,
|
|
863
|
+
Operation.POST_OBJECT: post_object,
|
|
864
|
+
}
|
|
865
|
+
OBJECT_HANDLERS = {
|
|
866
|
+
Operation.PUT_OBJECT: put_object,
|
|
867
|
+
Operation.COPY_OBJECT: copy_object,
|
|
868
|
+
Operation.GET_OBJECT: get_object,
|
|
869
|
+
Operation.HEAD_OBJECT: head_object,
|
|
870
|
+
Operation.DELETE_OBJECT: delete_object,
|
|
871
|
+
Operation.GET_OBJECT_ACL: get_object_acl,
|
|
872
|
+
Operation.PUT_OBJECT_ACL: put_object_acl,
|
|
873
|
+
Operation.GET_OBJECT_TAGGING: get_object_tagging,
|
|
874
|
+
Operation.PUT_OBJECT_TAGGING: put_object_tagging,
|
|
875
|
+
Operation.DELETE_OBJECT_TAGGING: delete_object_tagging,
|
|
876
|
+
Operation.DELETE_OBJECT_VERSION: delete_object_version,
|
|
877
|
+
Operation.CREATE_MULTIPART_UPLOAD: create_multipart_upload,
|
|
878
|
+
Operation.UPLOAD_PART: upload_part,
|
|
879
|
+
Operation.UPLOAD_PART_COPY: upload_part_copy,
|
|
880
|
+
Operation.COMPLETE_MULTIPART_UPLOAD: complete_multipart_upload,
|
|
881
|
+
Operation.ABORT_MULTIPART_UPLOAD: abort_multipart_upload,
|
|
882
|
+
Operation.LIST_PARTS: list_parts,
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
#
|
|
887
|
+
# Shared pieces
|
|
888
|
+
#
|
|
889
|
+
def _list_query(query: Mapping[str, str], after: str) -> listing.ListQuery:
|
|
890
|
+
return listing.ListQuery(
|
|
891
|
+
query.get("prefix", ""),
|
|
892
|
+
query.get("delimiter", ""),
|
|
893
|
+
after,
|
|
894
|
+
listing.parse_max_keys(query),
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
async def _load_copy_source(ctx: Context, request: Request) -> _CopySource:
|
|
899
|
+
"""Find what a copy reads, if the key may read it and its conditions hold."""
|
|
900
|
+
bucket, key, version_id = _parse_copy_source(request.headers["x-amz-copy-source"])
|
|
901
|
+
access.require(request.scope, bucket, Level.READ)
|
|
902
|
+
record = await meta.get_object(ctx.db, bucket, key, version_id)
|
|
903
|
+
source_key = streams.customer_key(ctx, request, sse.COPY_SOURCE_PREFIX)
|
|
904
|
+
sse.check_key(record.sse_check, source_key)
|
|
905
|
+
if conditions.read_is_not_modified(
|
|
906
|
+
request.headers, record.etag, record.modified_at, prefix="x-amz-copy-source-"
|
|
907
|
+
):
|
|
908
|
+
raise S3Error(errors.PRECONDITION_FAILED, "The source was not modified.")
|
|
909
|
+
return _CopySource(bucket, version_id, record, source_key)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
async def _commit_new_part(
|
|
913
|
+
ctx: Context,
|
|
914
|
+
upload: UploadRef,
|
|
915
|
+
number: int,
|
|
916
|
+
blob: Blob,
|
|
917
|
+
checksum: Checksum | None,
|
|
918
|
+
) -> None:
|
|
919
|
+
"""Commit, and remove the blob if the commit refuses it: no one can refer to it."""
|
|
920
|
+
try:
|
|
921
|
+
await meta.commit_part(ctx.db, upload, number, blob, checksum)
|
|
922
|
+
except S3Error:
|
|
923
|
+
ctx.blobs.delete(blob.blob_id, blob.volume_ids)
|
|
924
|
+
raise
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
async def _read_small_body(
|
|
928
|
+
request: Request, headers: Mapping[str, str] | None = None
|
|
929
|
+
) -> bytes:
|
|
930
|
+
"""Read a bounded XML body, holding it to the same promises as any upload."""
|
|
931
|
+
spec = payload.parse_spec(request.headers if headers is None else headers)
|
|
932
|
+
decoder = payload.new_decoder(spec, request.stream_seed)
|
|
933
|
+
hashers = payload.new_hashers(spec)
|
|
934
|
+
body = bytearray()
|
|
935
|
+
async with aclosing(streams.decoded_body(request, decoder)) as chunks:
|
|
936
|
+
async for chunk in chunks:
|
|
937
|
+
body += chunk
|
|
938
|
+
if len(body) > MAX_XML_BYTES:
|
|
939
|
+
raise S3Error(errors.MALFORMED_XML, "The XML body is too large.")
|
|
940
|
+
complete_body = bytes(body)
|
|
941
|
+
for hasher in hashers.values():
|
|
942
|
+
hasher.update(complete_body)
|
|
943
|
+
digests = {name: hasher.digest() for name, hasher in hashers.items()}
|
|
944
|
+
payload.verify(spec, digests, len(complete_body), decoder.finish())
|
|
945
|
+
return complete_body
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
async def _commit_new_blob(
|
|
949
|
+
ctx: Context,
|
|
950
|
+
bucket: str,
|
|
951
|
+
record: ObjectRecord,
|
|
952
|
+
condition: conditions.WriteCondition,
|
|
953
|
+
) -> str | None:
|
|
954
|
+
"""Commit, and remove the blob if the commit refuses it: no one can refer to it."""
|
|
955
|
+
try:
|
|
956
|
+
return await meta.commit_object(ctx.db, bucket, record, condition)
|
|
957
|
+
except S3Error:
|
|
958
|
+
for blob in record.blobs:
|
|
959
|
+
ctx.blobs.delete(blob.blob_id, blob.volume_ids)
|
|
960
|
+
raise
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _refuse_unless_reachable(
|
|
964
|
+
ctx: Context, record: ObjectRecord, start: int, length: int
|
|
965
|
+
) -> None:
|
|
966
|
+
"""Say so while a client can still retry: after the headers, a read can only break.
|
|
967
|
+
|
|
968
|
+
A worker can be without its volumes for moments, as when it starts on a full disk.
|
|
969
|
+
"""
|
|
970
|
+
blobs = (
|
|
971
|
+
(piece.blob for piece in conditions.slice_blobs(record.blobs, start, length))
|
|
972
|
+
if record.sse_check is None
|
|
973
|
+
else record.blobs
|
|
974
|
+
)
|
|
975
|
+
if not all(map(ctx.blobs.can_read, blobs)):
|
|
976
|
+
raise S3Error(errors.SERVICE_UNAVAILABLE)
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _object_response(
|
|
980
|
+
ctx: Context,
|
|
981
|
+
request: Request,
|
|
982
|
+
record: ObjectRecord,
|
|
983
|
+
*,
|
|
984
|
+
include_body: bool,
|
|
985
|
+
) -> Response:
|
|
986
|
+
headers = request.headers
|
|
987
|
+
key_sent = streams.customer_key(ctx, request)
|
|
988
|
+
sse.check_key(record.sse_check, key_sent)
|
|
989
|
+
validators = (
|
|
990
|
+
("etag", f'"{record.etag}"'),
|
|
991
|
+
("last-modified", xml.http_date(record.modified_at)),
|
|
992
|
+
*(
|
|
993
|
+
()
|
|
994
|
+
if record.version_id == NULL_VERSION_ID
|
|
995
|
+
else _version_headers(record.version_id)
|
|
996
|
+
),
|
|
997
|
+
)
|
|
998
|
+
if conditions.read_is_not_modified(headers, record.etag, record.modified_at):
|
|
999
|
+
return Response(status=304, headers=validators)
|
|
1000
|
+
|
|
1001
|
+
byte_range = conditions.resolve_range(headers.get("range"), record.size)
|
|
1002
|
+
start, length = byte_range or (0, record.size)
|
|
1003
|
+
response_headers = [
|
|
1004
|
+
*validators,
|
|
1005
|
+
*_stored_response_headers(record, request.query),
|
|
1006
|
+
("accept-ranges", "bytes"),
|
|
1007
|
+
("content-length", str(length)),
|
|
1008
|
+
]
|
|
1009
|
+
if record.tags:
|
|
1010
|
+
response_headers.append(("x-amz-tagging-count", str(len(record.tags))))
|
|
1011
|
+
response_headers.extend(streams.key_headers(key_sent))
|
|
1012
|
+
if byte_range is not None:
|
|
1013
|
+
response_headers.append((
|
|
1014
|
+
"content-range",
|
|
1015
|
+
f"bytes {start}-{start + length - 1}/{record.size}",
|
|
1016
|
+
))
|
|
1017
|
+
# A checksum describes the whole object, so a client would reject it on a range.
|
|
1018
|
+
wants_checksum = headers.get("x-amz-checksum-mode", "").upper() == "ENABLED"
|
|
1019
|
+
if wants_checksum and record.checksum is not None and byte_range is None:
|
|
1020
|
+
response_headers.extend((
|
|
1021
|
+
_checksum_header(record.checksum),
|
|
1022
|
+
("x-amz-checksum-type", record.checksum.checksum_type),
|
|
1023
|
+
))
|
|
1024
|
+
|
|
1025
|
+
body: bytes | AsyncGenerator[bytes] = b""
|
|
1026
|
+
if include_body:
|
|
1027
|
+
_refuse_unless_reachable(ctx, record, start, length)
|
|
1028
|
+
body = streams.plain_bytes(ctx, record, ByteSpan(start, length), key_sent)
|
|
1029
|
+
status = 206 if byte_range is not None else 200
|
|
1030
|
+
return Response(status, tuple(response_headers), body)
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _stored_response_headers(
|
|
1034
|
+
record: ObjectRecord, query: Mapping[str, str]
|
|
1035
|
+
) -> list[tuple[str, str]]:
|
|
1036
|
+
merged = dict(record.headers)
|
|
1037
|
+
for parameter, header in _RESPONSE_OVERRIDES.items():
|
|
1038
|
+
if parameter in query:
|
|
1039
|
+
if not _VALID_HEADER_VALUE.fullmatch(query[parameter]):
|
|
1040
|
+
raise S3Error(errors.INVALID_ARGUMENT, f"{parameter} is not valid.")
|
|
1041
|
+
merged[header] = query[parameter]
|
|
1042
|
+
return list(merged.items())
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _headers_to_store(headers: Mapping[str, str]) -> dict[str, str]:
|
|
1046
|
+
"""Pick the headers that belong to the object, and enforce the metadata limit."""
|
|
1047
|
+
stored = {name: headers[name] for name in _STORED_HEADERS if name in headers}
|
|
1048
|
+
stored.setdefault("content-type", _DEFAULT_CONTENT_TYPE)
|
|
1049
|
+
encoding = ", ".join(
|
|
1050
|
+
token.strip()
|
|
1051
|
+
for token in headers.get("content-encoding", "").split(",")
|
|
1052
|
+
if token.strip() and token.strip() != "aws-chunked"
|
|
1053
|
+
)
|
|
1054
|
+
if encoding:
|
|
1055
|
+
stored["content-encoding"] = encoding
|
|
1056
|
+
|
|
1057
|
+
user_metadata = {
|
|
1058
|
+
name: value for name, value in headers.items() if name.startswith(_META_PREFIX)
|
|
1059
|
+
}
|
|
1060
|
+
metadata_size = sum(
|
|
1061
|
+
len(name) - len(_META_PREFIX) + len(value)
|
|
1062
|
+
for name, value in user_metadata.items()
|
|
1063
|
+
)
|
|
1064
|
+
if metadata_size > MAX_METADATA_BYTES:
|
|
1065
|
+
raise S3Error(errors.METADATA_TOO_LARGE)
|
|
1066
|
+
return stored | user_metadata
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _parse_copy_source(header: str) -> tuple[str, str, str | None]:
|
|
1070
|
+
"""Return the bucket, key and, if one is named, version of a copy's source."""
|
|
1071
|
+
path, _, raw_query = header.partition("?")
|
|
1072
|
+
query = routing.parse_query(raw_query.encode("latin-1"))
|
|
1073
|
+
if query.keys() - {"versionId"}:
|
|
1074
|
+
raise S3Error(errors.INVALID_ARGUMENT, "x-amz-copy-source is not valid.")
|
|
1075
|
+
raw_bucket, separator, raw_key = path.removeprefix("/").partition("/")
|
|
1076
|
+
if not separator or not raw_key:
|
|
1077
|
+
raise S3Error(errors.INVALID_ARGUMENT, "x-amz-copy-source is not valid.")
|
|
1078
|
+
return (
|
|
1079
|
+
routing.parse_raw_bucket(raw_bucket.encode()),
|
|
1080
|
+
routing.parse_key(raw_key.encode()),
|
|
1081
|
+
query.get("versionId"),
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def _copy_source_headers(copied: _CopySource) -> tuple[tuple[str, str], ...]:
|
|
1086
|
+
version_id = copied.record.version_id
|
|
1087
|
+
unversioned = version_id == NULL_VERSION_ID and copied.version_id is None
|
|
1088
|
+
return () if unversioned else (("x-amz-copy-source-version-id", version_id),)
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _version_headers(version_id: str | None) -> tuple[tuple[str, str], ...]:
|
|
1092
|
+
return () if version_id is None else (("x-amz-version-id", version_id),)
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _new_version_headers(version_id: str | None) -> tuple[tuple[str, str], ...]:
|
|
1096
|
+
"""What a write reports. A suspended bucket's "null" isn't worth naming."""
|
|
1097
|
+
return () if version_id == NULL_VERSION_ID else _version_headers(version_id)
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _delete_headers(outcome: DeleteOutcome) -> tuple[tuple[str, str], ...]:
|
|
1101
|
+
marker = (("x-amz-delete-marker", "true"),) if outcome.is_delete_marker else ()
|
|
1102
|
+
return (*marker, *_version_headers(outcome.version_id))
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _etag_headers(etag: str, checksum: Checksum | None) -> tuple[tuple[str, str], ...]:
|
|
1106
|
+
extra = (_checksum_header(checksum),) if checksum else ()
|
|
1107
|
+
return (("etag", f'"{etag}"'), *extra)
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _checksum_header(checksum: Checksum) -> tuple[str, str]:
|
|
1111
|
+
return (f"x-amz-checksum-{checksum.algorithm}", checksum.value)
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _xml_response(
|
|
1115
|
+
body: bytes, extra_headers: tuple[tuple[str, str], ...] = ()
|
|
1116
|
+
) -> Response:
|
|
1117
|
+
return Response(
|
|
1118
|
+
headers=(("content-type", _XML_CONTENT_TYPE), *extra_headers), body=body
|
|
1119
|
+
)
|