merge: issue/0014-file-upload — file upload/storage + S3 stubs (11 fns, 3 tipos)
# Conflicts: # registry.db
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,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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user