supython 0.1.0__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.
- supython/__init__.py +24 -0
- supython/admin/__init__.py +3 -0
- supython/admin/api/__init__.py +24 -0
- supython/admin/api/auth.py +118 -0
- supython/admin/api/auth_templates.py +67 -0
- supython/admin/api/auth_users.py +225 -0
- supython/admin/api/db.py +174 -0
- supython/admin/api/functions.py +92 -0
- supython/admin/api/jobs.py +192 -0
- supython/admin/api/ops.py +224 -0
- supython/admin/api/realtime.py +281 -0
- supython/admin/api/service_auth.py +49 -0
- supython/admin/api/service_auth_templates.py +83 -0
- supython/admin/api/service_auth_users.py +346 -0
- supython/admin/api/service_db.py +214 -0
- supython/admin/api/service_functions.py +287 -0
- supython/admin/api/service_jobs.py +282 -0
- supython/admin/api/service_ops.py +213 -0
- supython/admin/api/service_realtime.py +30 -0
- supython/admin/api/service_storage.py +220 -0
- supython/admin/api/storage.py +117 -0
- supython/admin/api/system.py +37 -0
- supython/admin/audit.py +29 -0
- supython/admin/deps.py +22 -0
- supython/admin/errors.py +16 -0
- supython/admin/schemas.py +310 -0
- supython/admin/session.py +52 -0
- supython/admin/spa.py +38 -0
- supython/admin/static/assets/Alert-dluGVkos.js +49 -0
- supython/admin/static/assets/Audit-Njung3HI.js +2 -0
- supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
- supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
- supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
- supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
- supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
- supython/admin/static/assets/Crons-B67vc39F.js +2 -0
- supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
- supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
- supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
- supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
- supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
- supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
- supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
- supython/admin/static/assets/Input-DppYTq9C.js +259 -0
- supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
- supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
- supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
- supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
- supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
- supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
- supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
- supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
- supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
- supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
- supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
- supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
- supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
- supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
- supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
- supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
- supython/admin/static/assets/Space-n5-XcguU.js +400 -0
- supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
- supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
- supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
- supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
- supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
- supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
- supython/admin/static/assets/Users-wzwajhlh.js +2 -0
- supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
- supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
- supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
- supython/admin/static/assets/get-Ca6unauB.js +2 -0
- supython/admin/static/assets/index-CeE6v959.js +951 -0
- supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
- supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
- supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
- supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
- supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
- supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
- supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
- supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
- supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
- supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
- supython/admin/static/favicon.svg +1 -0
- supython/admin/static/icons.svg +24 -0
- supython/admin/static/index.html +24 -0
- supython/app.py +162 -0
- supython/auth/__init__.py +3 -0
- supython/auth/_email_job.py +11 -0
- supython/auth/providers/__init__.py +34 -0
- supython/auth/providers/github.py +22 -0
- supython/auth/providers/google.py +19 -0
- supython/auth/providers/oauth.py +56 -0
- supython/auth/providers/registry.py +16 -0
- supython/auth/ratelimit.py +39 -0
- supython/auth/router.py +282 -0
- supython/auth/schemas.py +79 -0
- supython/auth/service.py +587 -0
- supython/backups/__init__.py +24 -0
- supython/backups/_backup_job.py +170 -0
- supython/backups/schemas.py +18 -0
- supython/backups/service.py +217 -0
- supython/body_size.py +184 -0
- supython/cli.py +1663 -0
- supython/client/__init__.py +67 -0
- supython/client/_auth.py +249 -0
- supython/client/_client.py +145 -0
- supython/client/_config.py +92 -0
- supython/client/_functions.py +69 -0
- supython/client/_storage.py +255 -0
- supython/client/py.typed +0 -0
- supython/db.py +151 -0
- supython/db_admin.py +8 -0
- supython/extensions.py +36 -0
- supython/functions/__init__.py +19 -0
- supython/functions/context.py +262 -0
- supython/functions/loader.py +307 -0
- supython/functions/router.py +228 -0
- supython/functions/schemas.py +50 -0
- supython/gen/__init__.py +5 -0
- supython/gen/_introspect.py +137 -0
- supython/gen/types_py.py +270 -0
- supython/gen/types_ts.py +365 -0
- supython/health.py +229 -0
- supython/hooks.py +117 -0
- supython/jobs/__init__.py +31 -0
- supython/jobs/backends.py +97 -0
- supython/jobs/context.py +58 -0
- supython/jobs/cron.py +152 -0
- supython/jobs/cron_inproc.py +119 -0
- supython/jobs/decorators.py +76 -0
- supython/jobs/registry.py +79 -0
- supython/jobs/router.py +136 -0
- supython/jobs/schemas.py +92 -0
- supython/jobs/service.py +311 -0
- supython/jobs/worker.py +219 -0
- supython/jwks.py +257 -0
- supython/keyset.py +279 -0
- supython/logging_config.py +291 -0
- supython/mail.py +33 -0
- supython/mailer.py +65 -0
- supython/migrate.py +81 -0
- supython/migrations/0001_extensions_and_roles.sql +46 -0
- supython/migrations/0002_auth_schema.sql +66 -0
- supython/migrations/0003_demo_todos.sql +42 -0
- supython/migrations/0004_auth_v0_2.sql +47 -0
- supython/migrations/0005_storage_schema.sql +117 -0
- supython/migrations/0006_realtime_schema.sql +206 -0
- supython/migrations/0007_jobs_schema.sql +254 -0
- supython/migrations/0008_jobs_last_error.sql +56 -0
- supython/migrations/0009_auth_rate_limits.sql +33 -0
- supython/migrations/0010_worker_heartbeat.sql +14 -0
- supython/migrations/0011_admin_schema.sql +45 -0
- supython/migrations/0012_auth_banned_until.sql +10 -0
- supython/migrations/0013_email_templates.sql +19 -0
- supython/migrations/0014_realtime_payload_warning.sql +96 -0
- supython/migrations/0015_backups_schema.sql +14 -0
- supython/passwords.py +15 -0
- supython/realtime/__init__.py +6 -0
- supython/realtime/broker.py +814 -0
- supython/realtime/protocol.py +234 -0
- supython/realtime/router.py +184 -0
- supython/realtime/schemas.py +207 -0
- supython/realtime/service.py +261 -0
- supython/realtime/topics.py +175 -0
- supython/realtime/websocket.py +586 -0
- supython/scaffold/__init__.py +5 -0
- supython/scaffold/init_project.py +144 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/apps_hooks.py.tmpl +11 -0
- supython/scaffold/templates/apps_jobs.py.tmpl +8 -0
- supython/scaffold/templates/asgi.py.tmpl +14 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +45 -0
- supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
- supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
- supython/scaffold/templates/env.example.tmpl +168 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/manage.py.tmpl +11 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/scaffold/templates/package_init.py.tmpl +1 -0
- supython/scaffold/templates/settings.py.tmpl +31 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +244 -0
- supython/settings_module.py +117 -0
- supython/storage/__init__.py +5 -0
- supython/storage/backends.py +392 -0
- supython/storage/router.py +341 -0
- supython/storage/schemas.py +50 -0
- supython/storage/service.py +445 -0
- supython/storage/signing.py +119 -0
- supython/tokens.py +85 -0
- supython-0.1.0.dist-info/METADATA +756 -0
- supython-0.1.0.dist-info/RECORD +200 -0
- supython-0.1.0.dist-info/WHEEL +4 -0
- supython-0.1.0.dist-info/entry_points.txt +2 -0
- supython-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""HTTP surface for storage. Thin: parse, delegate, translate, stream."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
import jwt
|
|
7
|
+
from fastapi import (
|
|
8
|
+
APIRouter,
|
|
9
|
+
Depends,
|
|
10
|
+
File,
|
|
11
|
+
Header,
|
|
12
|
+
HTTPException,
|
|
13
|
+
Query,
|
|
14
|
+
UploadFile,
|
|
15
|
+
status,
|
|
16
|
+
)
|
|
17
|
+
from fastapi.responses import StreamingResponse
|
|
18
|
+
|
|
19
|
+
from .. import db, tokens
|
|
20
|
+
from . import service
|
|
21
|
+
from .backends import ObjectStream, get_backend
|
|
22
|
+
from .schemas import (
|
|
23
|
+
BucketResponse,
|
|
24
|
+
CreateBucketRequest,
|
|
25
|
+
ObjectResponse,
|
|
26
|
+
SignedUrlRequest,
|
|
27
|
+
SignedUrlResponse,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
router = APIRouter(prefix="/storage/v1", tags=["storage"])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_UPLOAD_CHUNK = 64 * 1024
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Helpers
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _storage_error(exc: service.StorageError) -> HTTPException:
|
|
42
|
+
return HTTPException(
|
|
43
|
+
status_code=exc.status,
|
|
44
|
+
detail={"code": exc.code, "message": exc.message},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _current_claims(
|
|
49
|
+
authorization: Annotated[str | None, Header()] = None,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""Return the decoded JWT claims so we can pump them into ``as_role``."""
|
|
52
|
+
if not authorization or not authorization.lower().startswith("bearer "):
|
|
53
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing bearer token")
|
|
54
|
+
token = authorization.split(" ", 1)[1]
|
|
55
|
+
try:
|
|
56
|
+
return tokens.decode_access_token(token)
|
|
57
|
+
except jwt.PyJWTError as exc:
|
|
58
|
+
raise HTTPException(
|
|
59
|
+
status.HTTP_401_UNAUTHORIZED, f"Invalid token: {exc}"
|
|
60
|
+
) from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _claims_role(claims: dict[str, Any]) -> str:
|
|
64
|
+
role = claims.get("role")
|
|
65
|
+
if not isinstance(role, str) or not role:
|
|
66
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token missing role claim")
|
|
67
|
+
return role
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_range(header: str | None, total_unknown: bool = True) -> tuple[int, int | None] | None:
|
|
71
|
+
"""Parse an HTTP ``Range: bytes=START-END`` header. Returns None if absent.
|
|
72
|
+
|
|
73
|
+
Only single-range, byte-unit requests are supported. Multi-range requests
|
|
74
|
+
are intentionally rejected — they materially complicate streaming and are
|
|
75
|
+
rare in practice.
|
|
76
|
+
"""
|
|
77
|
+
if not header:
|
|
78
|
+
return None
|
|
79
|
+
if not header.lower().startswith("bytes="):
|
|
80
|
+
raise HTTPException(
|
|
81
|
+
status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE,
|
|
82
|
+
"Only byte ranges are supported",
|
|
83
|
+
)
|
|
84
|
+
spec = header.split("=", 1)[1].strip()
|
|
85
|
+
if "," in spec:
|
|
86
|
+
raise HTTPException(
|
|
87
|
+
status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE,
|
|
88
|
+
"Multi-range requests are not supported",
|
|
89
|
+
)
|
|
90
|
+
if "-" not in spec:
|
|
91
|
+
raise HTTPException(
|
|
92
|
+
status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE,
|
|
93
|
+
"Malformed range",
|
|
94
|
+
)
|
|
95
|
+
start_s, end_s = spec.split("-", 1)
|
|
96
|
+
try:
|
|
97
|
+
start = int(start_s) if start_s else 0
|
|
98
|
+
end: int | None = int(end_s) if end_s else None
|
|
99
|
+
except ValueError as exc:
|
|
100
|
+
raise HTTPException(
|
|
101
|
+
status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE,
|
|
102
|
+
"Malformed range",
|
|
103
|
+
) from exc
|
|
104
|
+
return start, end
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _stream_response(stream: ObjectStream, *, filename: str | None = None) -> StreamingResponse:
|
|
108
|
+
headers: dict[str, str] = {
|
|
109
|
+
"content-length": str(stream.content_length),
|
|
110
|
+
"accept-ranges": "bytes",
|
|
111
|
+
}
|
|
112
|
+
if stream.etag:
|
|
113
|
+
headers["etag"] = stream.etag
|
|
114
|
+
if stream.content_range:
|
|
115
|
+
headers["content-range"] = stream.content_range
|
|
116
|
+
if filename:
|
|
117
|
+
headers["content-disposition"] = f'inline; filename="{filename}"'
|
|
118
|
+
media_type = stream.content_type or "application/octet-stream"
|
|
119
|
+
return StreamingResponse(
|
|
120
|
+
stream.iterator,
|
|
121
|
+
status_code=stream.status_code,
|
|
122
|
+
headers=headers,
|
|
123
|
+
media_type=media_type,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _file_iterator(file: UploadFile) -> AsyncIterator[bytes]:
|
|
128
|
+
while True:
|
|
129
|
+
chunk = await file.read(_UPLOAD_CHUNK)
|
|
130
|
+
if not chunk:
|
|
131
|
+
break
|
|
132
|
+
yield chunk
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Bucket endpoints
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@router.post("/bucket", response_model=BucketResponse, status_code=201)
|
|
141
|
+
async def create_bucket(
|
|
142
|
+
payload: CreateBucketRequest,
|
|
143
|
+
claims: Annotated[dict[str, Any], Depends(_current_claims)],
|
|
144
|
+
) -> BucketResponse:
|
|
145
|
+
try:
|
|
146
|
+
async with db.as_role(_claims_role(claims), claims) as conn:
|
|
147
|
+
return await service.create_bucket(
|
|
148
|
+
conn,
|
|
149
|
+
name=payload.name,
|
|
150
|
+
public=payload.public,
|
|
151
|
+
file_size_limit=payload.file_size_limit,
|
|
152
|
+
allowed_mime_types=payload.allowed_mime_types,
|
|
153
|
+
)
|
|
154
|
+
except service.StorageError as exc:
|
|
155
|
+
raise _storage_error(exc) from exc
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@router.get("/bucket", response_model=list[BucketResponse])
|
|
159
|
+
async def list_buckets(
|
|
160
|
+
claims: Annotated[dict[str, Any], Depends(_current_claims)],
|
|
161
|
+
) -> list[BucketResponse]:
|
|
162
|
+
async with db.as_role(_claims_role(claims), claims) as conn:
|
|
163
|
+
return await service.list_buckets(conn)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@router.get("/bucket/{name}", response_model=BucketResponse)
|
|
167
|
+
async def get_bucket(
|
|
168
|
+
name: str,
|
|
169
|
+
claims: Annotated[dict[str, Any], Depends(_current_claims)],
|
|
170
|
+
) -> BucketResponse:
|
|
171
|
+
try:
|
|
172
|
+
async with db.as_role(_claims_role(claims), claims) as conn:
|
|
173
|
+
return await service.get_bucket(conn, name)
|
|
174
|
+
except service.StorageError as exc:
|
|
175
|
+
raise _storage_error(exc) from exc
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@router.delete("/bucket/{name}", status_code=204)
|
|
179
|
+
async def delete_bucket(
|
|
180
|
+
name: str,
|
|
181
|
+
claims: Annotated[dict[str, Any], Depends(_current_claims)],
|
|
182
|
+
) -> None:
|
|
183
|
+
backend = get_backend()
|
|
184
|
+
try:
|
|
185
|
+
async with db.as_role(_claims_role(claims), claims) as conn:
|
|
186
|
+
await service.delete_bucket(conn, backend, name)
|
|
187
|
+
except service.StorageError as exc:
|
|
188
|
+
raise _storage_error(exc) from exc
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# Signed URLs (registered before generic /{bucket}/{path} to avoid shadowing)
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@router.post(
|
|
197
|
+
"/object/sign/{bucket}/{path:path}",
|
|
198
|
+
response_model=SignedUrlResponse,
|
|
199
|
+
)
|
|
200
|
+
async def sign_object(
|
|
201
|
+
bucket: str,
|
|
202
|
+
path: str,
|
|
203
|
+
claims: Annotated[dict[str, Any], Depends(_current_claims)],
|
|
204
|
+
payload: SignedUrlRequest | None = None,
|
|
205
|
+
) -> SignedUrlResponse:
|
|
206
|
+
expires_in = payload.expires_in if payload else None
|
|
207
|
+
try:
|
|
208
|
+
async with db.as_role(_claims_role(claims), claims) as conn:
|
|
209
|
+
return await service.issue_signed_url(
|
|
210
|
+
conn,
|
|
211
|
+
bucket_name=bucket,
|
|
212
|
+
path=path,
|
|
213
|
+
expires_in=expires_in,
|
|
214
|
+
)
|
|
215
|
+
except service.StorageError as exc:
|
|
216
|
+
raise _storage_error(exc) from exc
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@router.get("/object/signed/{bucket}/{path:path}")
|
|
220
|
+
async def fetch_signed_object(
|
|
221
|
+
bucket: str,
|
|
222
|
+
path: str,
|
|
223
|
+
token: Annotated[str, Query(description="HMAC token from /object/sign/...")],
|
|
224
|
+
range_header: Annotated[str | None, Header(alias="range")] = None,
|
|
225
|
+
) -> StreamingResponse:
|
|
226
|
+
byte_range = _parse_range(range_header)
|
|
227
|
+
backend = get_backend()
|
|
228
|
+
try:
|
|
229
|
+
async with db.acquire() as conn:
|
|
230
|
+
obj, stream = await service.verify_signed_download(
|
|
231
|
+
conn,
|
|
232
|
+
backend,
|
|
233
|
+
bucket_name=bucket,
|
|
234
|
+
path=path,
|
|
235
|
+
token=token,
|
|
236
|
+
byte_range=byte_range,
|
|
237
|
+
)
|
|
238
|
+
except service.StorageError as exc:
|
|
239
|
+
raise _storage_error(exc) from exc
|
|
240
|
+
return _stream_response(stream, filename=obj.name.rsplit("/", 1)[-1])
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# Public buckets (registered before generic /{bucket}/{path} to avoid shadowing)
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@router.get("/object/public/{bucket}/{path:path}")
|
|
249
|
+
async def fetch_public_object(
|
|
250
|
+
bucket: str,
|
|
251
|
+
path: str,
|
|
252
|
+
range_header: Annotated[str | None, Header(alias="range")] = None,
|
|
253
|
+
) -> StreamingResponse:
|
|
254
|
+
byte_range = _parse_range(range_header)
|
|
255
|
+
backend = get_backend()
|
|
256
|
+
try:
|
|
257
|
+
async with db.as_role("anon", {"role": "anon"}) as conn:
|
|
258
|
+
obj, stream = await service.download_public_object(
|
|
259
|
+
conn,
|
|
260
|
+
backend,
|
|
261
|
+
bucket_name=bucket,
|
|
262
|
+
path=path,
|
|
263
|
+
byte_range=byte_range,
|
|
264
|
+
)
|
|
265
|
+
except service.StorageError as exc:
|
|
266
|
+
raise _storage_error(exc) from exc
|
|
267
|
+
return _stream_response(stream, filename=obj.name.rsplit("/", 1)[-1])
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
# Object endpoints (generic wildcard routes registered last)
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@router.post(
|
|
276
|
+
"/object/{bucket}/{path:path}",
|
|
277
|
+
response_model=ObjectResponse,
|
|
278
|
+
status_code=201,
|
|
279
|
+
)
|
|
280
|
+
async def upload_object(
|
|
281
|
+
bucket: str,
|
|
282
|
+
path: str,
|
|
283
|
+
claims: Annotated[dict[str, Any], Depends(_current_claims)],
|
|
284
|
+
file: Annotated[UploadFile, File(description="The file to upload")],
|
|
285
|
+
) -> ObjectResponse:
|
|
286
|
+
backend = get_backend()
|
|
287
|
+
content_type = file.content_type
|
|
288
|
+
try:
|
|
289
|
+
async with db.as_role(_claims_role(claims), claims) as conn:
|
|
290
|
+
return await service.upload_object(
|
|
291
|
+
conn,
|
|
292
|
+
backend,
|
|
293
|
+
bucket_name=bucket,
|
|
294
|
+
path=path,
|
|
295
|
+
data=_file_iterator(file),
|
|
296
|
+
content_type=content_type,
|
|
297
|
+
)
|
|
298
|
+
except service.StorageError as exc:
|
|
299
|
+
raise _storage_error(exc) from exc
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@router.get("/object/{bucket}/{path:path}")
|
|
303
|
+
async def download_object(
|
|
304
|
+
bucket: str,
|
|
305
|
+
path: str,
|
|
306
|
+
claims: Annotated[dict[str, Any], Depends(_current_claims)],
|
|
307
|
+
range_header: Annotated[str | None, Header(alias="range")] = None,
|
|
308
|
+
) -> StreamingResponse:
|
|
309
|
+
byte_range = _parse_range(range_header)
|
|
310
|
+
backend = get_backend()
|
|
311
|
+
try:
|
|
312
|
+
async with db.as_role(_claims_role(claims), claims) as conn:
|
|
313
|
+
obj, stream = await service.download_object(
|
|
314
|
+
conn,
|
|
315
|
+
backend,
|
|
316
|
+
bucket_name=bucket,
|
|
317
|
+
path=path,
|
|
318
|
+
byte_range=byte_range,
|
|
319
|
+
)
|
|
320
|
+
except service.StorageError as exc:
|
|
321
|
+
raise _storage_error(exc) from exc
|
|
322
|
+
return _stream_response(stream, filename=obj.name.rsplit("/", 1)[-1])
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@router.delete("/object/{bucket}/{path:path}", status_code=204)
|
|
326
|
+
async def delete_object(
|
|
327
|
+
bucket: str,
|
|
328
|
+
path: str,
|
|
329
|
+
claims: Annotated[dict[str, Any], Depends(_current_claims)],
|
|
330
|
+
) -> None:
|
|
331
|
+
backend = get_backend()
|
|
332
|
+
try:
|
|
333
|
+
async with db.as_role(_claims_role(claims), claims) as conn:
|
|
334
|
+
await service.delete_object(
|
|
335
|
+
conn,
|
|
336
|
+
backend,
|
|
337
|
+
bucket_name=bucket,
|
|
338
|
+
path=path,
|
|
339
|
+
)
|
|
340
|
+
except service.StorageError as exc:
|
|
341
|
+
raise _storage_error(exc) from exc
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CreateBucketRequest(BaseModel):
|
|
8
|
+
name: str = Field(min_length=1, max_length=63, pattern=r"^[a-z0-9][a-z0-9_-]{0,62}$")
|
|
9
|
+
public: bool = False
|
|
10
|
+
file_size_limit: int | None = Field(default=None, ge=1)
|
|
11
|
+
allowed_mime_types: list[str] | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BucketResponse(BaseModel):
|
|
15
|
+
id: UUID
|
|
16
|
+
name: str
|
|
17
|
+
owner: UUID | None
|
|
18
|
+
public: bool
|
|
19
|
+
file_size_limit: int | None
|
|
20
|
+
allowed_mime_types: list[str] | None
|
|
21
|
+
created_at: datetime
|
|
22
|
+
updated_at: datetime
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ObjectResponse(BaseModel):
|
|
26
|
+
id: UUID
|
|
27
|
+
bucket_id: UUID
|
|
28
|
+
bucket: str
|
|
29
|
+
name: str
|
|
30
|
+
owner: UUID
|
|
31
|
+
size: int
|
|
32
|
+
mime_type: str | None
|
|
33
|
+
etag: str | None
|
|
34
|
+
created_at: datetime
|
|
35
|
+
updated_at: datetime
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SignedUrlRequest(BaseModel):
|
|
39
|
+
expires_in: int | None = Field(
|
|
40
|
+
default=None,
|
|
41
|
+
ge=1,
|
|
42
|
+
description="Lifetime of the signed URL in seconds. Defaults to settings.",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SignedUrlResponse(BaseModel):
|
|
47
|
+
signed_url: str
|
|
48
|
+
token: str
|
|
49
|
+
expires_at: datetime
|
|
50
|
+
expires_in: int
|