feat: file_save_disk, file_delete, file_serve, upload_parse, upload_handler, thumbnail_generate (issue 0014 fase 3)

This commit is contained in:
2026-04-18 17:15:39 +02:00
parent 746d9dd4c9
commit 55998e36ad
18 changed files with 1114 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
package infra
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// FileDelete elimina un archivo del disco. Rechaza paths que contengan ".." para
// evitar path traversal fuera del directorio esperado.
//
// Retorna error si el archivo no existe (os.ErrNotExist), si el path contiene "..",
// o si la operacion de remove falla por permisos.
func FileDelete(path string) error {
if path == "" {
return fmt.Errorf("file_delete: path vacio")
}
// Rechazar cualquier path traversal explicito en el input original
// (filepath.Clean resuelve `..` y borraria la huella, asi que comprobamos antes)
if containsParentRef(path) {
return fmt.Errorf("file_delete: path traversal no permitido en %q", path)
}
clean := filepath.Clean(path)
if _, err := os.Stat(clean); err != nil {
return fmt.Errorf("file_delete: stat %s: %w", clean, err)
}
if err := os.Remove(clean); err != nil {
return fmt.Errorf("file_delete: remove %s: %w", clean, err)
}
return nil
}
// containsParentRef detecta si el path tiene un segmento ".." entre separadores.
// Acepta tanto "/" como "\" como separadores. No marca como malo nombres como "..bashrc".
func containsParentRef(path string) bool {
// Normalizar a slashes
p := strings.ReplaceAll(path, "\\", "/")
for _, seg := range strings.Split(p, "/") {
if seg == ".." {
return true
}
}
return false
}
+40
View File
@@ -0,0 +1,40 @@
---
name: file_delete
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func FileDelete(path string) error"
description: "Elimina un archivo del disco. Rechaza paths con \"..\" para evitar path traversal. Retorna error si el archivo no existe o si falla el remove."
tags: [file, delete, disk, storage, security, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os, path/filepath, strings]
params:
- name: path
desc: "ruta del archivo a eliminar (no debe contener \"..\")"
output: "nil si el archivo se elimino correctamente, error si el path es vacio, contiene path traversal, no existe o falla la operacion"
tested: true
tests: ["elimina archivo existente", "rechaza path con ..", "rechaza path vacio", "retorna error si no existe"]
test_file_path: "functions/infra/file_delete_test.go"
file_path: "functions/infra/file_delete.go"
---
## Ejemplo
```go
err := FileDelete("./uploads/a1b2c3d4.png")
if err != nil {
log.Printf("delete fallo: %v", err)
}
```
## Notas
La proteccion contra path traversal es defensiva pero NO es suficiente por si sola: la app debe pasar paths que ya estan resueltos al directorio de storage (usar `filepath.Join(baseDir, storedName)`). Esta funcion es un cinturon adicional contra bugs en la app que llamaria.
NO sigue symlinks de forma especial — `os.Remove` borra el symlink, no el target.
+45
View File
@@ -0,0 +1,45 @@
package infra
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFileDelete(t *testing.T) {
t.Run("elimina archivo existente", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "x.txt")
if err := os.WriteFile(path, []byte("hi"), 0o644); err != nil {
t.Fatalf("setup: %v", err)
}
if err := FileDelete(path); err != nil {
t.Fatalf("FileDelete err: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("archivo aun existe: %v", err)
}
})
t.Run("rechaza path con ..", func(t *testing.T) {
err := FileDelete("./uploads/../etc/passwd")
if err == nil || !strings.Contains(err.Error(), "path traversal") {
t.Errorf("got err %v, want path traversal", err)
}
})
t.Run("rechaza path vacio", func(t *testing.T) {
err := FileDelete("")
if err == nil {
t.Error("got nil, want error")
}
})
t.Run("retorna error si no existe", func(t *testing.T) {
err := FileDelete(filepath.Join(t.TempDir(), "nope.txt"))
if err == nil {
t.Error("got nil, want error not found")
}
})
}
+53
View File
@@ -0,0 +1,53 @@
package infra
import (
"fmt"
"io"
"mime"
"os"
"path/filepath"
"strings"
"time"
)
// FileSaveDisk escribe el contenido de data en baseDir con un nombre unico generado a
// partir de filename original. Crea baseDir si no existe.
//
// Retorna el UploadedFile con la metadata y la ruta completa en disco. El campo
// ContentType se infiere de la extension via mime.TypeByExtension; si la app necesita
// validacion mas estricta, debe usar FileValidateType antes y/o sobreescribir el campo.
func FileSaveDisk(baseDir string, filename string, data io.Reader) (UploadedFile, error) {
if err := os.MkdirAll(baseDir, 0o755); err != nil {
return UploadedFile{}, fmt.Errorf("file_save_disk: mkdir %s: %w", baseDir, err)
}
stored := FileUniqueName(filename)
dst := filepath.Join(baseDir, stored)
f, err := os.Create(dst)
if err != nil {
return UploadedFile{}, fmt.Errorf("file_save_disk: create %s: %w", dst, err)
}
defer f.Close()
n, err := io.Copy(f, data)
if err != nil {
_ = os.Remove(dst)
return UploadedFile{}, fmt.Errorf("file_save_disk: copy: %w", err)
}
ext := strings.ToLower(filepath.Ext(stored))
ct := mime.TypeByExtension(ext)
if ct == "" {
ct = "application/octet-stream"
}
return UploadedFile{
Filename: filename,
StoredName: stored,
Size: n,
ContentType: ct,
Path: dst,
CreatedAt: time.Now().UTC(),
}, nil
}
+47
View File
@@ -0,0 +1,47 @@
---
name: file_save_disk
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func FileSaveDisk(baseDir string, filename string, data io.Reader) (UploadedFile, error)"
description: "Escribe el contenido de un io.Reader a disco en baseDir con un nombre unico (UUID + extension). Crea el directorio si no existe. Retorna UploadedFile con metadata."
tags: [file, save, disk, storage, upload, infra]
uses_functions: [file_unique_name_go_infra]
uses_types: [UploadedFile_go_infra]
returns: [UploadedFile_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: [fmt, io, mime, os, path/filepath, strings, time]
params:
- name: baseDir
desc: "directorio destino (se crea si no existe, permisos 0755)"
- name: filename
desc: "nombre original del archivo (solo se usa para extraer la extension)"
- name: data
desc: "reader con el contenido binario a escribir"
output: "UploadedFile con StoredName (UUID-based), Path completo, Size en bytes, ContentType inferido por extension y CreatedAt (UTC). Error si falla mkdir, create o copy"
tested: true
tests: ["guarda contenido en baseDir con nombre UUID", "crea baseDir si no existe", "tamano coincide con bytes escritos", "infiere ContentType desde la extension"]
test_file_path: "functions/infra/file_save_disk_test.go"
file_path: "functions/infra/file_save_disk.go"
---
## Ejemplo
```go
f, _ := os.Open("./input.png")
defer f.Close()
uploaded, err := FileSaveDisk("./uploads", "input.png", f)
if err != nil {
log.Fatal(err)
}
fmt.Println(uploaded.Path) // ./uploads/{uuid}.png
```
## Notas
- El nombre original NUNCA se usa como nombre en disco (riesgo path traversal). Solo se preserva como metadata en el campo `Filename` para trazabilidad.
- ContentType se infiere de la extension via `mime.TypeByExtension`. Para validacion estricta del tipo real, llamar `FileValidateType` ANTES de guardar y/o sobreescribir el campo.
- Si falla el `io.Copy`, el archivo parcial se borra automaticamente.
+62
View File
@@ -0,0 +1,62 @@
package infra
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFileSaveDisk(t *testing.T) {
t.Run("guarda contenido en baseDir con nombre UUID", func(t *testing.T) {
dir := t.TempDir()
body := strings.NewReader("hello world")
got, err := FileSaveDisk(dir, "saludos.txt", body)
if err != nil {
t.Fatalf("FileSaveDisk err: %v", err)
}
if got.Filename != "saludos.txt" {
t.Errorf("got Filename %q, want saludos.txt", got.Filename)
}
if !strings.HasSuffix(got.StoredName, ".txt") {
t.Errorf("got StoredName %q, want suffix .txt", got.StoredName)
}
if !strings.HasPrefix(got.Path, dir) {
t.Errorf("got Path %q, want prefix %q", got.Path, dir)
}
if got.Size != int64(len("hello world")) {
t.Errorf("got Size %d, want %d", got.Size, len("hello world"))
}
data, err := os.ReadFile(got.Path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(data) != "hello world" {
t.Errorf("contenido en disco %q, want %q", data, "hello world")
}
})
t.Run("crea baseDir si no existe", func(t *testing.T) {
base := filepath.Join(t.TempDir(), "nested", "uploads")
body := strings.NewReader("x")
got, err := FileSaveDisk(base, "a.png", body)
if err != nil {
t.Fatalf("FileSaveDisk err: %v", err)
}
if _, err := os.Stat(got.Path); err != nil {
t.Fatalf("archivo no escrito: %v", err)
}
})
t.Run("infiere ContentType desde la extension", func(t *testing.T) {
dir := t.TempDir()
got, err := FileSaveDisk(dir, "logo.png", strings.NewReader("x"))
if err != nil {
t.Fatalf("err: %v", err)
}
if !strings.HasPrefix(got.ContentType, "image/png") {
t.Errorf("got ContentType %q, want image/png prefix", got.ContentType)
}
})
}
+28
View File
@@ -0,0 +1,28 @@
package infra
import (
"fmt"
"net/http"
"strings"
)
// FileServe retorna un http.Handler que sirve archivos estaticos desde dir.
// Stripea pathPrefix del request URL antes de buscar el archivo, y setea el
// header Cache-Control con max-age=maxAge segundos.
//
// El handler rechaza cualquier path que contenga ".." para mitigar path traversal,
// aunque http.FileServer ya hace su propia normalizacion.
func FileServe(dir string, pathPrefix string, maxAge int) http.Handler {
fs := http.FileServer(http.Dir(dir))
cacheControl := fmt.Sprintf("public, max-age=%d", maxAge)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Rechazar path traversal explicito
if strings.Contains(r.URL.Path, "..") {
http.Error(w, "path traversal not allowed", http.StatusBadRequest)
return
}
w.Header().Set("Cache-Control", cacheControl)
http.StripPrefix(pathPrefix, fs).ServeHTTP(w, r)
})
}
+41
View File
@@ -0,0 +1,41 @@
---
name: file_serve
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func FileServe(dir string, pathPrefix string, maxAge int) http.Handler"
description: "Retorna un http.Handler que sirve archivos estaticos desde dir, stripeando pathPrefix del URL. Setea Cache-Control con max-age. Rechaza paths con \"..\"."
tags: [http, file, serve, static, cache, security, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, net/http, strings]
params:
- name: dir
desc: "directorio raiz desde donde se sirven los archivos"
- name: pathPrefix
desc: "prefijo del URL a remover antes de buscar (ej: \"/files/\")"
- name: maxAge
desc: "segundos para el header Cache-Control max-age"
output: "http.Handler listo para registrar en un mux. No retorna error directo; el handler responde 400 si detecta path traversal y delega al http.FileServer en otros casos"
tested: true
tests: ["sirve archivo existente con headers de cache", "responde 404 para archivo inexistente", "rechaza path con .. con 400"]
test_file_path: "functions/infra/file_serve_test.go"
file_path: "functions/infra/file_serve.go"
---
## Ejemplo
```go
mux := http.NewServeMux()
mux.Handle("/files/", FileServe("./uploads", "/files/", 3600))
http.ListenAndServe(":8080", mux)
```
## Notas
Wrapper sobre `http.FileServer` con dos refuerzos: rechazo explicito de paths con `..` y header `Cache-Control` configurable. `http.FileServer` ya normaliza paths, pero la doble verificacion es barata y reduce la superficie de ataque. Para servir archivos generados dinamicamente o detras de auth, no usar esta funcion — usar handlers custom.
+52
View File
@@ -0,0 +1,52 @@
package infra
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func TestFileServe(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hola mundo"), 0o644); err != nil {
t.Fatalf("setup: %v", err)
}
handler := FileServe(dir, "/files/", 60)
t.Run("sirve archivo existente con headers de cache", func(t *testing.T) {
req := httptest.NewRequest("GET", "/files/hello.txt", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if rec.Body.String() != "hola mundo" {
t.Errorf("got body %q", rec.Body.String())
}
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=60" {
t.Errorf("got Cache-Control %q", cc)
}
})
t.Run("responde 404 para archivo inexistente", func(t *testing.T) {
req := httptest.NewRequest("GET", "/files/missing.txt", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("got %d, want 404", rec.Code)
}
})
t.Run("rechaza path con .. con 400", func(t *testing.T) {
req := httptest.NewRequest("GET", "/files/../etc/passwd", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400", rec.Code)
}
})
}
+103
View File
@@ -0,0 +1,103 @@
package infra
import (
"fmt"
"image"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"strings"
)
// ThumbnailGenerate lee una imagen JPEG o PNG desde srcPath, la redimensiona
// manteniendo aspect ratio para que quepa dentro de (maxWidth, maxHeight) y guarda
// el resultado en dstPath con el formato inferido por la extension de dstPath.
//
// Solo soporta entrada/salida JPEG y PNG (image stdlib). Formatos modernos como
// WebP, AVIF o HEIC no estan soportados — retornan error explicito.
//
// El algoritmo de resize es nearest-neighbor (sin filtro) para mantener cero
// dependencias externas. Para apps que necesiten calidad alta usar una libreria
// como `golang.org/x/image/draw` con BiLinear o CatmullRom.
func ThumbnailGenerate(srcPath string, dstPath string, maxWidth int, maxHeight int) error {
if maxWidth <= 0 || maxHeight <= 0 {
return fmt.Errorf("thumbnail_generate: maxWidth y maxHeight deben ser > 0")
}
srcF, err := os.Open(srcPath)
if err != nil {
return fmt.Errorf("thumbnail_generate: open src %s: %w", srcPath, err)
}
defer srcF.Close()
srcImg, _, err := image.Decode(srcF)
if err != nil {
return fmt.Errorf("thumbnail_generate: decode %s: %w", srcPath, err)
}
thumb := resizeNearest(srcImg, maxWidth, maxHeight)
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return fmt.Errorf("thumbnail_generate: mkdir dst: %w", err)
}
dstF, err := os.Create(dstPath)
if err != nil {
return fmt.Errorf("thumbnail_generate: create dst %s: %w", dstPath, err)
}
defer dstF.Close()
ext := strings.ToLower(filepath.Ext(dstPath))
switch ext {
case ".jpg", ".jpeg":
return jpeg.Encode(dstF, thumb, &jpeg.Options{Quality: 85})
case ".png":
return png.Encode(dstF, thumb)
default:
return fmt.Errorf("thumbnail_generate: extension %q no soportada (solo jpg/jpeg/png)", ext)
}
}
// resizeNearest redimensiona la imagen al maximo (maxW, maxH) manteniendo aspect
// ratio. Usa interpolacion nearest-neighbor (rapida pero baja calidad).
func resizeNearest(src image.Image, maxW, maxH int) image.Image {
srcBounds := src.Bounds()
srcW := srcBounds.Dx()
srcH := srcBounds.Dy()
if srcW == 0 || srcH == 0 {
return src
}
// Calcular escala manteniendo aspect ratio
scaleW := float64(maxW) / float64(srcW)
scaleH := float64(maxH) / float64(srcH)
scale := scaleW
if scaleH < scaleW {
scale = scaleH
}
if scale >= 1.0 {
// Imagen ya cabe, no agrandar
return src
}
dstW := int(float64(srcW) * scale)
dstH := int(float64(srcH) * scale)
if dstW < 1 {
dstW = 1
}
if dstH < 1 {
dstH = 1
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
for y := 0; y < dstH; y++ {
srcY := int(float64(y) / scale)
for x := 0; x < dstW; x++ {
srcX := int(float64(x) / scale)
dst.Set(x, y, src.At(srcBounds.Min.X+srcX, srcBounds.Min.Y+srcY))
}
}
return dst
}
+46
View File
@@ -0,0 +1,46 @@
---
name: thumbnail_generate
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func ThumbnailGenerate(srcPath string, dstPath string, maxWidth int, maxHeight int) error"
description: "Lee una imagen JPEG o PNG, la redimensiona manteniendo aspect ratio para que quepa en (maxWidth, maxHeight) y la guarda en dstPath. Solo soporta entrada/salida JPEG y PNG."
tags: [image, thumbnail, resize, file, upload, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, image, image/jpeg, image/png, os, path/filepath, strings]
params:
- name: srcPath
desc: "ruta de la imagen original (JPEG o PNG)"
- name: dstPath
desc: "ruta destino del thumbnail (extension determina formato de salida: .jpg/.jpeg/.png)"
- name: maxWidth
desc: "ancho maximo del thumbnail en pixeles (>0)"
- name: maxHeight
desc: "alto maximo del thumbnail en pixeles (>0)"
output: "nil si el thumbnail se genero correctamente, error si falla la lectura, decode, encode o si la extension no es soportada"
tested: true
tests: ["genera thumbnail JPEG mas pequeno que el original", "preserva aspect ratio", "rechaza extension de salida no soportada", "no agranda imagenes ya pequenas"]
test_file_path: "functions/infra/thumbnail_generate_test.go"
file_path: "functions/infra/thumbnail_generate.go"
---
## Ejemplo
```go
err := ThumbnailGenerate("./uploads/photo.jpg", "./uploads/thumbs/photo.jpg", 200, 200)
if err != nil {
log.Fatal(err)
}
```
## Notas
Implementacion solo con stdlib (`image`, `image/jpeg`, `image/png`). Resize nearest-neighbor: rapido y sin dependencias, pero baja calidad. Para apps que necesiten thumbnails de alta calidad usar `golang.org/x/image/draw` con `BiLinear` o `CatmullRom`.
NO soporta WebP, AVIF, HEIC ni GIF. Si la imagen original ya es mas pequena que (maxWidth, maxHeight), se guarda sin redimensionar (no se agranda).
+109
View File
@@ -0,0 +1,109 @@
package infra
import (
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"testing"
)
// genPNG crea un PNG cuadrado de size pixeles en path.
func genPNG(t *testing.T, path string, size int) {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: 100, A: 255})
}
}
f, err := os.Create(path)
if err != nil {
t.Fatalf("create: %v", err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
t.Fatalf("encode: %v", err)
}
}
func TestThumbnailGenerate(t *testing.T) {
t.Run("genera thumbnail PNG mas pequeno que el original", func(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.png")
dst := filepath.Join(dir, "thumb.png")
genPNG(t, src, 400)
if err := ThumbnailGenerate(src, dst, 100, 100); err != nil {
t.Fatalf("ThumbnailGenerate: %v", err)
}
f, err := os.Open(dst)
if err != nil {
t.Fatalf("open dst: %v", err)
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
t.Fatalf("decode dst: %v", err)
}
b := img.Bounds()
if b.Dx() > 100 || b.Dy() > 100 {
t.Errorf("thumbnail %dx%d, want <= 100x100", b.Dx(), b.Dy())
}
})
t.Run("preserva aspect ratio", func(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.png")
dst := filepath.Join(dir, "thumb.png")
// Imagen 400x200 (aspect 2:1)
img := image.NewRGBA(image.Rect(0, 0, 400, 200))
f, _ := os.Create(src)
png.Encode(f, img)
f.Close()
if err := ThumbnailGenerate(src, dst, 100, 100); err != nil {
t.Fatalf("err: %v", err)
}
f2, _ := os.Open(dst)
defer f2.Close()
thumb, _, _ := image.Decode(f2)
b := thumb.Bounds()
// Aspect 2:1 mantenido: 100x50
if b.Dx() != 100 || b.Dy() != 50 {
t.Errorf("thumbnail %dx%d, want 100x50 (aspect 2:1)", b.Dx(), b.Dy())
}
})
t.Run("rechaza extension de salida no soportada", func(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.png")
dst := filepath.Join(dir, "thumb.webp")
genPNG(t, src, 200)
err := ThumbnailGenerate(src, dst, 100, 100)
if err == nil {
t.Error("got nil err, want extension no soportada")
}
})
t.Run("no agranda imagenes ya pequenas", func(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.png")
dst := filepath.Join(dir, "thumb.png")
genPNG(t, src, 50)
if err := ThumbnailGenerate(src, dst, 200, 200); err != nil {
t.Fatalf("err: %v", err)
}
f, _ := os.Open(dst)
defer f.Close()
thumb, _, _ := image.Decode(f)
b := thumb.Bounds()
if b.Dx() != 50 || b.Dy() != 50 {
t.Errorf("thumbnail %dx%d, want 50x50 (sin agrandar)", b.Dx(), b.Dy())
}
})
}
+57
View File
@@ -0,0 +1,57 @@
package infra
import (
"net/http"
)
// UploadHandler retorna un http.HandlerFunc completo para multipart upload. Compone
// internamente UploadParse + FileValidateType + FileSaveDisk segun cfg.
//
// Comportamiento:
// - Parsea el multipart con maxSize = cfg.MaxFileSize.
// - Para cada archivo: valida tipo por magic bytes contra cfg.AllowedTypes,
// guarda en cfg.BaseDir con FileSaveDisk, sobreescribe ContentType con el
// MIME real detectado.
// - Responde 200 con JSON `{"files": [UploadedFile, ...]}`.
// - Errores: 400 si el parse falla, 415 si algun archivo es de tipo no permitido,
// 500 si falla el guardado.
func UploadHandler(cfg StorageConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
files, err := UploadParse(r, cfg.MaxFileSize)
if err != nil {
HTTPErrorResponse(w, HTTPError{
Status: http.StatusBadRequest,
Code: "parse_error",
Message: err.Error(),
})
return
}
var saved []UploadedFile
for _, pf := range files {
mime, ok := FileValidateType(pf.Header, cfg.AllowedTypes)
if !ok {
HTTPErrorResponse(w, HTTPError{
Status: http.StatusUnsupportedMediaType,
Code: "invalid_type",
Message: "tipo no permitido: " + mime,
})
return
}
uploaded, err := FileSaveDisk(cfg.BaseDir, pf.Filename, pf.Content)
if err != nil {
HTTPErrorResponse(w, HTTPError{
Status: http.StatusInternalServerError,
Code: "save_error",
Message: err.Error(),
})
return
}
uploaded.ContentType = mime
saved = append(saved, uploaded)
}
HTTPJSONResponse(w, http.StatusOK, map[string]any{"files": saved})
}
}
+46
View File
@@ -0,0 +1,46 @@
---
name: upload_handler
kind: pipeline
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func UploadHandler(cfg StorageConfig) http.HandlerFunc"
description: "HTTP handler completo para multipart upload. Compone UploadParse + FileValidateType + FileSaveDisk segun StorageConfig. Responde JSON con los UploadedFile guardados o un HTTPError estructurado en caso de fallo."
tags: [http, upload, multipart, handler, pipeline, infra]
uses_functions: [upload_parse_go_infra, file_validate_type_go_infra, file_save_disk_go_infra, http_json_response_go_infra, http_error_response_go_infra]
uses_types: [StorageConfig_go_infra, UploadedFile_go_infra, HTTPError_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [net/http]
params:
- name: cfg
desc: "StorageConfig con BaseDir, MaxFileSize y AllowedTypes"
output: "http.HandlerFunc lista para montar como ruta. Responde 200 con {\"files\":[UploadedFile,...]}, 400 parse_error, 415 invalid_type o 500 save_error"
tested: true
tests: ["acepta upload con multiple imagenes y responde JSON", "rechaza tipo no permitido con 415", "rechaza body que excede MaxFileSize con 400"]
test_file_path: "functions/infra/upload_handler_test.go"
file_path: "functions/infra/upload_handler.go"
---
## Ejemplo
```go
cfg := StorageConfig{
BaseDir: "./uploads",
MaxFileSize: 10 << 20,
AllowedTypes: []string{"image/png", "image/jpeg", "application/pdf"},
}
mux := HTTPRouter([]Route{
{Method: "POST", Path: "/api/upload", Handler: UploadHandler(cfg)},
})
http.ListenAndServe(":8080", mux)
```
## Notas
Pipeline de 5 funciones — compone parse + validate + save + json/error responses. El campo `ContentType` del `UploadedFile` retornado se sobreescribe con el MIME REAL detectado por magic bytes (ignorando el Content-Type del request), no con el inferido por extension.
Si un archivo del batch falla, el handler responde error y NO continua. Las funciones que ya se guardaron en disco quedan ahi (no hay rollback transaccional).
+100
View File
@@ -0,0 +1,100 @@
package infra
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
)
// buildPNGMultipart crea un multipart con un campo "file" cuyo contenido tiene
// los magic bytes de PNG (suficientes para que FileValidateType los reconozca).
func buildPNGMultipart(t *testing.T, filename string, body string) (string, *bytes.Buffer) {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, err := w.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
// Magic bytes PNG: 89 50 4E 47 0D 0A 1A 0A
part.Write([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})
part.Write([]byte(body))
w.Close()
return w.FormDataContentType(), &buf
}
func TestUploadHandler(t *testing.T) {
t.Run("acepta upload con imagen y responde JSON", func(t *testing.T) {
dir := t.TempDir()
cfg := StorageConfig{
BaseDir: dir,
MaxFileSize: 1 << 20,
AllowedTypes: []string{"image/png"},
}
ct, body := buildPNGMultipart(t, "foto.png", "data extra")
req := httptest.NewRequest("POST", "/upload", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
UploadHandler(cfg).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got %d, body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Files []UploadedFile `json:"files"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(resp.Files) != 1 {
t.Fatalf("got %d files, want 1", len(resp.Files))
}
if resp.Files[0].ContentType != "image/png" {
t.Errorf("got ContentType %q", resp.Files[0].ContentType)
}
})
t.Run("rechaza tipo no permitido con 415", func(t *testing.T) {
dir := t.TempDir()
cfg := StorageConfig{
BaseDir: dir,
MaxFileSize: 1 << 20,
AllowedTypes: []string{"application/pdf"}, // PNG no en lista
}
ct, body := buildPNGMultipart(t, "foto.png", "x")
req := httptest.NewRequest("POST", "/upload", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
UploadHandler(cfg).ServeHTTP(rec, req)
if rec.Code != http.StatusUnsupportedMediaType {
t.Errorf("got %d, want 415", rec.Code)
}
})
t.Run("rechaza body que excede MaxFileSize con 400", func(t *testing.T) {
dir := t.TempDir()
cfg := StorageConfig{
BaseDir: dir,
MaxFileSize: 10, // muy pequeno
AllowedTypes: []string{"image/png"},
}
ct, body := buildPNGMultipart(t, "foto.png", "muchos bytes extra para superar 10")
req := httptest.NewRequest("POST", "/upload", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
UploadHandler(cfg).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400", rec.Code)
}
})
}
+87
View File
@@ -0,0 +1,87 @@
package infra
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"strings"
)
// ParsedFile representa un archivo extraido de un multipart form, con su contenido
// completo en memoria y los primeros bytes (Header) listos para validacion por
// magic bytes.
type ParsedFile struct {
FormField string // nombre del field del form (ej: "file", "image")
Filename string // nombre original reportado por el cliente
Size int64 // tamano en bytes
MIMEHint string // Content-Type segun el cliente (NO confiar — usar FileValidateType)
Header []byte // primeros 512 bytes para magic byte detection
Content io.Reader
}
// UploadParse parsea un request multipart/form-data y extrae todos los archivos
// adjuntos. Aplica http.MaxBytesReader para limitar el tamano total a maxSize bytes.
//
// Retorna un slice de ParsedFile con el contenido cargado en memoria como bytes.Reader,
// listo para ser pasado a FileSaveDisk o S3Upload.
//
// Para uploads muy grandes considerar streaming con multipart.Reader directamente
// en vez de esta funcion (que carga todo en memoria).
func UploadParse(r *http.Request, maxSize int64) ([]ParsedFile, error) {
if r.Body == nil {
return nil, fmt.Errorf("upload_parse: request sin body")
}
ct := r.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "multipart/") {
return nil, fmt.Errorf("upload_parse: content-type %q no es multipart", ct)
}
r.Body = http.MaxBytesReader(nil, r.Body, maxSize)
if err := r.ParseMultipartForm(maxSize); err != nil {
return nil, fmt.Errorf("upload_parse: parse form: %w", err)
}
if r.MultipartForm == nil {
return nil, fmt.Errorf("upload_parse: form vacio")
}
var out []ParsedFile
for field, headers := range r.MultipartForm.File {
for _, fh := range headers {
pf, err := readMultipartFile(field, fh)
if err != nil {
return nil, err
}
out = append(out, pf)
}
}
return out, nil
}
func readMultipartFile(field string, fh *multipart.FileHeader) (ParsedFile, error) {
f, err := fh.Open()
if err != nil {
return ParsedFile{}, fmt.Errorf("upload_parse: open %s: %w", fh.Filename, err)
}
defer f.Close()
buf, err := io.ReadAll(f)
if err != nil {
return ParsedFile{}, fmt.Errorf("upload_parse: read %s: %w", fh.Filename, err)
}
header := buf
if len(header) > 512 {
header = header[:512]
}
return ParsedFile{
FormField: field,
Filename: fh.Filename,
Size: int64(len(buf)),
MIMEHint: fh.Header.Get("Content-Type"),
Header: header,
Content: bytes.NewReader(buf),
}, nil
}
+49
View File
@@ -0,0 +1,49 @@
---
name: upload_parse
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func UploadParse(r *http.Request, maxSize int64) ([]ParsedFile, error)"
description: "Parsea un request multipart/form-data y extrae todos los archivos adjuntos. Aplica http.MaxBytesReader para limitar el tamano. Carga el contenido en memoria como bytes.Reader."
tags: [http, upload, multipart, parse, form, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [bytes, fmt, io, mime/multipart, net/http, strings]
params:
- name: r
desc: "http.Request con Content-Type multipart/form-data"
- name: maxSize
desc: "tamano maximo total del body en bytes (ej: 10<<20 para 10MB)"
output: "slice de ParsedFile con FormField, Filename, Size, MIMEHint, Header (primeros 512 bytes para magic detection) y Content (io.Reader). Error si el body excede maxSize, content-type no es multipart o falla el parse"
tested: true
tests: ["extrae un archivo del multipart", "extrae multiples archivos", "rechaza content-type no multipart", "respeta maxSize"]
test_file_path: "functions/infra/upload_parse_test.go"
file_path: "functions/infra/upload_parse.go"
---
## Ejemplo
```go
files, err := UploadParse(r, 10<<20) // 10 MB
if err != nil {
HTTPErrorResponse(w, HTTPError{Status: 400, Code: "parse_error", Message: err.Error()})
return
}
for _, f := range files {
mime, ok := FileValidateType(f.Header, []string{"image/png", "image/jpeg"})
if !ok {
continue
}
uploaded, _ := FileSaveDisk("./uploads", f.Filename, f.Content)
log.Println(uploaded.Path, mime)
}
```
## Notas
Esta implementacion carga TODO el contenido en memoria. Adecuada para archivos pequenos/medianos (<100MB). Para uploads enormes, usar `multipart.NewReader(r.Body, boundary)` directamente y stream-ear cada parte a disco/S3. `MIMEHint` viene del header del cliente y NO debe confiarse — usar `FileValidateType(f.Header, allowed)` para verificacion real.
+101
View File
@@ -0,0 +1,101 @@
package infra
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// buildMultipart construye un multipart body con los archivos dados (field, filename, contenido).
func buildMultipart(t *testing.T, files []struct{ field, filename, content string }) (string, *bytes.Buffer) {
t.Helper()
var body bytes.Buffer
w := multipart.NewWriter(&body)
for _, f := range files {
part, err := w.CreateFormFile(f.field, f.filename)
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
if _, err := part.Write([]byte(f.content)); err != nil {
t.Fatalf("Write part: %v", err)
}
}
if err := w.Close(); err != nil {
t.Fatalf("Close writer: %v", err)
}
return w.FormDataContentType(), &body
}
func newMultipartReq(t *testing.T, contentType string, body *bytes.Buffer) *http.Request {
t.Helper()
req := httptest.NewRequest("POST", "/upload", body)
req.Header.Set("Content-Type", contentType)
return req
}
func TestUploadParse(t *testing.T) {
t.Run("extrae un archivo del multipart", func(t *testing.T) {
ct, body := buildMultipart(t, []struct{ field, filename, content string }{
{"file", "a.txt", "hello"},
})
req := newMultipartReq(t, ct, body)
got, err := UploadParse(req, 1<<20)
if err != nil {
t.Fatalf("UploadParse: %v", err)
}
if len(got) != 1 {
t.Fatalf("got %d files, want 1", len(got))
}
if got[0].Filename != "a.txt" {
t.Errorf("got Filename %q", got[0].Filename)
}
if got[0].Size != 5 {
t.Errorf("got Size %d, want 5", got[0].Size)
}
buf, _ := io.ReadAll(got[0].Content)
if string(buf) != "hello" {
t.Errorf("got content %q, want hello", buf)
}
})
t.Run("extrae multiples archivos", func(t *testing.T) {
ct, body := buildMultipart(t, []struct{ field, filename, content string }{
{"a", "1.txt", "uno"},
{"b", "2.txt", "dos"},
})
req := newMultipartReq(t, ct, body)
got, err := UploadParse(req, 1<<20)
if err != nil {
t.Fatalf("UploadParse: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d files, want 2", len(got))
}
})
t.Run("rechaza content-type no multipart", func(t *testing.T) {
req := httptest.NewRequest("POST", "/upload", strings.NewReader("x"))
req.Header.Set("Content-Type", "application/json")
_, err := UploadParse(req, 1<<20)
if err == nil || !strings.Contains(err.Error(), "no es multipart") {
t.Errorf("got %v, want error de content-type", err)
}
})
t.Run("respeta maxSize", func(t *testing.T) {
ct, body := buildMultipart(t, []struct{ field, filename, content string }{
{"file", "big.bin", strings.Repeat("x", 1024)},
})
req := newMultipartReq(t, ct, body)
_, err := UploadParse(req, 100) // demasiado pequeno
if err == nil {
t.Errorf("got nil err, want max bytes error")
}
})
}