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.
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.
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.
[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:
# 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:
El 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) |
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
# 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:
"""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
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:
# 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:
@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:
# 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)
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
imagense agregó concreate_allen 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
- ¿Por qué la imagen no va en una columna de PostgreSQL? Dame una razón que no sea "es más lento".
- Te mando un archivo
virus.jpgconContent-Type: image/jpeg. ¿Qué línea tuya lo detiene? - ¿Por qué el
GETde una imagen puede responder304, y qué se ahorra? - Borras un aviso y el almacén está caído. ¿Qué queda inconsistente, y por qué elegiste ese orden?
- ¿Qué cambiarías para que el código hable con Amazon S3 en vez de RustFS?