---
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`).
