Files
fn_registry/dev/issues/completed/0014-file-upload.md
T

286 lines
15 KiB
Markdown

---
id: "0014"
title: "File Upload & Storage"
status: completado
type: feature
domain: []
scope: multi-app
priority: media
depends: []
blocks: []
related: []
created: 2026-05-17
updated: 2026-05-17
tags: []
---
# 0014 — File Upload & Storage
## Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0014 |
| **Estado** | pendiente |
| **Prioridad** | media |
| **Tipo** | feature |
## Dependencias
- **0009 — HTTP Server Foundation**: las funciones `upload_handler` y `file_serve` son handlers HTTP que dependen de los tipos y patrones definidos en 0009 (Route, Middleware, HTTPError, http_json_response, http_error_response, http_parse_body).
---
## Objetivo
Crear funciones reutilizables en Go (dominio infra) para manejar subida de archivos, almacenamiento en disco, servido de archivos estaticos, y opcionalmente almacenamiento en S3. Cualquier app que necesite gestionar imagenes, documentos o media puede componer estas primitivas en vez de reimplementar el manejo de archivos desde cero.
## Contexto
- No existen funciones de upload/storage en el registry. Cada app que necesita manejar archivos tiene que construir el handler de upload, la validacion, el almacenamiento y el servido desde cero.
- El patron es comun: apps con imagenes de perfil, documentos adjuntos, exports de datos, thumbnails, etc.
- Go stdlib tiene todo lo necesario para multipart parsing (`mime/multipart`), manipulacion de imagenes (`image`, `image/jpeg`, `image/png`), y filesystem. Para S3 se necesita el SDK de AWS, pero como dependencia opcional.
- Con estas funciones, una app nueva que necesite uploads solo hace: montar `upload_handler` como ruta, configurar `StorageConfig`, y usar `file_serve` para servir los archivos.
## Arquitectura
```
functions/infra/
upload_handler.go -- NEW: HTTP handler multipart upload
upload_handler.md -- NEW
upload_parse.go -- NEW: parse multipart form, extraer archivos
upload_parse.md -- NEW
file_save_disk.go -- NEW: guardar archivo en disco con nombre unico
file_save_disk.md -- NEW
file_serve.go -- NEW: HTTP handler para servir archivos estaticos
file_serve.md -- NEW
file_delete.go -- NEW: eliminar archivo del disco
file_delete.md -- NEW
thumbnail_generate.go -- NEW: generar thumbnail de imagen
thumbnail_generate.md -- NEW
file_validate_type.go -- NEW: validar MIME type por magic bytes
file_validate_type.md -- NEW
file_unique_name.go -- NEW: generar nombre unico UUID + extension
file_unique_name.md -- NEW
s3_upload.go -- NEW: subir archivo a S3-compatible
s3_upload.md -- NEW
s3_download.go -- NEW: descargar archivo desde S3-compatible
s3_download.md -- NEW
s3_presign_url.go -- NEW: generar URL presignada S3
s3_presign_url.md -- NEW
types/infra/
uploaded_file.md -- NEW: metadata del tipo UploadedFile
storage_config.md -- NEW: metadata del tipo StorageConfig
s3_config.md -- NEW: metadata del tipo S3Config
```
### Patron pure core / impure shell
- **Pure:** `file_validate_type` (lee bytes en memoria, sin I/O), `file_unique_name` (genera string, determinista dado un UUID)
- **Impure:** todo lo demas — interactuan con disco, red, HTTP requests/responses
## Diseno
### Tipos
```go
// UploadedFile contiene la metadata de un archivo subido y almacenado.
type UploadedFile struct {
Filename string `json:"filename"` // nombre original del archivo
StoredName string `json:"stored_name"` // nombre en disco (UUID-based)
Size int64 `json:"size"` // tamano en bytes
ContentType string `json:"content_type"` // MIME type detectado
Path string `json:"path"` // ruta completa en disco
CreatedAt time.Time `json:"created_at"`
}
// StorageConfig configura el almacenamiento local de archivos.
type StorageConfig struct {
BaseDir string `json:"base_dir"` // directorio base para almacenar archivos
MaxFileSize int64 `json:"max_file_size"` // tamano maximo en bytes (ej: 10<<20 = 10MB)
AllowedTypes []string `json:"allowed_types"` // MIME types permitidos (ej: ["image/png", "image/jpeg", "application/pdf"])
}
// S3Config configura la conexion a almacenamiento S3-compatible.
type S3Config struct {
Endpoint string `json:"endpoint"` // URL del servidor (ej: "s3.amazonaws.com", "minio.local:9000")
Bucket string `json:"bucket"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Region string `json:"region"`
UseSSL bool `json:"use_ssl"`
}
```
### Funciones
| Funcion | Purity | Firma (simplificada) |
|---------|--------|---------------------|
| `upload_handler` | impure | `(cfg StorageConfig) http.HandlerFunc` |
| `upload_parse` | impure | `(r *http.Request, maxSize int64) ([]ParsedFile, error)` |
| `file_save_disk` | impure | `(baseDir string, filename string, data io.Reader) (UploadedFile, error)` |
| `file_serve` | impure | `(dir string, pathPrefix string, maxAge int) http.Handler` |
| `file_delete` | impure | `(path string) error` |
| `thumbnail_generate` | impure | `(srcPath string, dstPath string, maxWidth int, maxHeight int) error` |
| `file_validate_type` | pure | `(header []byte, allowedTypes []string) (string, bool)` |
| `file_unique_name` | pure | `(originalName string) string` |
| `s3_upload` | impure | `(cfg S3Config, key string, data io.Reader, contentType string) error` |
| `s3_download` | impure | `(cfg S3Config, key string, dst io.Writer) error` |
| `s3_presign_url` | impure | `(cfg S3Config, key string, expiry time.Duration) (string, error)` |
### Detalle de cada funcion
**`upload_handler`** — Handler HTTP completo para multipart upload. Recibe un `StorageConfig`, aplica limites de tamano, valida tipos MIME, guarda en disco con nombre unico, y responde con JSON del `UploadedFile`. Compone internamente `upload_parse`, `file_validate_type`, `file_unique_name`, y `file_save_disk`.
**`upload_parse`** — Parsea el request multipart, extrae uno o mas archivos con su metadata (nombre original, tamano, content type, contenido). Aplica `http.MaxBytesReader` para limitar tamano. Retorna un slice de structs intermedios con el contenido listo para guardar.
**`file_save_disk`** — Recibe un `io.Reader` con el contenido y lo escribe a disco en `baseDir` con un nombre unico generado por `file_unique_name`. Crea subdirectorios si no existen. Retorna el `UploadedFile` con la ruta final.
**`file_serve`** — Retorna un `http.Handler` que sirve archivos estaticos desde un directorio. Setea headers de cache (`Cache-Control`, `ETag`). Usa `http.FileServer` internamente pero stripea el prefijo de path y anade seguridad contra path traversal.
**`file_delete`** — Elimina un archivo del disco. Valida que el path es relativo al directorio de storage (no permite `../` traversal). Retorna error si el archivo no existe.
**`thumbnail_generate`** — Lee una imagen del disco, la redimensiona manteniendo aspect ratio al tamano maximo indicado, y la guarda en `dstPath`. Usa `image`, `image/jpeg`, `image/png` de stdlib. Soporta JPEG y PNG.
**`file_validate_type`** — Lee los primeros N bytes (magic bytes / file signature) de un `[]byte` y determina el MIME type real del archivo. Compara contra la lista de tipos permitidos. No confia en el Content-Type del request — siempre verifica los bytes. Retorna el MIME type detectado y si esta en la lista permitida.
**`file_unique_name`** — Genera un nombre de archivo unico combinando un UUID v4 con la extension del archivo original. Ejemplo: `a1b2c3d4-e5f6-7890-abcd-ef1234567890.png`. Sanitiza la extension (solo alfanumericos).
**`s3_upload`** — Sube un archivo a un bucket S3-compatible. Acepta `S3Config` para conectar a AWS S3, MinIO, o cualquier implementacion compatible. Usa el AWS SDK v2.
**`s3_download`** — Descarga un archivo desde S3 a un `io.Writer`. Permite streaming directo a disco o a un HTTP response.
**`s3_presign_url`** — Genera una URL presignada para upload o download directo sin pasar por el servidor. Util para uploads grandes donde el cliente sube directamente a S3.
## Tareas
### Fase 1: Tipos
- [ ] **1.1** Crear tipo `UploadedFile` en `functions/infra/uploaded_file.go` con `.md` en `types/infra/`
- [ ] **1.2** Crear tipo `StorageConfig` en `functions/infra/storage_config.go` con `.md` en `types/infra/`
- [ ] **1.3** Crear tipo `S3Config` en `functions/infra/s3_config.go` con `.md` en `types/infra/`
### Fase 2: Funciones puras (validacion y naming)
- [ ] **2.1** `file_validate_type` — detectar MIME type por magic bytes, comparar contra lista permitida. Tabla interna de signatures: JPEG (`FF D8 FF`), PNG (`89 50 4E 47`), GIF (`47 49 46 38`), PDF (`25 50 44 46`), WebP (`52 49 46 46...57 45 42 50`), ZIP (`50 4B 03 04`)
- [ ] **2.2** `file_unique_name` — UUID v4 + extension sanitizada. Sin I/O, sin estado
- [ ] **2.3** Tests unitarios para ambas funciones puras
### Fase 3: Almacenamiento en disco
- [ ] **3.1** `file_save_disk` — escribir a disco con nombre unico, crear subdirectorios, retornar UploadedFile
- [ ] **3.2** `file_delete` — eliminar archivo, validar path traversal
- [ ] **3.3** `file_serve` — http.Handler con FileServer, cache headers, path traversal protection
- [ ] **3.4** `upload_parse` — parsear multipart form, extraer archivos con metadata
- [ ] **3.5** `upload_handler` — handler HTTP completo que compone parse + validate + save
- [ ] **3.6** `thumbnail_generate` — resize con image stdlib, mantener aspect ratio
- [ ] **3.7** Tests para funciones de disco con `os.MkdirTemp`
### Fase 4: S3-compatible storage
- [ ] **4.1** `s3_upload` — subir archivo a bucket con AWS SDK v2
- [ ] **4.2** `s3_download` — descargar archivo desde bucket
- [ ] **4.3** `s3_presign_url` — generar URL presignada con expiracion configurable
- [ ] **4.4** Tests con stub/mock de S3 (o `go build` + `go vet` si no hay MinIO local)
- [ ] **4.5** `fn index` y verificar que todas las funciones aparecen en registry.db
- [ ] **4.6** Verificar `go vet -tags fts5`
---
## Ejemplo de uso
```go
// Configurar storage
cfg := infra.StorageConfig{
BaseDir: "./uploads",
MaxFileSize: 10 << 20, // 10 MB
AllowedTypes: []string{"image/jpeg", "image/png", "application/pdf"},
}
// Montar rutas (usando funciones de 0009 HTTP Server Foundation)
routes := []infra.Route{
{Method: "POST", Path: "/api/upload", Handler: infra.UploadHandler(cfg)},
}
mux := infra.HttpRouter(routes)
// Servir archivos subidos
mux.Handle("/files/", infra.FileServe("./uploads", "/files/", 3600))
// Graceful shutdown
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
infra.HttpServe(":8080", mux, ctx)
```
```go
// Dentro de un handler custom (sin usar upload_handler):
func customUpload(w http.ResponseWriter, r *http.Request) {
files, err := infra.UploadParse(r, 10<<20)
if err != nil {
infra.HttpErrorResponse(w, infra.HTTPError{Status: 400, Code: "parse_error", Message: err.Error()})
return
}
for _, f := range files {
// Validar tipo real (no confiar en Content-Type del request)
mimeType, ok := infra.FileValidateType(f.Header, cfg.AllowedTypes)
if !ok {
infra.HttpErrorResponse(w, infra.HTTPError{Status: 415, Code: "invalid_type", Message: "tipo no permitido"})
return
}
// Guardar en disco
uploaded, err := infra.FileSaveDisk(cfg.BaseDir, f.Filename, f.Content)
if err != nil {
infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "save_error", Message: err.Error()})
return
}
// Generar thumbnail si es imagen
if mimeType == "image/jpeg" || mimeType == "image/png" {
thumbPath := filepath.Join(cfg.BaseDir, "thumbs", uploaded.StoredName)
infra.ThumbnailGenerate(uploaded.Path, thumbPath, 200, 200)
}
}
infra.HttpJsonResponse(w, 200, map[string]string{"status": "ok"})
}
```
```go
// S3 upload (opcional, para apps que necesiten storage remoto)
s3cfg := infra.S3Config{
Endpoint: "minio.local:9000",
Bucket: "uploads",
AccessKey: os.Getenv("S3_ACCESS_KEY"),
SecretKey: os.Getenv("S3_SECRET_KEY"),
Region: "us-east-1",
UseSSL: false,
}
f, _ := os.Open("./uploads/a1b2c3d4.png")
defer f.Close()
err := infra.S3Upload(s3cfg, "images/a1b2c3d4.png", f, "image/png")
// Generar URL presignada para download directo (1 hora)
url, err := infra.S3PresignUrl(s3cfg, "images/a1b2c3d4.png", time.Hour)
```
## Decisiones de diseno
- **Disco local primero, S3 opcional:** la mayoria de apps del registry corren en un solo servidor. El almacenamiento en disco es suficiente y no requiere infraestructura adicional. S3 es para apps que escalan o necesitan storage distribuido.
- **Validacion por magic bytes, no por Content-Type:** el header Content-Type del request puede ser falso. Los primeros bytes del archivo son la fuente de verdad para determinar el tipo real.
- **UUID para nombres en disco:** evita colisiones de nombres y elimina problemas con caracteres especiales en nombres de archivo originales. Se preserva la extension para que el MIME type sea inferible por el filesystem.
- **Solo stdlib para imagenes:** `image/jpeg` y `image/png` de Go stdlib son suficientes para thumbnails basicos. Si se necesita soporte de mas formatos (WebP, AVIF), se puede extender despues sin romper la interfaz.
- **Sin base de datos de metadata:** las funciones manejan archivos en disco/S3 pero no mantienen un indice de archivos subidos. Cada app decide como trackear sus uploads (puede usar operations.db, una tabla SQL, o simplemente el filesystem).
- **S3Config como struct separado:** permite que apps que no usan S3 no tengan que importar el AWS SDK. Las funciones S3 son independientes del resto.
## Riesgos
- **Path traversal:** Un atacante podria intentar subir archivos con nombres como `../../etc/passwd` o acceder a archivos fuera del directorio de storage. Mitigado: `file_save_disk` ignora el nombre original y usa UUID, `file_serve` valida que el path resuelto esta dentro del directorio base, `file_delete` rechaza paths con `..`.
- **File size DoS:** Un cliente podria enviar archivos enormes para agotar disco o memoria. Mitigado: `upload_parse` usa `http.MaxBytesReader` para cortar la lectura al limite configurado, `upload_handler` rechaza antes de leer si `Content-Length` excede el maximo.
- **MIME type bypass:** Un archivo puede tener magic bytes validos pero contenido malicioso despues. `file_validate_type` solo verifica los primeros bytes — no es un antivirus. Documentar que para apps con requisitos de seguridad altos se necesita escaneo adicional.
- **Dependencia AWS SDK para S3:** Anade un arbol de dependencias significativo. Mitigado: las funciones S3 son opcionales y estan en archivos separados. Si una app no importa S3, no paga el costo.
- **Thumbnails con stdlib limitados:** `image/jpeg` y `image/png` no soportan formatos modernos (WebP, AVIF, HEIC). Para apps que necesiten mas formatos, habra que evaluar dependencias externas o delegar a herramientas de sistema (`imagemagick`).