Spaces:
Running
Running
Commit ·
efa9f90
1
Parent(s): 5f78436
yes
Browse files- app/api/v1/gcs.py +25 -7
- app/services/gcs_service.py +1 -1
- app/services/media_conversion_service.py +6 -0
app/api/v1/gcs.py
CHANGED
|
@@ -2,9 +2,12 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import base64
|
| 4 |
import json
|
|
|
|
| 5 |
import re
|
| 6 |
import time
|
|
|
|
| 7 |
from typing import Any, Dict, Optional
|
|
|
|
| 8 |
|
| 9 |
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, Response, UploadFile
|
| 10 |
|
|
@@ -441,10 +444,10 @@ async def list_objects(
|
|
| 441 |
summary="Upload an object from a file or a URL (up to the server upload limit)")
|
| 442 |
async def upload_object(
|
| 443 |
bucket: str,
|
| 444 |
-
name: str = Form(
|
| 445 |
file: Optional[UploadFile] = File(None, description="Object content to upload"),
|
| 446 |
file_url: Optional[str] = Form(None, description="Publicly reachable URL whose content becomes the object"),
|
| 447 |
-
content_type: str = Form(
|
| 448 |
metadata_json: str = Form(None, description="Optional JSON object of object metadata to set on upload"),
|
| 449 |
if_generation_match: Optional[int] = Form(None, description="Generation match condition"),
|
| 450 |
credentials_file: Optional[UploadFile] = File(None, description="Service account JSON file (alternative to inline credentials)"),
|
|
@@ -458,12 +461,27 @@ async def upload_object(
|
|
| 458 |
raise HTTPException(status_code=422, detail="Either 'file' or 'file_url' must be provided to upload an object.")
|
| 459 |
if file is not None and file_url:
|
| 460 |
raise HTTPException(status_code=422, detail="Provide either 'file' or 'file_url', not both.")
|
| 461 |
-
|
| 462 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
cleaned_name = name.strip()
|
| 464 |
-
if
|
| 465 |
raise HTTPException(status_code=422, detail="Object name must be non-empty and must not start with '/'.")
|
| 466 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
if credentials_file is not None:
|
| 468 |
cred_bytes = await _read_file_limited(credentials_file, what="credentials file")
|
| 469 |
creds = await _resolve(service, file_bytes=cred_bytes, file_name=credentials_file.filename)
|
|
@@ -477,8 +495,8 @@ async def upload_object(
|
|
| 477 |
raise HTTPException(status_code=exc.status_code, detail=exc.message)
|
| 478 |
if len(content) > _MAX_UPLOAD_BYTES:
|
| 479 |
raise HTTPException(status_code=413, detail=f"Content from file_url exceeds the {_MAX_UPLOAD_BYTES} byte upload limit")
|
| 480 |
-
if content_type == "application/octet-stream":
|
| 481 |
-
content_type = fetched_type
|
| 482 |
else:
|
| 483 |
content = await _read_file_limited(file, what="file")
|
| 484 |
|
|
|
|
| 2 |
|
| 3 |
import base64
|
| 4 |
import json
|
| 5 |
+
import mimetypes
|
| 6 |
import re
|
| 7 |
import time
|
| 8 |
+
from pathlib import Path
|
| 9 |
from typing import Any, Dict, Optional
|
| 10 |
+
from urllib.parse import urlparse
|
| 11 |
|
| 12 |
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, Response, UploadFile
|
| 13 |
|
|
|
|
| 444 |
summary="Upload an object from a file or a URL (up to the server upload limit)")
|
| 445 |
async def upload_object(
|
| 446 |
bucket: str,
|
| 447 |
+
name: Optional[str] = Form(None, min_length=1, max_length=1024, description="Destination object name (defaults to the uploaded file name)"),
|
| 448 |
file: Optional[UploadFile] = File(None, description="Object content to upload"),
|
| 449 |
file_url: Optional[str] = Form(None, description="Publicly reachable URL whose content becomes the object"),
|
| 450 |
+
content_type: Optional[str] = Form(None, description="Content-Type of the uploaded object (auto-detected when omitted)"),
|
| 451 |
metadata_json: str = Form(None, description="Optional JSON object of object metadata to set on upload"),
|
| 452 |
if_generation_match: Optional[int] = Form(None, description="Generation match condition"),
|
| 453 |
credentials_file: Optional[UploadFile] = File(None, description="Service account JSON file (alternative to inline credentials)"),
|
|
|
|
| 461 |
raise HTTPException(status_code=422, detail="Either 'file' or 'file_url' must be provided to upload an object.")
|
| 462 |
if file is not None and file_url:
|
| 463 |
raise HTTPException(status_code=422, detail="Provide either 'file' or 'file_url', not both.")
|
| 464 |
+
|
| 465 |
+
if name is None:
|
| 466 |
+
if file is not None and file.filename:
|
| 467 |
+
name = Path(file.filename).name
|
| 468 |
+
elif file_url:
|
| 469 |
+
parsed_url = urlparse(file_url)
|
| 470 |
+
name = Path(parsed_url.path).name or "download"
|
| 471 |
+
if not name or not name.strip():
|
| 472 |
+
raise HTTPException(status_code=422, detail="Object name must be non-empty and must not start with '/'.")
|
| 473 |
cleaned_name = name.strip()
|
| 474 |
+
if cleaned_name.startswith("/"):
|
| 475 |
raise HTTPException(status_code=422, detail="Object name must be non-empty and must not start with '/'.")
|
| 476 |
|
| 477 |
+
if content_type is None:
|
| 478 |
+
if file is not None and file.content_type:
|
| 479 |
+
content_type = file.content_type
|
| 480 |
+
else:
|
| 481 |
+
content_type = mimetypes.guess_type(cleaned_name)[0] or "application/octet-stream"
|
| 482 |
+
if not re.fullmatch(r"[\w./+-]{1,255}", content_type):
|
| 483 |
+
raise HTTPException(status_code=422, detail="content_type must be a valid media type.")
|
| 484 |
+
|
| 485 |
if credentials_file is not None:
|
| 486 |
cred_bytes = await _read_file_limited(credentials_file, what="credentials file")
|
| 487 |
creds = await _resolve(service, file_bytes=cred_bytes, file_name=credentials_file.filename)
|
|
|
|
| 495 |
raise HTTPException(status_code=exc.status_code, detail=exc.message)
|
| 496 |
if len(content) > _MAX_UPLOAD_BYTES:
|
| 497 |
raise HTTPException(status_code=413, detail=f"Content from file_url exceeds the {_MAX_UPLOAD_BYTES} byte upload limit")
|
| 498 |
+
if fetched_type and (content_type == "application/octet-stream" or content_type == mimetypes.guess_type(cleaned_name)[0]):
|
| 499 |
+
content_type = fetched_type
|
| 500 |
else:
|
| 501 |
content = await _read_file_limited(file, what="file")
|
| 502 |
|
app/services/gcs_service.py
CHANGED
|
@@ -515,7 +515,7 @@ class GCSService:
|
|
| 515 |
async def test_bucket_permissions(
|
| 516 |
self, creds: GCSCredentials, bucket: str, permissions: List[str],
|
| 517 |
) -> Dict[str, Any]:
|
| 518 |
-
params = {"permissions":
|
| 519 |
return await self._request("GET", f"/b/{quote(bucket, safe='')}/iam/testPermissions", creds, params=params)
|
| 520 |
|
| 521 |
# ------------------------------------------------------------------
|
|
|
|
| 515 |
async def test_bucket_permissions(
|
| 516 |
self, creds: GCSCredentials, bucket: str, permissions: List[str],
|
| 517 |
) -> Dict[str, Any]:
|
| 518 |
+
params = {"permissions": permissions}
|
| 519 |
return await self._request("GET", f"/b/{quote(bucket, safe='')}/iam/testPermissions", creds, params=params)
|
| 520 |
|
| 521 |
# ------------------------------------------------------------------
|
app/services/media_conversion_service.py
CHANGED
|
@@ -308,6 +308,12 @@ def _convert_image_bytes(data: bytes, params: ImageConversionParams, out_path: P
|
|
| 308 |
img = img.resize(target, Image.LANCZOS)
|
| 309 |
|
| 310 |
fmt = _fmt_str(params.format)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
if fmt == "JPEG":
|
| 312 |
img = _normalise_for_jpeg(img)
|
| 313 |
elif fmt in ("PNG", "WEBP") and img.mode not in ("RGB", "RGBA", "L", "LA"):
|
|
|
|
| 308 |
img = img.resize(target, Image.LANCZOS)
|
| 309 |
|
| 310 |
fmt = _fmt_str(params.format)
|
| 311 |
+
if detected == fmt:
|
| 312 |
+
raise MediaConversionError(
|
| 313 |
+
f"Input is already {fmt}. Same-format conversion is not supported; "
|
| 314 |
+
"choose a different output format.",
|
| 315 |
+
status_code=422,
|
| 316 |
+
)
|
| 317 |
if fmt == "JPEG":
|
| 318 |
img = _normalise_for_jpeg(img)
|
| 319 |
elif fmt in ("PNG", "WEBP") and img.mode not in ("RGB", "RGBA", "L", "LA"):
|