From 55998e36ad4ea0596d2097d25703397d7280b5a5 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 18 Apr 2026 17:15:39 +0200 Subject: [PATCH] feat: file_save_disk, file_delete, file_serve, upload_parse, upload_handler, thumbnail_generate (issue 0014 fase 3) --- functions/infra/file_delete.go | 48 +++++++++ functions/infra/file_delete.md | 40 ++++++++ functions/infra/file_delete_test.go | 45 +++++++++ functions/infra/file_save_disk.go | 53 ++++++++++ functions/infra/file_save_disk.md | 47 +++++++++ functions/infra/file_save_disk_test.go | 62 ++++++++++++ functions/infra/file_serve.go | 28 ++++++ functions/infra/file_serve.md | 41 ++++++++ functions/infra/file_serve_test.go | 52 ++++++++++ functions/infra/thumbnail_generate.go | 103 +++++++++++++++++++ functions/infra/thumbnail_generate.md | 46 +++++++++ functions/infra/thumbnail_generate_test.go | 109 +++++++++++++++++++++ functions/infra/upload_handler.go | 57 +++++++++++ functions/infra/upload_handler.md | 46 +++++++++ functions/infra/upload_handler_test.go | 100 +++++++++++++++++++ functions/infra/upload_parse.go | 87 ++++++++++++++++ functions/infra/upload_parse.md | 49 +++++++++ functions/infra/upload_parse_test.go | 101 +++++++++++++++++++ 18 files changed, 1114 insertions(+) create mode 100644 functions/infra/file_delete.go create mode 100644 functions/infra/file_delete.md create mode 100644 functions/infra/file_delete_test.go create mode 100644 functions/infra/file_save_disk.go create mode 100644 functions/infra/file_save_disk.md create mode 100644 functions/infra/file_save_disk_test.go create mode 100644 functions/infra/file_serve.go create mode 100644 functions/infra/file_serve.md create mode 100644 functions/infra/file_serve_test.go create mode 100644 functions/infra/thumbnail_generate.go create mode 100644 functions/infra/thumbnail_generate.md create mode 100644 functions/infra/thumbnail_generate_test.go create mode 100644 functions/infra/upload_handler.go create mode 100644 functions/infra/upload_handler.md create mode 100644 functions/infra/upload_handler_test.go create mode 100644 functions/infra/upload_parse.go create mode 100644 functions/infra/upload_parse.md create mode 100644 functions/infra/upload_parse_test.go diff --git a/functions/infra/file_delete.go b/functions/infra/file_delete.go new file mode 100644 index 00000000..d9b2d25a --- /dev/null +++ b/functions/infra/file_delete.go @@ -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 +} diff --git a/functions/infra/file_delete.md b/functions/infra/file_delete.md new file mode 100644 index 00000000..73bbe807 --- /dev/null +++ b/functions/infra/file_delete.md @@ -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. diff --git a/functions/infra/file_delete_test.go b/functions/infra/file_delete_test.go new file mode 100644 index 00000000..36afc28d --- /dev/null +++ b/functions/infra/file_delete_test.go @@ -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") + } + }) +} diff --git a/functions/infra/file_save_disk.go b/functions/infra/file_save_disk.go new file mode 100644 index 00000000..afe0d94c --- /dev/null +++ b/functions/infra/file_save_disk.go @@ -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 +} diff --git a/functions/infra/file_save_disk.md b/functions/infra/file_save_disk.md new file mode 100644 index 00000000..401c24ba --- /dev/null +++ b/functions/infra/file_save_disk.md @@ -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. diff --git a/functions/infra/file_save_disk_test.go b/functions/infra/file_save_disk_test.go new file mode 100644 index 00000000..862dbcad --- /dev/null +++ b/functions/infra/file_save_disk_test.go @@ -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) + } + }) +} diff --git a/functions/infra/file_serve.go b/functions/infra/file_serve.go new file mode 100644 index 00000000..b4a99834 --- /dev/null +++ b/functions/infra/file_serve.go @@ -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) + }) +} diff --git a/functions/infra/file_serve.md b/functions/infra/file_serve.md new file mode 100644 index 00000000..56d509c6 --- /dev/null +++ b/functions/infra/file_serve.md @@ -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. diff --git a/functions/infra/file_serve_test.go b/functions/infra/file_serve_test.go new file mode 100644 index 00000000..b18482c6 --- /dev/null +++ b/functions/infra/file_serve_test.go @@ -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) + } + }) +} diff --git a/functions/infra/thumbnail_generate.go b/functions/infra/thumbnail_generate.go new file mode 100644 index 00000000..fb30ae8f --- /dev/null +++ b/functions/infra/thumbnail_generate.go @@ -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 +} diff --git a/functions/infra/thumbnail_generate.md b/functions/infra/thumbnail_generate.md new file mode 100644 index 00000000..2aa0353a --- /dev/null +++ b/functions/infra/thumbnail_generate.md @@ -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). diff --git a/functions/infra/thumbnail_generate_test.go b/functions/infra/thumbnail_generate_test.go new file mode 100644 index 00000000..da4a80c5 --- /dev/null +++ b/functions/infra/thumbnail_generate_test.go @@ -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()) + } + }) +} diff --git a/functions/infra/upload_handler.go b/functions/infra/upload_handler.go new file mode 100644 index 00000000..cabaf4bf --- /dev/null +++ b/functions/infra/upload_handler.go @@ -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}) + } +} diff --git a/functions/infra/upload_handler.md b/functions/infra/upload_handler.md new file mode 100644 index 00000000..b0753b86 --- /dev/null +++ b/functions/infra/upload_handler.md @@ -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). diff --git a/functions/infra/upload_handler_test.go b/functions/infra/upload_handler_test.go new file mode 100644 index 00000000..60148038 --- /dev/null +++ b/functions/infra/upload_handler_test.go @@ -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) + } + }) +} diff --git a/functions/infra/upload_parse.go b/functions/infra/upload_parse.go new file mode 100644 index 00000000..b4225c84 --- /dev/null +++ b/functions/infra/upload_parse.go @@ -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 +} diff --git a/functions/infra/upload_parse.md b/functions/infra/upload_parse.md new file mode 100644 index 00000000..584dee1b --- /dev/null +++ b/functions/infra/upload_parse.md @@ -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. diff --git a/functions/infra/upload_parse_test.go b/functions/infra/upload_parse_test.go new file mode 100644 index 00000000..abe3e0db --- /dev/null +++ b/functions/infra/upload_parse_test.go @@ -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") + } + }) +}