fad4006f60
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
286 lines
15 KiB
Markdown
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`).
|