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