Skip to content

Commit 1472abc

Browse files
feat: server side validation
1 parent 17ec46a commit 1472abc

1 file changed

Lines changed: 58 additions & 2 deletions

File tree

src/backend/app/routes/upload.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
from datetime import datetime, timedelta, timezone
33
from typing import Annotated
44

5-
from fastapi import APIRouter, Form, UploadFile
5+
from fastapi import APIRouter, Form, HTTPException, UploadFile, status
6+
from sqlmodel import select
67

78
from app.converter.bytes import ByteSize
89
from app.deps import S3Dep, SessionDep
10+
from app.models.config import Config
911
from app.models.files import File, FileOut
1012
from app.settings import settings
1113
from app.tasks.clean_file import delete_expired_file
@@ -17,6 +19,26 @@
1719
).total_bytes()
1820

1921

22+
async def _get_current_storage_used(session: SessionDep, s3: S3Dep) -> int:
23+
"""Sum the sizes (ContentLength) of currently active (non-expired) files."""
24+
now = datetime.now(timezone.utc).replace(tzinfo=None)
25+
query = select(File).where(
26+
File.expires_at > now, File.download_count < File.expire_after_n_download
27+
)
28+
result = await session.exec(query)
29+
files = result.all()
30+
31+
total = 0
32+
for f in files:
33+
try:
34+
resp = await s3.head_object(Bucket=settings.RUSTFS_BUCKET_NAME, Key=f.key)
35+
total += int(resp.get("ContentLength", 0) or 0)
36+
except Exception:
37+
# If the object is missing or head fails for any reason, ignore and continue
38+
continue
39+
return total
40+
41+
2042
@router.post("/upload")
2143
async def upload_file(
2244
file: UploadFile,
@@ -31,6 +53,28 @@ async def upload_file(
3153
filename = uuid.uuid7() # type: ignore
3254

3355
key = uuid.uuid7()
56+
57+
# Load the singleton config and determine current usage
58+
config_q = select(Config)
59+
config_result = await session.exec(config_q)
60+
config = config_result.first()
61+
if not config:
62+
raise HTTPException(
63+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
64+
detail="Configuration not found",
65+
)
66+
67+
total_limit = config.total_storage_limit
68+
current_used = 0
69+
if total_limit is not None:
70+
current_used = await _get_current_storage_used(session, s3)
71+
# Quick fail: no space at all left
72+
if current_used >= total_limit:
73+
raise HTTPException(
74+
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
75+
detail="Storage quota exceeded",
76+
)
77+
3478
resp = await s3.create_multipart_upload(
3579
Bucket=settings.RUSTFS_BUCKET_NAME,
3680
Key=str(key),
@@ -39,13 +83,25 @@ async def upload_file(
3983
upload_id = resp["UploadId"]
4084
parts = []
4185
part_number = 1
86+
uploaded_size = 0
4287

4388
try:
4489
while True:
4590
chunk = await file.read(CHUNK_SIZE)
4691
if not chunk:
4792
break
4893

94+
# Enforce total storage limit incrementally
95+
if (
96+
total_limit is not None
97+
and current_used + uploaded_size + len(chunk) > total_limit
98+
):
99+
# This will be caught by the outer except block which aborts the multipart upload
100+
raise HTTPException(
101+
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
102+
detail="Storage quota exceeded",
103+
)
104+
49105
part = await s3.upload_part(
50106
Bucket=settings.RUSTFS_BUCKET_NAME,
51107
Key=str(key),
@@ -56,6 +112,7 @@ async def upload_file(
56112

57113
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
58114
part_number += 1
115+
uploaded_size += len(chunk)
59116

60117
await s3.complete_multipart_upload(
61118
Bucket=settings.RUSTFS_BUCKET_NAME,
@@ -71,7 +128,6 @@ async def upload_file(
71128
UploadId=upload_id,
72129
)
73130
raise
74-
75131
now = datetime.now(timezone.utc).replace(tzinfo=None)
76132
file_obj = File(
77133
filename=str(filename),

0 commit comments

Comments
 (0)