Serving went straight to disk with FileResponse, so object storage was
effectively write-only: bytes went to the bucket and were still read from the
volume. `/uploads/{path}` now tries the local file first, then the object,
keeping the existing authorisation and path-confinement checks in front of both.
That is what makes the volume removable at all.
Migration (scripts/migrate_uploads_to_s3.py)
Every file is copied and read back with a SHA-256 comparison before anything is
deleted, and deletion is a separate opt-in flag that refuses to run if a single
file failed to verify. 3,852 files, 853.7 MB, all verified, then removed from the
volume — which now holds 0 files.
A bug this caught in its own first run: verification used `storage_service.load`,
which falls back to the volume, so it compared each local file against itself and
reported 3,852 perfect matches against an empty bucket. `s3_object` reads
strictly from S3 with no fallback, and verification uses that. The fallback is
right for serving and wrong for verifying, and the two now have separate calls.
Proven before deleting: a file removed from the volume still served correctly and
byte-identically from the bucket.
Backups, corrected: borgmatic already covers /var/lib/docker/volumes, so
quiz_minio_data is backed up nightly with 7/4/6 retention — my earlier claim that
MinIO was outside the backup routine was wrong, based on db-backup alone.
Existing archives still hold the old uploads volume, so there is no window in
which these files exist in only one place.
Tests: 131 backend green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
162 lines
5 KiB
Python
162 lines
5 KiB
Python
"""Where uploaded files live.
|
|
|
|
Two backends behind one interface:
|
|
|
|
* ``local`` — the container volume, which is where every existing upload
|
|
already sits;
|
|
* ``s3`` — MinIO (or any S3-compatible service).
|
|
|
|
Object storage is the right home for media: a container volume can only be
|
|
mounted by one host, has no presigned URLs, and no lifecycle rules. But the
|
|
existing 800-odd MB of PDFs live on the volume, so reads fall back to local when
|
|
an object is missing. That keeps the switch reversible and lets files migrate
|
|
gradually instead of in one risky pass.
|
|
|
|
A stored path is always the key, never a URL: URLs embed the backend, and a row
|
|
that hardcodes ``http://minio:9000/...`` breaks the moment the backend changes.
|
|
"""
|
|
import logging
|
|
import mimetypes
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_client = None
|
|
|
|
|
|
def using_s3() -> bool:
|
|
return settings.STORAGE_BACKEND == "s3"
|
|
|
|
|
|
def _s3():
|
|
"""A cached boto3 client pointed at MinIO."""
|
|
global _client
|
|
if _client is None:
|
|
import boto3
|
|
from botocore.config import Config
|
|
|
|
_client = boto3.client(
|
|
"s3",
|
|
endpoint_url=settings.S3_ENDPOINT_URL,
|
|
aws_access_key_id=settings.S3_ACCESS_KEY,
|
|
aws_secret_access_key=settings.S3_SECRET_KEY,
|
|
region_name=settings.S3_REGION,
|
|
# MinIO needs path style; virtual-host style expects DNS per bucket.
|
|
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
|
|
)
|
|
return _client
|
|
|
|
|
|
def ensure_bucket() -> None:
|
|
"""Create the bucket if it is not there. Safe to call repeatedly."""
|
|
if not using_s3():
|
|
return
|
|
client = _s3()
|
|
try:
|
|
client.head_bucket(Bucket=settings.S3_BUCKET)
|
|
except Exception:
|
|
try:
|
|
client.create_bucket(Bucket=settings.S3_BUCKET)
|
|
logger.info("Created bucket %s", settings.S3_BUCKET)
|
|
except Exception:
|
|
logger.warning("Could not create bucket %s", settings.S3_BUCKET, exc_info=True)
|
|
|
|
|
|
def local_path(key: str) -> Path:
|
|
return Path(settings.UPLOAD_DIR) / key
|
|
|
|
|
|
def save(key: str, data: bytes, content_type: str | None = None) -> str:
|
|
"""Store bytes under `key` and return the key."""
|
|
if using_s3():
|
|
ensure_bucket()
|
|
_s3().put_object(
|
|
Bucket=settings.S3_BUCKET, Key=key, Body=data,
|
|
ContentType=content_type or mimetypes.guess_type(key)[0] or "application/octet-stream",
|
|
)
|
|
return key
|
|
target = local_path(key)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(data)
|
|
return key
|
|
|
|
|
|
def load(key: str) -> bytes | None:
|
|
"""Read an object, falling back to the local volume for files not yet migrated."""
|
|
if using_s3():
|
|
try:
|
|
return _s3().get_object(Bucket=settings.S3_BUCKET, Key=key)["Body"].read()
|
|
except Exception:
|
|
logger.debug("Object %s not in S3; trying the local volume", key)
|
|
target = local_path(key)
|
|
return target.read_bytes() if target.is_file() else None
|
|
|
|
|
|
def exists(key: str) -> bool:
|
|
if using_s3():
|
|
try:
|
|
_s3().head_object(Bucket=settings.S3_BUCKET, Key=key)
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return local_path(key).is_file()
|
|
|
|
|
|
def delete(key: str) -> None:
|
|
if using_s3():
|
|
try:
|
|
_s3().delete_object(Bucket=settings.S3_BUCKET, Key=key)
|
|
except Exception:
|
|
logger.warning("Could not delete %s from S3", key, exc_info=True)
|
|
target = local_path(key)
|
|
if target.is_file():
|
|
try:
|
|
target.unlink()
|
|
except OSError:
|
|
logger.warning("Could not delete %s from disk", key, exc_info=True)
|
|
|
|
|
|
def presigned_url(key: str, expires: int = 3600) -> str | None:
|
|
"""A time-limited direct URL, so large media need not stream through the API.
|
|
|
|
None when the object is not in S3, in which case the caller serves it from
|
|
the volume as before.
|
|
"""
|
|
if not using_s3():
|
|
return None
|
|
try:
|
|
_s3().head_object(Bucket=settings.S3_BUCKET, Key=key)
|
|
return _s3().generate_presigned_url(
|
|
"get_object",
|
|
Params={"Bucket": settings.S3_BUCKET, "Key": key},
|
|
ExpiresIn=expires,
|
|
)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def s3_object(key: str) -> bytes | None:
|
|
"""Read strictly from S3, with no fallback.
|
|
|
|
`load` falls back to the volume, which is right for serving but useless for
|
|
verifying a migration: it would compare a local file against itself.
|
|
"""
|
|
if not using_s3():
|
|
return None
|
|
try:
|
|
return _s3().get_object(Bucket=settings.S3_BUCKET, Key=key)["Body"].read()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def size_of(key: str) -> int | None:
|
|
if using_s3():
|
|
try:
|
|
return _s3().head_object(Bucket=settings.S3_BUCKET, Key=key)["ContentLength"]
|
|
except Exception:
|
|
pass
|
|
target = local_path(key)
|
|
return target.stat().st_size if target.is_file() else None
|