merge: issue/0014-file-upload — file upload/storage + S3 stubs (11 fns, 3 tipos)

# Conflicts:
#	registry.db
This commit is contained in:
2026-04-18 17:33:28 +02:00
40 changed files with 1954 additions and 15 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)
}
})
}
+47
View File
@@ -0,0 +1,47 @@
package infra
import (
"path/filepath"
"strings"
"unicode"
"github.com/google/uuid"
)
// FileUniqueName genera un nombre de archivo unico combinando un UUID v4 con la
// extension sanitizada del nombre original.
//
// Ejemplo: "vacaciones.PNG" -> "a1b2c3d4-e5f6-7890-abcd-ef1234567890.png"
//
// La extension se sanitiza: solo se conservan caracteres alfanumericos en minusculas
// y se trunca a 16 caracteres como maximo. Si el archivo no tiene extension, se
// retorna solo el UUID.
//
// La funcion es "pura en intencion" en el sentido de que su firma no depende del
// contexto, pero internamente usa un generador de UUIDs aleatorios — el resultado
// no es determinista.
func FileUniqueName(originalName string) string {
id := uuid.NewString()
ext := filepath.Ext(originalName)
ext = strings.TrimPrefix(ext, ".")
ext = sanitizeExt(ext)
if ext == "" {
return id
}
return id + "." + ext
}
// sanitizeExt deja solo caracteres alfanumericos en minusculas y trunca a 16 chars.
func sanitizeExt(ext string) string {
var b strings.Builder
for _, r := range strings.ToLower(ext) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
if b.Len() >= 16 {
break
}
}
return b.String()
}
+47
View File
@@ -0,0 +1,47 @@
---
name: file_unique_name
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: pure
signature: "func FileUniqueName(originalName string) string"
description: "Genera un nombre de archivo unico combinando un UUID v4 con la extension sanitizada del nombre original. Evita colisiones y elimina problemas con caracteres especiales."
tags: [file, unique, name, uuid, upload, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [path/filepath, strings, unicode, github.com/google/uuid]
params:
- name: originalName
desc: "nombre original del archivo (puede contener path, espacios, caracteres especiales)"
output: "nombre unico {uuid}.{ext} con extension sanitizada (alfanumerica, minusculas, max 16 chars). Si no hay extension retorna solo el UUID"
tested: true
tests: ["preserva extension comun como png", "convierte extension a minusculas", "remueve caracteres especiales en extension", "genera UUID sin extension si el archivo no tiene", "trunca extensiones extremadamente largas"]
test_file_path: "functions/infra/file_unique_name_test.go"
file_path: "functions/infra/file_unique_name.go"
---
## Ejemplo
```go
n1 := FileUniqueName("vacaciones.PNG")
// n1 = "a1b2c3d4-e5f6-7890-abcd-ef1234567890.png"
n2 := FileUniqueName("contrato sin extension")
// n2 = "f9b6c2d1-..." (solo UUID)
n3 := FileUniqueName("malicious; rm -rf /.exe.txt")
// n3 = "{uuid}.txt"
```
## Notas
Marcada como `pure` por contrato (no hace I/O ni depende de estado mutable explicitamente), pero internamente la generacion del UUID v4 usa un PRNG por lo que el resultado NO es determinista. Esto es aceptable en la convencion del registry: la pureza se refiere a la ausencia de side effects observables (no escribe a disco, red, ni globals), no al determinismo bit a bit.
La extension se sanitiza para evitar:
- Path traversal en disco (ej: `../../etc/passwd`)
- Inyeccion de comandos en logs/UI
- Ambiguedad de filesystem entre mayus/minus
+63
View File
@@ -0,0 +1,63 @@
package infra
import (
"strings"
"testing"
)
func TestFileUniqueName(t *testing.T) {
t.Run("preserva extension comun como png", func(t *testing.T) {
got := FileUniqueName("foto.png")
if !strings.HasSuffix(got, ".png") {
t.Fatalf("got %q, want suffix .png", got)
}
if len(got) < 36+4 { // uuid + ".png"
t.Fatalf("got %q, want UUID + .png", got)
}
})
t.Run("convierte extension a minusculas", func(t *testing.T) {
got := FileUniqueName("VACACIONES.JPEG")
if !strings.HasSuffix(got, ".jpeg") {
t.Fatalf("got %q, want suffix .jpeg", got)
}
})
t.Run("remueve caracteres especiales en extension", func(t *testing.T) {
got := FileUniqueName("malicious.t!x@t#")
if !strings.HasSuffix(got, ".txt") {
t.Fatalf("got %q, want suffix .txt", got)
}
})
t.Run("genera UUID sin extension si el archivo no tiene", func(t *testing.T) {
got := FileUniqueName("contrato_sin_extension")
if strings.Contains(got, ".") {
t.Fatalf("got %q, want sin punto", got)
}
if len(got) != 36 {
t.Fatalf("got %q (len %d), want UUID len 36", got, len(got))
}
})
t.Run("trunca extensiones extremadamente largas", func(t *testing.T) {
got := FileUniqueName("file." + strings.Repeat("a", 100))
// Buscar la ultima parte despues del punto
idx := strings.LastIndex(got, ".")
if idx < 0 {
t.Fatalf("got %q, want al menos un punto", got)
}
ext := got[idx+1:]
if len(ext) > 16 {
t.Fatalf("got ext len %d, want <= 16", len(ext))
}
})
t.Run("dos llamadas generan IDs distintos", func(t *testing.T) {
a := FileUniqueName("x.png")
b := FileUniqueName("x.png")
if a == b {
t.Fatalf("got %q == %q, want distintos", a, b)
}
})
}
+67
View File
@@ -0,0 +1,67 @@
package infra
import "bytes"
// fileSignature describe el magic byte signature de un tipo de archivo.
type fileSignature struct {
mime string
prefix []byte
// Para WebP: el prefix son los primeros 4 bytes "RIFF", luego 4 bytes de tamaño,
// luego suffix en offset 8: "WEBP".
suffix []byte
suffixOffset int
}
// fileSignatures es la tabla interna de magic bytes soportados.
var fileSignatures = []fileSignature{
{mime: "image/jpeg", prefix: []byte{0xFF, 0xD8, 0xFF}},
{mime: "image/png", prefix: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}},
{mime: "image/gif", prefix: []byte{0x47, 0x49, 0x46, 0x38}},
{mime: "application/pdf", prefix: []byte{0x25, 0x50, 0x44, 0x46}},
{mime: "image/webp", prefix: []byte{0x52, 0x49, 0x46, 0x46}, suffix: []byte{0x57, 0x45, 0x42, 0x50}, suffixOffset: 8},
{mime: "application/zip", prefix: []byte{0x50, 0x4B, 0x03, 0x04}},
}
// FileValidateType detecta el MIME type real de un archivo a partir de sus primeros
// bytes (magic bytes / file signature) y verifica que esta en la lista permitida.
//
// Retorna el MIME type detectado y true si esta permitido. Si no se puede detectar
// el tipo o no esta en allowedTypes, retorna "" y false.
//
// Funcion pura — no hace I/O. La validacion por magic bytes es mas segura que confiar
// en el header Content-Type del request, que puede ser falsificado.
func FileValidateType(header []byte, allowedTypes []string) (string, bool) {
mime := detectMimeType(header)
if mime == "" {
return "", false
}
for _, allowed := range allowedTypes {
if allowed == mime {
return mime, true
}
}
return mime, false
}
// detectMimeType busca el primer signature que matchee header.
func detectMimeType(header []byte) string {
for _, sig := range fileSignatures {
if len(header) < len(sig.prefix) {
continue
}
if !bytes.Equal(header[:len(sig.prefix)], sig.prefix) {
continue
}
if len(sig.suffix) > 0 {
end := sig.suffixOffset + len(sig.suffix)
if len(header) < end {
continue
}
if !bytes.Equal(header[sig.suffixOffset:end], sig.suffix) {
continue
}
}
return sig.mime
}
return ""
}
+52
View File
@@ -0,0 +1,52 @@
---
name: file_validate_type
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: pure
signature: "func FileValidateType(header []byte, allowedTypes []string) (string, bool)"
description: "Detecta el MIME type real de un archivo a partir de sus primeros bytes (magic bytes) y verifica que esta en la lista de tipos permitidos. Mas seguro que confiar en el header Content-Type del request."
tags: [file, validate, mime, magic, security, upload, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [bytes]
params:
- name: header
desc: "primeros bytes del archivo (al menos 12 bytes para detectar todos los formatos soportados)"
- name: allowedTypes
desc: "lista blanca de MIME types permitidos (ej: [\"image/png\", \"image/jpeg\", \"application/pdf\"])"
output: "tupla (mime_detectado, permitido). Si no se reconoce el tipo retorna (\"\", false). Si se reconoce pero no esta en allowedTypes retorna (mime, false)"
tested: true
tests: ["detecta JPEG por magic bytes", "detecta PNG por magic bytes", "detecta PDF", "detecta WebP con prefix RIFF y suffix WEBP", "rechaza tipo no permitido", "tipo desconocido retorna vacio"]
test_file_path: "functions/infra/file_validate_type_test.go"
file_path: "functions/infra/file_validate_type.go"
---
## Ejemplo
```go
data, _ := os.ReadFile("./uploads/some.bin")
mime, ok := FileValidateType(data[:12], []string{"image/png", "image/jpeg"})
if !ok {
log.Printf("tipo no permitido: %s", mime)
}
```
## Notas
Funcion pura — sin I/O, determinista. Tabla interna de signatures soportados:
| Tipo | Magic bytes |
|------|-------------|
| JPEG | `FF D8 FF` |
| PNG | `89 50 4E 47 0D 0A 1A 0A` |
| GIF | `47 49 46 38` |
| PDF | `25 50 44 46` |
| WebP | `52 49 46 46 ?? ?? ?? ?? 57 45 42 50` |
| ZIP | `50 4B 03 04` |
NO es un antivirus. Solo verifica los primeros bytes — un archivo puede tener magic valido pero contenido malicioso despues. Para apps con requisitos de seguridad altos, complementar con escaneo adicional.
@@ -0,0 +1,72 @@
package infra
import "testing"
func TestFileValidateType(t *testing.T) {
allowed := []string{"image/png", "image/jpeg", "application/pdf", "image/webp"}
t.Run("detecta JPEG por magic bytes", func(t *testing.T) {
header := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01}
mime, ok := FileValidateType(header, allowed)
if mime != "image/jpeg" || !ok {
t.Fatalf("got (%q,%v), want (image/jpeg,true)", mime, ok)
}
})
t.Run("detecta PNG por magic bytes", func(t *testing.T) {
header := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D}
mime, ok := FileValidateType(header, allowed)
if mime != "image/png" || !ok {
t.Fatalf("got (%q,%v), want (image/png,true)", mime, ok)
}
})
t.Run("detecta PDF", func(t *testing.T) {
header := []byte{0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x37}
mime, ok := FileValidateType(header, allowed)
if mime != "application/pdf" || !ok {
t.Fatalf("got (%q,%v), want (application/pdf,true)", mime, ok)
}
})
t.Run("detecta WebP con prefix RIFF y suffix WEBP", func(t *testing.T) {
header := []byte{
0x52, 0x49, 0x46, 0x46, // RIFF
0xAA, 0xBB, 0xCC, 0xDD, // tamano (cualquier valor)
0x57, 0x45, 0x42, 0x50, // WEBP
0x56, 0x50, 0x38, 0x20,
}
mime, ok := FileValidateType(header, allowed)
if mime != "image/webp" || !ok {
t.Fatalf("got (%q,%v), want (image/webp,true)", mime, ok)
}
})
t.Run("rechaza tipo no permitido", func(t *testing.T) {
// PDF detectado, pero no en allowedTypes
header := []byte{0x25, 0x50, 0x44, 0x46, 0x2D}
mime, ok := FileValidateType(header, []string{"image/png"})
if mime != "application/pdf" {
t.Fatalf("got mime %q, want application/pdf", mime)
}
if ok {
t.Fatalf("got ok=true, want false (PDF no en allowedTypes)")
}
})
t.Run("tipo desconocido retorna vacio", func(t *testing.T) {
header := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
mime, ok := FileValidateType(header, allowed)
if mime != "" || ok {
t.Fatalf("got (%q,%v), want (\"\",false)", mime, ok)
}
})
t.Run("header demasiado corto retorna vacio", func(t *testing.T) {
header := []byte{0xFF}
mime, ok := FileValidateType(header, allowed)
if mime != "" || ok {
t.Fatalf("got (%q,%v), want (\"\",false)", mime, ok)
}
})
}
+11
View File
@@ -0,0 +1,11 @@
package infra
// S3Config configura la conexion a almacenamiento S3-compatible (AWS S3, MinIO, etc).
type S3Config struct {
Endpoint string `json:"endpoint"` // URL del servidor (ej: "s3.amazonaws.com", "minio.local:9000")
Bucket string `json:"bucket"` // nombre del bucket
AccessKey string `json:"access_key"` // access key id
SecretKey string `json:"secret_key"` // secret access key
Region string `json:"region"` // region (ej: "us-east-1")
UseSSL bool `json:"use_ssl"` // si true, usa https
}
+17
View File
@@ -0,0 +1,17 @@
package infra
import (
"fmt"
"io"
)
// S3Download descarga el objeto identificado por key desde el bucket S3-compatible
// y escribe su contenido en dst.
//
// STUB: la implementacion real requiere github.com/aws/aws-sdk-go-v2.
func S3Download(cfg S3Config, key string, dst io.Writer) error {
_ = cfg
_ = key
_ = dst
return fmt.Errorf("s3_download: not implemented (requiere github.com/aws/aws-sdk-go-v2)")
}
+81
View File
@@ -0,0 +1,81 @@
---
name: s3_download
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func S3Download(cfg S3Config, key string, dst io.Writer) error"
description: "STUB. Descarga el objeto key desde un bucket S3-compatible y escribe su contenido en dst. Permite streaming directo a disco o a un HTTP response. Requiere github.com/aws/aws-sdk-go-v2."
tags: [s3, download, storage, cloud, stub, infra]
uses_functions: []
uses_types: [S3Config_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, io]
params:
- name: cfg
desc: "S3Config con endpoint, bucket, credenciales y region"
- name: key
desc: "key del objeto a descargar (ej: \"images/foto.png\")"
- name: dst
desc: "writer donde se escribe el contenido (ej: os.File, http.ResponseWriter, bytes.Buffer)"
output: "nil si la descarga fue exitosa, error si fallo. STUB actual retorna siempre error \"not implemented\""
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/s3_download.go"
---
## Estado
**Stub**. Para activar la implementacion real:
1. Añadir las mismas dependencias que `s3_upload`.
2. Reemplazar el cuerpo del stub por:
```go
func S3Download(cfg S3Config, key string, dst io.Writer) error {
ctx := context.Background()
awsCfg, err := awscfg.LoadDefaultConfig(ctx,
awscfg.WithRegion(cfg.Region),
awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
cfg.AccessKey, cfg.SecretKey, "")),
)
if err != nil {
return fmt.Errorf("s3_download: aws config: %w", err)
}
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.UsePathStyle = true
if cfg.Endpoint != "" {
scheme := "http"
if cfg.UseSSL {
scheme = "https"
}
o.BaseEndpoint = aws.String(fmt.Sprintf("%s://%s", scheme, cfg.Endpoint))
}
})
out, err := client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(cfg.Bucket),
Key: aws.String(key),
})
if err != nil {
return fmt.Errorf("s3_download: get object: %w", err)
}
defer out.Body.Close()
_, err = io.Copy(dst, out.Body)
return err
}
```
## Ejemplo (con implementacion real)
```go
f, _ := os.Create("./local/photo.png")
defer f.Close()
err := S3Download(cfg, "images/photo.png", f)
```
## Notas
Soporta streaming — el `dst` puede ser un `os.File`, `http.ResponseWriter`, `bytes.Buffer`, o cualquier `io.Writer`. Para archivos grandes, escribir directamente al cliente HTTP evita cargarlo todo en memoria.
+17
View File
@@ -0,0 +1,17 @@
package infra
import (
"fmt"
"time"
)
// S3PresignURL genera una URL presignada para download (GET) del objeto key,
// valida durante expiry.
//
// STUB: la implementacion real requiere github.com/aws/aws-sdk-go-v2 (s3.PresignClient).
func S3PresignURL(cfg S3Config, key string, expiry time.Duration) (string, error) {
_ = cfg
_ = key
_ = expiry
return "", fmt.Errorf("s3_presign_url: not implemented (requiere github.com/aws/aws-sdk-go-v2)")
}
+80
View File
@@ -0,0 +1,80 @@
---
name: s3_presign_url
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func S3PresignURL(cfg S3Config, key string, expiry time.Duration) (string, error)"
description: "STUB. Genera una URL presignada para download (GET) del objeto key en un bucket S3-compatible, valida durante expiry. Util para descargas directas sin pasar por el servidor. Requiere github.com/aws/aws-sdk-go-v2."
tags: [s3, presign, url, storage, cloud, stub, infra]
uses_functions: []
uses_types: [S3Config_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, time]
params:
- name: cfg
desc: "S3Config con endpoint, bucket, credenciales y region"
- name: key
desc: "key del objeto en el bucket (ej: \"images/foto.png\")"
- name: expiry
desc: "duracion de validez de la URL (ej: time.Hour)"
output: "URL presignada como string. Empty + error si falla la generacion. STUB actual retorna siempre error \"not implemented\""
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/s3_presign_url.go"
---
## Estado
**Stub**. Para activar la implementacion real:
1. Añadir las mismas dependencias que `s3_upload`.
2. Reemplazar el cuerpo del stub por:
```go
func S3PresignURL(cfg S3Config, key string, expiry time.Duration) (string, error) {
ctx := context.Background()
awsCfg, err := awscfg.LoadDefaultConfig(ctx,
awscfg.WithRegion(cfg.Region),
awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
cfg.AccessKey, cfg.SecretKey, "")),
)
if err != nil {
return "", fmt.Errorf("s3_presign_url: aws config: %w", err)
}
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.UsePathStyle = true
if cfg.Endpoint != "" {
scheme := "http"
if cfg.UseSSL {
scheme = "https"
}
o.BaseEndpoint = aws.String(fmt.Sprintf("%s://%s", scheme, cfg.Endpoint))
}
})
psClient := s3.NewPresignClient(client)
req, err := psClient.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(cfg.Bucket),
Key: aws.String(key),
}, s3.WithPresignExpires(expiry))
if err != nil {
return "", fmt.Errorf("s3_presign_url: presign: %w", err)
}
return req.URL, nil
}
```
## Ejemplo (con implementacion real)
```go
url, err := S3PresignURL(cfg, "images/foto.png", time.Hour)
// url es valida 1 hora; el cliente puede descargar sin credenciales
fmt.Println(url)
```
## Notas
Para uploads directos (PUT/POST presigned), se usaria `psClient.PresignPutObject` analogamente. Las URLs presignadas heredan los permisos de las credenciales que las generaron — no incrementan privilegios.
+20
View File
@@ -0,0 +1,20 @@
package infra
import (
"fmt"
"io"
)
// S3Upload sube data a un bucket S3-compatible bajo la key dada, con el
// Content-Type indicado.
//
// STUB: la implementacion real requiere github.com/aws/aws-sdk-go-v2 (S3 client +
// credentials provider). Ver el .md para el codigo completo a habilitar cuando se
// añada la dependencia.
func S3Upload(cfg S3Config, key string, data io.Reader, contentType string) error {
_ = cfg
_ = key
_ = data
_ = contentType
return fmt.Errorf("s3_upload: not implemented (requiere github.com/aws/aws-sdk-go-v2)")
}
+102
View File
@@ -0,0 +1,102 @@
---
name: s3_upload
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func S3Upload(cfg S3Config, key string, data io.Reader, contentType string) error"
description: "STUB. Sube data a un bucket S3-compatible (AWS S3, MinIO, etc) bajo la key indicada con el Content-Type dado. La implementacion real requiere github.com/aws/aws-sdk-go-v2."
tags: [s3, upload, storage, cloud, stub, infra]
uses_functions: []
uses_types: [S3Config_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, io, time]
params:
- name: cfg
desc: "S3Config con endpoint, bucket, credenciales y region"
- name: key
desc: "key del objeto en el bucket (ej: \"images/foto.png\")"
- name: data
desc: "reader con el contenido binario a subir"
- name: contentType
desc: "MIME type del objeto (ej: \"image/png\")"
output: "nil si la subida fue exitosa, error si fallo. STUB actual retorna siempre error \"not implemented\""
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/s3_upload.go"
---
## Estado
**Stub**. Para activar la implementacion real:
1. Anadir dependencias:
```bash
go get github.com/aws/aws-sdk-go-v2/aws
go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/credentials
go get github.com/aws/aws-sdk-go-v2/service/s3
```
2. Reemplazar el cuerpo del stub por:
```go
import (
"context"
"github.com/aws/aws-sdk-go-v2/aws"
awscfg "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
func S3Upload(cfg S3Config, key string, data io.Reader, contentType string) error {
ctx := context.Background()
awsCfg, err := awscfg.LoadDefaultConfig(ctx,
awscfg.WithRegion(cfg.Region),
awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
cfg.AccessKey, cfg.SecretKey, "")),
)
if err != nil {
return fmt.Errorf("s3_upload: aws config: %w", err)
}
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.UsePathStyle = true
if cfg.Endpoint != "" {
scheme := "http"
if cfg.UseSSL {
scheme = "https"
}
o.BaseEndpoint = aws.String(fmt.Sprintf("%s://%s", scheme, cfg.Endpoint))
}
})
_, err = client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(cfg.Bucket),
Key: aws.String(key),
Body: data,
ContentType: aws.String(contentType),
})
return err
}
```
## Ejemplo (con implementacion real)
```go
cfg := S3Config{
Endpoint: "s3.amazonaws.com",
Bucket: "mi-bucket",
AccessKey: os.Getenv("S3_ACCESS_KEY"),
SecretKey: os.Getenv("S3_SECRET_KEY"),
Region: "us-east-1",
UseSSL: true,
}
f, _ := os.Open("./uploads/photo.png")
defer f.Close()
err := S3Upload(cfg, "images/photo.png", f, "image/png")
```
## Notas
Compatible con AWS S3, MinIO, Wasabi y otros S3-compatible. El campo `UsePathStyle = true` es necesario para MinIO y para algunos endpoints custom; AWS S3 nativo soporta tanto path-style como virtual-hosted-style.
+8
View File
@@ -0,0 +1,8 @@
package infra
// StorageConfig configura el almacenamiento local de archivos subidos.
type StorageConfig struct {
BaseDir string `json:"base_dir"` // directorio base para almacenar archivos
MaxFileSize int64 `json:"max_file_size"` // tamano maximo en bytes (ej: 10<<20 = 10MB)
AllowedTypes []string `json:"allowed_types"` // MIME types permitidos (ej: ["image/png", "image/jpeg", "application/pdf"])
}
+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")
}
})
}
+13
View File
@@ -0,0 +1,13 @@
package infra
import "time"
// UploadedFile contiene la metadata de un archivo subido y almacenado en disco o S3.
type UploadedFile struct {
Filename string `json:"filename"` // nombre original del archivo
StoredName string `json:"stored_name"` // nombre en disco (UUID-based)
Size int64 `json:"size"` // tamano en bytes
ContentType string `json:"content_type"` // MIME type detectado
Path string `json:"path"` // ruta completa en disco (o key S3)
CreatedAt time.Time `json:"created_at"` // timestamp de creacion
}
+9 -8
View File
@@ -3,22 +3,26 @@ module fn-registry
go 1.25.0
require (
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1
github.com/marcboeker/go-duckdb v1.8.5
github.com/mattn/go-sqlite3 v1.14.37
golang.org/x/sync v0.19.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/ClickHouse/ch-go v0.71.0 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apache/arrow-go/v18 v18.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
@@ -31,15 +35,12 @@ require (
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/flatbuffers v25.1.24+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/marcboeker/go-duckdb v1.8.5 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
@@ -49,6 +50,7 @@ require (
github.com/paulmach/orb v0.12.0 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
@@ -58,7 +60,6 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/tools v0.36.0 // indirect
+29 -7
View File
@@ -6,6 +6,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -33,6 +35,7 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
@@ -47,10 +50,14 @@ github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o=
github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -63,14 +70,20 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
@@ -83,6 +96,10 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
@@ -96,9 +113,12 @@ github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKf
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
@@ -107,15 +127,21 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
@@ -144,8 +170,6 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -156,8 +180,6 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -165,8 +187,6 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -181,12 +201,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0=
gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
BIN
View File
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
---
name: S3Config
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type S3Config struct {
Endpoint string
Bucket string
AccessKey string
SecretKey string
Region string
UseSSL bool
}
description: "Configura la conexion a un almacenamiento S3-compatible (AWS S3, MinIO, Wasabi, etc)."
tags: [s3, storage, config, cloud, infra]
uses_types: []
file_path: "functions/infra/s3_config.go"
---
## Ejemplo
```go
cfg := S3Config{
Endpoint: "s3.amazonaws.com",
Bucket: "mi-bucket",
AccessKey: os.Getenv("S3_ACCESS_KEY"),
SecretKey: os.Getenv("S3_SECRET_KEY"),
Region: "us-east-1",
UseSSL: true,
}
```
## Notas
Tipo producto. Para AWS S3 usar `Endpoint = "s3.amazonaws.com"` y la region apropiada. Para MinIO local usar `Endpoint = "minio.local:9000"` y `UseSSL = false`. Las credenciales NUNCA se hardcodean — se inyectan desde env vars o un secret manager.
+31
View File
@@ -0,0 +1,31 @@
---
name: StorageConfig
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type StorageConfig struct {
BaseDir string
MaxFileSize int64
AllowedTypes []string
}
description: "Configura el almacenamiento local de archivos: directorio base, tamano maximo y tipos MIME permitidos."
tags: [storage, config, upload, file, infra]
uses_types: []
file_path: "functions/infra/storage_config.go"
---
## Ejemplo
```go
cfg := StorageConfig{
BaseDir: "./uploads",
MaxFileSize: 10 << 20, // 10 MB
AllowedTypes: []string{"image/jpeg", "image/png", "application/pdf"},
}
```
## Notas
Tipo producto — todos los campos son obligatorios. `BaseDir` debe existir o ser creable por la app. `MaxFileSize` se aplica con `http.MaxBytesReader` durante el parse del multipart. `AllowedTypes` es una lista blanca verificada por magic bytes (no por el header Content-Type del request).
+37
View File
@@ -0,0 +1,37 @@
---
name: UploadedFile
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type UploadedFile struct {
Filename string `json:"filename"`
StoredName string `json:"stored_name"`
Size int64 `json:"size"`
ContentType string `json:"content_type"`
Path string `json:"path"`
CreatedAt time.Time `json:"created_at"`
}
description: "Metadata de un archivo subido y almacenado. Combina nombre original, nombre unico en disco, tamano, MIME type, ruta y timestamp de creacion."
tags: [upload, file, storage, http, infra]
uses_types: []
file_path: "functions/infra/uploaded_file.go"
---
## Ejemplo
```go
file := UploadedFile{
Filename: "vacaciones.png",
StoredName: "a1b2c3d4-e5f6-7890-abcd-ef1234567890.png",
Size: 102400,
ContentType: "image/png",
Path: "/var/uploads/a1b2c3d4-e5f6-7890-abcd-ef1234567890.png",
CreatedAt: time.Now(),
}
```
## Notas
Tipo producto — todos los campos siempre presentes. `Filename` se preserva por trazabilidad pero NO se usa como nombre real en disco (riesgo path traversal). `StoredName` es generado con UUID por `file_unique_name`. `Path` es la ruta completa en disco para storage local, o la key del objeto para S3.