Anexo al reto · FastAPI + PostgreSQL + RustFS · todo en Docker

Anexo con Docker: las imágenes de tu reto

El servidor del anexo de identidad aprende a recibir, guardar y servir imágenes. Las imágenes van a un almacén de objetos que habla S3, como Amazon; la base guarda solo su clave. Es el servidor de la Práctica 10, y el punto de partida para las fotos de tu reto.

Para el reto, y el servidor de la Práctica 10 Extiende el anexo de identidad con Docker RustFS: almacén de objetos compatible con S3 ~150 líneas nuevas de Python, todas leídas Código en github.com/rpversontec/reto-imagenes-fastapi-docker

Prerrequisitos. El anexo de identidad con Docker. Este anexo lo extiende: todo lo de cuentas, tokens, roles y errores es igual, y no se vuelve a explicar. Si Docker Desktop ya te funcionó ahí, aquí también.

Lo que trae. El mismo servidor, más imágenes: un contenedor nuevo —el almacén— y tres rutas. Es el servidor que usa la Práctica 10, y está en un repositorio público aparte:

git clone https://github.com/rpversontec/reto-imagenes-fastapi-docker.git
cd reto-imagenes-fastapi-docker

Es otro repositorio, no una rama del anterior: así tienes los dos y puedes compararlos (git diff --no-index ../reto-auth-fastapi-docker/app app).

Relación con el reto. Casi todo reto tiene una foto en algún lado. Guardarla mal no truena el primer día: truena cuando la base pesa gigas, el respaldo tarda una hora, y cada lista de cien productos arrastra cien imágenes.

Lo que agrega

Ruta Quién Qué hace
POST /api/imagenes Bearer + rol profesor Recibe una imagen (multipart, campo archivo), la guarda en el almacén y devuelve su clave
GET /api/imagenes/:clave Bearer Devuelve la imagen, con ETag y caché de un año
POST /api/avisos Bearer + rol profesor Ahora acepta imagen con la clave; 422 si esa imagen no existe
DELETE /api/avisos/:id Bearer + rol profesor Ahora borra también la imagen del aviso

Y las reglas que no se ven en la tabla: el tipo de la imagen se reconoce por sus primeros bytes, no por lo que diga el cliente; el máximo es 2 MB (IMAGEN_MAX_BYTES); las claves las inventa el servidor; todos los errores tienen la forma { error, code, hint } de siempre.


Dónde guardar una imagen

Hay tres lugares obvios. Solo uno escala, y no es el más obvio:

Columna BYTEA en PostgreSQL Archivos en el disco del contenedor Almacén de objetos (este anexo)
Lo que cuesta empezar Nada: una columna Una carpeta Un contenedor más
Una lista de cien avisos Arrastra cien imágenes si no tienes cuidado con el SELECT Bien Bien
El respaldo de la base Pesa lo que pesen las fotos Hay que respaldar dos cosas a mano La base sigue chica; el almacén tiene sus propias herramientas
Dos servidores detrás de un balanceador Bien Mal: cada uno tiene su disco Bien
Llevarlo a producción Mismo código Hay que cambiarlo Mismo código, otro S3_ENDPOINT: Amazon S3, Cloudflare R2, Google Cloud Storage

Un almacén de objetos es casi tonto a propósito: una clave, unos bytes y su tipo. Sin carpetas reales, sin consultas, sin transacciones. A cambio, guarda cualquier cantidad y lo sirve rápido. La base guarda la clave, y se queda haciendo lo que sabe hacer.

¿Por qué RustFS y no MinIO?

MinIO fue durante años el almacén S3 para correr en tu máquina, y casi todos los tutoriales lo usan. En 2025 dejó de publicar imágenes de Docker de su edición comunitaria, y en 2026 su repositorio está archivado: la última imagen es de octubre de 2025 y ya no recibe correcciones de seguridad.

