feat: file_save_disk, file_delete, file_serve, upload_parse, upload_handler, thumbnail_generate (issue 0014 fase 3)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user