RustFS es una alternativa con licencia Apache 2.0, compatible con S3, con consola web, y estable desde su versión 1.0 (septiembre de 2026). Se verificó en Docker Hub antes de elegirla: rustfs/rustfs:1.0.1.

Y la razón de fondo para que no importe tanto: el código del servidor no sabe que es RustFS. Habla S3 con boto3, el cliente oficial de Amazon. Mañana lo apuntas a MinIO, a R2 o a S3 cambiando una variable.


Levántalo

Lo que cambia en los contenedores

Dos dependencias nuevas: boto3 para hablar S3, y python-multipart, sin la cual FastAPI no sabe leer un archivo subido.

pyproject.toml
[project]
name = "imagenes-fastapi"
version = "0.1.0"
description = "Modulo de identidad e imagenes de referencia para el reto de TC2007B: FastAPI + PostgreSQL + RustFS (S3), mismo contrato que la API de las Practicas 6, 8 y 10."
requires-python = ">=3.11"
dependencies = [
    "fastapi==0.141.1",
    "uvicorn[standard]==0.53.0",
    "sqlalchemy==2.0.54",
    "psycopg[binary]==3.3.6",
    "pyjwt==2.14.0",
    "pwdlib[argon2]==0.3.1",
    "pydantic-settings==2.15.0",
    "python-multipart==0.0.32",
    "boto3==1.43.108",
]

Y un servicio más:

docker-compose.yml
# Todo el backend en la red de Docker:
#   docker compose up -d                   la base, el almacén y el servidor (http://localhost:8000)
#   docker compose --profile tunel up -d   además, el túnel HTTPS
#   docker compose logs tunel              ahí aparece la dirección https://…trycloudflare.com
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: avisos
    volumes:
      - pgdata:/var/lib/postgresql/data
    # Sin `ports`: la base solo se ve dentro de la red de Docker. Así no choca con
    # un PostgreSQL que ya tengas en el 5432. Para entrar: docker compose exec db psql -U postgres avisos
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d avisos"]
      interval: 2s
      retries: 30

  # El almacén de objetos: guarda las imágenes. Habla S3, como Amazon, MinIO o R2.
  almacen:
    image: rustfs/rustfs:1.0.1
    environment:
      # Las mismas llaves del .env: RustFS las toma como su administrador y la API entra con ellas.
      RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY}
      RUSTFS_SECRET_KEY: ${S3_SECRET_KEY}
      RUSTFS_CONSOLE_ENABLE: "true"
    volumes:
      - objetos:/data
    # La consola web (9001) es una página que le habla a la API S3 (9000) desde tu
    # navegador: por eso se publican las dos. Solo en 127.0.0.1: el resto del wifi no llega.
    # La app NUNCA habla con el almacén: todo pasa por `api`.
    ports:
      - "127.0.0.1:9000:9000"
      - "127.0.0.1:9001:9001"
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9000/health"]
      interval: 2s
      retries: 30

  api:
    build: .
    env_file: .env
    environment:
      # Dentro de la red de Docker, la base se llama `db` y el almacén `almacen`, no `localhost`.
      DATABASE_URL: postgresql+psycopg://postgres:postgres@db:5432/avisos
      S3_ENDPOINT: http://almacen:9000
    # --reload + el volumen: editas app/ en tu máquina y el servidor se reinicia solo.
    command: ["uv", "run", "--no-sync", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
    volumes:
      - ./app:/code/app
    # Solo en 127.0.0.1: tu máquina y el emulador (10.0.2.2) llegan; el resto del wifi, no.
    ports:
      - "127.0.0.1:8000:8000"
    depends_on:
      db:
        condition: service_healthy
      almacen:
        condition: service_healthy

  tunel:
    image: cloudflare/cloudflared:2026.9.3
    # Llega a la API por su nombre en la red de Docker. http2 porque muchas redes bloquean QUIC (UDP).
    command: ["tunnel", "--no-autoupdate", "--protocol", "http2", "--url", "http://api:8000"]
    depends_on:
      - api
    profiles: ["tunel"]

volumes:
  pgdata:
  # Las imágenes. Como pgdata: sobrevive a `docker compose down`; `down -v` lo borra.
  objetos:

Lo que conviene entender:

El almacén se llama almacen en la red de Docker, como la base se llama db. La API le habla en http://almacen:9000; tu app nunca le habla: todo pasa por la API, que es la que sabe quién tiene sesión.

Se publican dos puertos, y solo en 127.0.0.1. El 9001 es la consola web. El 9000 es la API S3, y se publica porque la consola es una página que corre en tu navegador y desde ahí le habla al 9000. Al preparar este anexo se publicó solo el 9001, y la consola respondía "Su sesión ha expirado" a cualquier usuario y contraseña. Con el 9000 publicado, entró a la primera.

api espera a que el almacén esté sano (condition: service_healthy), igual que espera a la base. Si no, al arrancar intentaría crear el bucket antes de que haya quién lo cree.

Un volumen nuevo, objetos. Como pgdata: sobrevive a docker compose down, y down -v lo borra.

Los secretos, en .env:

cp .env.example .env
# Copia este archivo a .env y cambia los valores. .env NUNCA se sube al repositorio.
# DATABASE_URL y S3_ENDPOINT no van aquí: los pone docker-compose.yml, porque dentro de
# Docker la base se llama `db` y el almacén se llama `almacen`.
JWT_SECRET=cambia-esto-por-48-bytes-al-azar
CODIGO_PROFESOR=cambia-este-codigo
ACCESS_TTL_S=300
REFRESH_TTL_S=604800

# Las llaves del almacén de objetos. Las usan los DOS lados: RustFS las toma como su
# usuario administrador y la API las usa para entrar. También son el usuario y la
# contraseña de la consola web (http://localhost:9001).
S3_ACCESS_KEY=avisos-admin
S3_SECRET_KEY=cambia-esto-por-otra-clave-larga

S3_ACCESS_KEY y S3_SECRET_KEY las usan los dos lados: RustFS las toma como su usuario administrador al arrancar por primera vez, y la API entra con ellas. Inventa una clave larga (openssl rand -base64 24 en Git Bash).

Arranca y prueba

docker compose up -d
bash smoke.sh

Los veinte casos del anexo de identidad, más once de imágenes. Todos tienen que salir con el código entre corchetes que dice la línea:

== 23 profesor sube (201)
{"id":"5b074bc4dc3a4fc6a6328b6620ddf464.png","tipo":"image/png","bytes":73} [201]
== 24 no es imagen aunque diga .png (415)
{"error":"El archivo no es una imagen","code":"no_es_imagen","hint":"Se aceptan JPEG, PNG y WebP."} [415]
== 25 demasiado grande (413)
{"error":"La imagen es demasiado grande","code":"imagen_grande","hint":"El máximo es 2048 KB. Redúcela en el teléfono antes de subirla."} [413]
== 29 bajar (200, mismos bytes)
  identica [200]
== 30 bajar otra vez con su ETag (304)
 [304]

Y mira dónde quedaron. La consola del almacén está en http://localhost:9001/rustfs/console/; entra con S3_ACCESS_KEY y S3_SECRET_KEY:

La consola de RustFS: el bucket avisos con tres objetos, 96.6 KiB, política de acceso privadaEl bucket lo crea la API al arrancar. Privado: nadie lo lee sin las llaves.

Lo que necesitas mientras trabajas es lo mismo del anexo de identidad, más una línea:

Si… Corre
Quieres ver los objetos La consola, en http://localhost:9001/rustfs/console/
Cambiaste el límite (IMAGEN_MAX_BYTES en .env) docker compose up -d
Quieres empezar de cero, imágenes incluidas docker compose down -v (borra la base y el almacén)
Si copias esto sobre tu proyecto del anexo de identidad

Tu volumen pgdata ya tiene la tabla avisos, sin la columna imagen. create_all crea tablas que no existen, pero no cambia las que ya existen. El servidor arranca bien, y la primera petición al tablón truena:

api-1  | psycopg.errors.UndefinedColumn: column avisos.imagen does not exist

Se agrega a mano, una vez:

docker compose exec db psql -U postgres avisos -c "ALTER TABLE avisos ADD COLUMN imagen VARCHAR(64)"

Eso, hecho con una herramienta y con historial, se llama migración. Para el reto, en cuanto tengas datos que no quieras perder, usa Alembic.


Léelo por partes

Son tres archivos nuevos y cinco cambios chicos. En el orden de una petición.

La configuración

app/config.py — lo nuevo, al final de Settings
    # El almacén de objetos (RustFS, compatible con S3). Las imágenes viven ahí, no en PostgreSQL.
    # Dentro de Docker se llama `almacen`; docker-compose.yml pone S3_ENDPOINT.
    s3_endpoint: str = "http://localhost:9000"
    s3_access_key: str
    s3_secret_key: str
    s3_bucket: str = "avisos"

    # Lo más grande que se acepta. La app de la Práctica 10 manda ~300 KB; una foto sin comprimir, 4 MB o más.
    imagen_max_bytes: int = 2 * 1024 * 1024

Las llaves no tienen valor por omisión, igual que JWT_SECRET: sin ellas, el servidor no arranca. A propósito.

El almacén

Todo el servidor habla con el almacén a través de este archivo. Nadie más importa boto3:

app/almacen.py
"""El almacén de objetos, en un solo archivo. Todo lo demás le habla a estas cinco
funciones y no sabe que del otro lado hay S3.

Se usa boto3, el cliente oficial de Amazon S3: habla con cualquier almacén
compatible —RustFS aquí, MinIO, Cloudflare R2, Amazon S3 en producción—
cambiando solo `S3_ENDPOINT` y las llaves.

Un objeto es una clave ("3f2a….jpg"), unos bytes y su tipo. No hay carpetas, no
hay consultas, no hay transacciones: guardar, leer, preguntar si existe y borrar.
Por eso las imágenes van aquí y no en PostgreSQL, y por eso la base solo guarda
la clave.
"""

from typing import Any

import boto3
from botocore.config import Config
from botocore.exceptions import ClientError

from .config import settings

_s3 = boto3.client(
    "s3",
    endpoint_url=settings.s3_endpoint,
    aws_access_key_id=settings.s3_access_key,
    aws_secret_access_key=settings.s3_secret_key,
    # S3 exige una región aunque el almacén sea tuyo; RustFS acepta esta.
    region_name="us-east-1",
    # "path": http://almacen:9000/avisos/clave. El estilo por omisión pone el bucket en el
    # nombre del host (avisos.almacen), y ese nombre no existe en la red de Docker.
    config=Config(s3={"addressing_style": "path"}, connect_timeout=3, retries={"max_attempts": 2}),
)


def preparar() -> None:
    """Crea el bucket si no existe. Se llama una vez, al arrancar."""
    try:
        _s3.head_bucket(Bucket=settings.s3_bucket)
    except ClientError:
        _s3.create_bucket(Bucket=settings.s3_bucket)


def guardar(clave: str, datos: bytes, tipo: str) -> None:
    _s3.put_object(Bucket=settings.s3_bucket, Key=clave, Body=datos, ContentType=tipo)


def abrir(clave: str) -> dict[str, Any] | None:
    """El objeto listo para mandarse por partes, o None si no existe.

    `Body` es un flujo: los bytes salen del almacén conforme el cliente los lee,
    sin cargar la imagen completa en la memoria del servidor.
    """
    try:
        return _s3.get_object(Bucket=settings.s3_bucket, Key=clave)
    except ClientError as e:
        if e.response["Error"]["Code"] in ("NoSuchKey", "404"):
            return None
        raise


def existe(clave: str) -> bool:
    try:
        _s3.head_object(Bucket=settings.s3_bucket, Key=clave)
        return True
    except ClientError as e:
        if e.response["Error"]["Code"] in ("NoSuchKey", "404"):
            return False
        raise


def borrar(clave: str) -> None:
    # Borrar lo que no existe no es error en S3: así borrar dos veces no truena.
    _s3.delete_object(Bucket=settings.s3_bucket, Key=clave)

addressing_style: "path" es la línea que más tutoriales olvidan. Por omisión, S3 pone el bucket en el nombre del servidor (avisos.almacen:9000), y ese nombre no existe en la red de Docker. Con path, va en la ruta (almacen:9000/avisos/…).

abrir() devuelve un flujo, no bytes. La imagen sale del almacén hacia el cliente conforme el cliente la lee. Con cien usuarios bajando imágenes a la vez, el servidor no carga cien imágenes en memoria.

preparar() crea el bucket si no existe, al arrancar. Por eso el bucket aparece solo en la consola.

Recibir y servir

app/routers/imagenes.py
import re
import uuid

from fastapi import APIRouter, Depends, Request, Response, UploadFile
from fastapi.responses import StreamingResponse

from .. import almacen
from ..config import settings
from ..deps import Identidad, requiere_profesor, usuario_actual
from ..errors import ApiError
from ..schemas import ImagenOut

router = APIRouter(prefix="/imagenes", tags=["imagenes"])

# Las claves las inventa el servidor: 32 hexadecimales y una extensión. Lo que no
# tenga esa forma no se le pregunta al almacén (así nadie pide "../../.env").
CLAVE_RE = re.compile(r"^[0-9a-f]{32}\.(jpg|png|webp)$")


def tipo_de(inicio: bytes) -> tuple[str, str] | None:
    """El tipo real, por los primeros bytes del archivo ("número mágico").

    No por el nombre ni por el Content-Type que manda el cliente: esos los
    escribe quien sube, y un .exe renombrado a foto.jpg sigue siendo un .exe.
    """
    if inicio.startswith(b"\xff\xd8\xff"):
        return "image/jpeg", "jpg"
    if inicio.startswith(b"\x89PNG\r\n\x1a\n"):
        return "image/png", "png"
    if inicio[:4] == b"RIFF" and inicio[8:12] == b"WEBP":
        return "image/webp", "webp"
    return None


@router.post("", response_model=ImagenOut, status_code=201)
def subir(archivo: UploadFile, _: Identidad = Depends(requiere_profesor)) -> ImagenOut:
    """Recibe una imagen (multipart, campo `archivo`), la guarda en el almacén y devuelve su clave.

    Todavía no pertenece a ningún aviso: la clave se manda después en `POST /avisos`.
    """
    # Se lee un byte de más: si llega, el archivo se pasó del límite.
    datos = archivo.file.read(settings.imagen_max_bytes + 1)
    if len(datos) > settings.imagen_max_bytes:
        raise ApiError(
            413,
            "La imagen es demasiado grande",
            "imagen_grande",
            f"El máximo es {settings.imagen_max_bytes // 1024} KB. Redúcela en el teléfono antes de subirla.",
        )
    tipo = tipo_de(datos[:12])
    if tipo is None:
        raise ApiError(415, "El archivo no es una imagen", "no_es_imagen", "Se aceptan JPEG, PNG y WebP.")
    mime, extension = tipo
    clave = f"{uuid.uuid4().hex}.{extension}"
    almacen.guardar(clave, datos, mime)
    return ImagenOut(id=clave, tipo=mime, bytes=len(datos))


@router.get("/{clave}")
def descargar(clave: str, request: Request, _: Identidad = Depends(usuario_actual)) -> Response:
    """La imagen, para quien tenga sesión. Se manda por partes, conforme sale del almacén."""
    if not CLAVE_RE.match(clave):
        raise ApiError(404, "No existe esa imagen")
    objeto = almacen.abrir(clave)
    if objeto is None:
        raise ApiError(404, "No existe esa imagen")
    etag = objeto["ETag"]
    # Una clave nunca cambia de contenido: puede guardarse para siempre en el teléfono.
    cache = {"ETag": etag, "Cache-Control": "private, max-age=31536000, immutable"}
    if request.headers.get("If-None-Match") == etag:
        objeto["Body"].close()
        return Response(status_code=304, headers=cache)
    return StreamingResponse(
        objeto["Body"].iter_chunks(64 * 1024),
        media_type=objeto["ContentType"],
        headers={**cache, "Content-Length": str(objeto["ContentLength"])},
    )

Cuatro decisiones de seguridad, cada una contra un ataque concreto:

El tipo por los primeros bytes. El nombre del archivo y el Content-Type los escribe quien sube. Un .exe renombrado a foto.jpg sigue empezando con MZ, no con FF D8 FF. El caso 24 del smoke manda texto con type=image/png: 415.

Se lee un byte de más. Si llegan max + 1, el archivo se pasó. Sin leer el archivo completo para saberlo.

Las claves las inventa el servidor. Un UUID: imposible de adivinar, y sin el nombre que el usuario le puso (que puede traer su nombre, su matrícula, o ../../.env). Y CLAVE_RE rechaza en el GET cualquier cosa que no tenga esa forma, antes de preguntarle nada al almacén.

def, no async def. boto3 bloquea mientras espera al almacén. FastAPI corre las funciones def en un hilo aparte; una async def que bloquea congelaría a todos los demás usuarios.

Y una de rendimiento: ETag y Cache-Control: immutable. Una clave nunca cambia de contenido, así que el teléfono puede guardar la imagen para siempre, y si pregunta otra vez con If-None-Match, responde 304 sin mandar un byte. Es el caso 30.

El aviso, con su clave

La tabla gana una columna que guarda la clave, no la imagen:

app/models.py — en Aviso
    # La CLAVE del objeto en el almacén ("3f2a….jpg"), no la imagen. NULL si el aviso no lleva.
    imagen: Mapped[str | None] = mapped_column(String(64), nullable=True)

El esquema de entrada la acepta, con la misma forma que exige el GET:

app/schemas.py — en NuevoAviso
    @field_validator("imagen")
    @classmethod
    def imagen_valida(cls, v: str | None) -> str | None:
        if v is not None and not IMAGEN_RE.match(v):
            raise ValueError("imagen debe ser la clave que devolvió POST /api/imagenes")
        return v

Y al crear y al borrar, el router de avisos le pregunta al almacén:

app/routers/avisos.py — en crear
    # Una clave con buena forma no basta: la imagen tiene que estar en el almacén.
    if body.imagen is not None and not almacen.existe(body.imagen):
        raise ApiError(422, "Esa imagen no existe", "imagen_inexistente", "Súbela primero con POST /api/imagenes y manda la clave que te devuelve.")
    aviso = Aviso(titulo=body.titulo, cuerpo=body.cuerpo, autor=quien.usuario, imagen=body.imagen)
app/routers/avisos.py — en borrar
    db.delete(aviso)
    db.commit()
    # Primero la fila y después el objeto: si el almacén falla, queda un archivo huérfano
    # (estorba) en vez de un aviso que apunta a una imagen que ya no existe (truena).
    if aviso.imagen:
        almacen.borrar(aviso.imagen)

El orden del borrado es la única decisión de consistencia de todo el anexo, y vale la pena discutirla: no hay una transacción que abarque PostgreSQL y el almacén. Cuando dos cosas no pueden fallar juntas, se elige cuál falla de la forma menos grave.

El SSE de la Práctica 8 no cambió: manda AvisoOut, y AvisoOut ya trae imagen.


Conecta tu app

La app de la Práctica 10 ya lee la dirección del servidor de local.properties, sin tocar el código:

Dónde corre la app avisos.api= Notas
Emulador (nada: es el valor por omisión) http://10.0.2.2:8000/api/ HTTP en claro, permitido solo hacia 10.0.2.2 y solo en debug
Teléfono de verdad https://<tu-túnel>.trycloudflare.com/api/ docker compose --profile tunel up -d, y la dirección sale en docker compose logs tunel

Por el túnel se probó el camino completo con la app de la Práctica 10: entrar, ver el tablón con imágenes, elegir una foto, reducirla en el teléfono y publicarla. Y el smoke.sh contra la dirección del túnel (bash smoke.sh https://…trycloudflare.com/api) pasa los once casos de imágenes, incluido el 413 de 3 MB.

Lo que no pasa por un túnel rápido es el SSE de la Práctica 8: Cloudflare no lo deja pasar en los túneles sin cuenta. El tablón no se actualiza solo; Recargar sí funciona.


Adáptalo a tu reto

Quién sube. Hoy, requiere_profesor. En tu reto, el dueño del recurso: el que vende el producto, el que reporta la evidencia. Es cambiar la dependencia de subir.

Quién ve. Hoy, cualquiera con sesión (usuario_actual). Si la imagen es privada —la foto de una identificación, un recibo—, guarda en la tabla quién la subió y compáralo en descargar. Si es pública —el catálogo de una tienda—, quita la dependencia: la clave UUID es lo único que la protege, y alcanza.

Cuántas. Un aviso, una imagen. Un producto con galería necesita una tabla aparte (producto_imagenes: producto, clave, orden), no una lista en una columna.

Qué tamaño. IMAGEN_MAX_BYTES es la última defensa, no la primera. Lo correcto es que la app reduzca (Práctica 10, Bloque B) y que el límite del servidor sea un poco más que lo que la app manda.


Lo que no hace, y qué haría falta para producción

  • Huérfanas. Una imagen que se sube y cuyo aviso nunca se publica se queda en el almacén para siempre. Las salidas: una regla de ciclo de vida en el almacén, o marcar las imágenes "en uso" al publicar y borrar las demás con un trabajo nocturno.
  • URLs firmadas. Hoy cada byte de cada imagen pasa por FastAPI. A escala, el servidor firma una URL temporal y el teléfono sube o baja directo del almacén. Es más rápido y más barato, y exige que el almacén sea alcanzable desde internet.
  • Miniaturas. El servidor guarda lo que le mandan. Un servicio serio genera dos o tres tamaños al recibir, y la lista pide la chica.
  • Revisar el contenido. El servidor sabe que es una imagen, no qué muestra. Una app pública necesita moderación.
  • Migraciones. La columna imagen se agregó con create_all en una base nueva. En una base con datos, Alembic.
  • Las llaves. El administrador del almacén es el mismo usuario que usa la API. En producción, la API usa un usuario con permiso solo sobre su bucket.

Lo que se revisa del módulo

Si tu reto guarda imágenes con este módulo o uno derivado, esto es lo que se va a mirar:

Qué Cómo se ve
Las imágenes no están en la base La tabla guarda claves; los bytes, en un almacén
El tipo no lo decide el cliente Una prueba con texto renombrado a .jpg da 415
Hay límite, y la app lo respeta Un 413 con mensaje legible; la app reduce antes de subir
Leer exige lo que tiene que exigir Un GET sin token da 401 (o, si la imagen es pública, lo justificas)
Las llaves no están en el repositorio .env en .gitignore; .env.example con valores de ejemplo
smoke.sh pasa completo Treinta y un códigos esperados

Defensa oral — las preguntas que van a caer

  1. ¿Por qué la imagen no va en una columna de PostgreSQL? Dame una razón que no sea "es más lento".
  2. Te mando un archivo virus.jpg con Content-Type: image/jpeg. ¿Qué línea tuya lo detiene?
  3. ¿Por qué el GET de una imagen puede responder 304, y qué se ahorra?
  4. Borras un aviso y el almacén está caído. ¿Qué queda inconsistente, y por qué elegiste ese orden?
  5. ¿Qué cambiarías para que el código hable con Amazon S3 en vez de RustFS?