diff --git a/functions/core/random_hex_id.go b/functions/core/random_hex_id.go new file mode 100644 index 00000000..2ee74eba --- /dev/null +++ b/functions/core/random_hex_id.go @@ -0,0 +1,21 @@ +package core + +import ( + "crypto/rand" + "encoding/hex" + "fmt" +) + +// RandomHexID generates a random hex string by reading numBytes bytes from +// crypto/rand. The returned string has length numBytes*2. +// Returns an error if numBytes <= 0 or if crypto/rand.Read fails. +func RandomHexID(numBytes int) (string, error) { + if numBytes <= 0 { + return "", fmt.Errorf("random_hex_id: numBytes must be > 0, got %d", numBytes) + } + b := make([]byte, numBytes) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("random_hex_id: failed to read from crypto/rand: %w", err) + } + return hex.EncodeToString(b), nil +} diff --git a/functions/core/random_hex_id.md b/functions/core/random_hex_id.md new file mode 100644 index 00000000..3e1533cf --- /dev/null +++ b/functions/core/random_hex_id.md @@ -0,0 +1,54 @@ +--- +name: random_hex_id +kind: function +lang: go +domain: core +version: "1.0.0" +purity: impure +signature: "func RandomHexID(numBytes int) (string, error)" +description: "Genera un string hex aleatorio leyendo numBytes bytes de crypto/rand. El resultado tiene longitud numBytes*2. Retorna error si numBytes<=0 o si falla crypto/rand." +tags: ["random", "id", "hex", "uuid-alternative"] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["crypto/rand", "encoding/hex", "fmt"] +params: + - name: numBytes + desc: "numero de bytes aleatorios a leer de crypto/rand; el string resultante mide numBytes*2 caracteres hex" +output: "string hex en minusculas con 2*numBytes caracteres, o error si numBytes<=0 o falla la lectura de crypto/rand" +tested: true +tests: + - "8 bytes retorna 16 caracteres hex validos" + - "dos llamadas devuelven valores distintos" + - "numBytes 0 retorna error" + - "numBytes negativo retorna error" +test_file_path: "functions/core/random_hex_id_test.go" +file_path: "functions/core/random_hex_id.go" +--- + +## Ejemplo + +```go +id, err := RandomHexID(8) +if err != nil { + log.Fatal(err) +} +// id = "a3f1c9b204e87d6a" (16 chars hex, aleatorio) + +// Para IDs de 32 chars (128 bits, equivalente a UUID sin guiones): +id32, _ := RandomHexID(16) +// id32 = "4e2a1b9c7d8f0e3a5c6b2d4f1a9e8c7b" +``` + +## Notas + +Funcion impura — usa `crypto/rand` (respaldado por `/dev/urandom` o equivalente del SO). No es determinista. + +Usos tipicos: +- `RandomHexID(8)` → 16 chars, 64 bits de entropia. Suficiente para IDs de rows en SQLite con tablas pequeñas (<10M filas). +- `RandomHexID(16)` → 32 chars, 128 bits. Equivalente a UUID v4 sin guiones. +- `RandomHexID(32)` → 64 chars, 256 bits. Equivalente a SHA-256 de entropia. + +Comparacion con `session_create_go_infra`: esa funcion genera tokens de 32 bytes internamente pero no expone la primitiva. Esta funcion es la primitiva componible que cualquier app puede usar directamente. diff --git a/functions/core/random_hex_id_test.go b/functions/core/random_hex_id_test.go new file mode 100644 index 00000000..7aa6e6bd --- /dev/null +++ b/functions/core/random_hex_id_test.go @@ -0,0 +1,49 @@ +package core + +import ( + "encoding/hex" + "testing" +) + +func TestRandomHexID(t *testing.T) { + t.Run("8 bytes retorna 16 caracteres hex validos", func(t *testing.T) { + got, err := RandomHexID(8) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 16 { + t.Errorf("expected length 16, got %d", len(got)) + } + if _, err := hex.DecodeString(got); err != nil { + t.Errorf("result is not valid hex: %v", err) + } + }) + + t.Run("dos llamadas devuelven valores distintos", func(t *testing.T) { + a, err := RandomHexID(8) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + b, err := RandomHexID(8) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if a == b { + t.Errorf("expected two different IDs, both are %q", a) + } + }) + + t.Run("numBytes 0 retorna error", func(t *testing.T) { + _, err := RandomHexID(0) + if err == nil { + t.Error("expected error for numBytes=0, got nil") + } + }) + + t.Run("numBytes negativo retorna error", func(t *testing.T) { + _, err := RandomHexID(-1) + if err == nil { + t.Error("expected error for numBytes=-1, got nil") + } + }) +} diff --git a/functions/infra/spa_handler.go b/functions/infra/spa_handler.go new file mode 100644 index 00000000..259d0274 --- /dev/null +++ b/functions/infra/spa_handler.go @@ -0,0 +1,49 @@ +package infra + +import ( + "io/fs" + "net/http" + "strings" +) + +// SPAHandler retorna un http.Handler que sirve los archivos estáticos de fsys +// y hace fallback a indexFile para cualquier ruta que no corresponda a un +// archivo existente — patrón SPA para React Router y similares. +// +// fsys es típicamente un iofs.Sub(embed.FS, "frontend/dist"). +// indexFile es la ruta dentro de fsys del HTML de fallback (ej. "index.html"). +func SPAHandler(fsys fs.FS, indexFile string) http.Handler { + fileServer := http.FileServer(http.FS(fsys)) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Defensa contra path traversal + if strings.Contains(r.URL.Path, "..") { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + + // Normalizar: quitar la barra inicial para fs.Stat + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "." + } + + info, err := fs.Stat(fsys, path) + if err == nil && !info.IsDir() { + // El archivo existe y no es directorio: servir normalmente + fileServer.ServeHTTP(w, r) + return + } + + // Fallback a indexFile (ruta no encontrada o directorio) + data, err := fs.ReadFile(fsys, indexFile) + if err != nil { + http.Error(w, "could not read index file", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write(data) //nolint:errcheck + }) +} diff --git a/functions/infra/spa_handler.md b/functions/infra/spa_handler.md new file mode 100644 index 00000000..cda4b82a --- /dev/null +++ b/functions/infra/spa_handler.md @@ -0,0 +1,60 @@ +--- +name: spa_handler +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: pure +signature: "func SPAHandler(fsys fs.FS, indexFile string) http.Handler" +description: "Retorna un http.Handler que sirve los archivos estaticos de un fs.FS y hace fallback a indexFile cuando el path no existe — patron SPA para React Router y similares." +tags: [http, spa, frontend, embed, fileserver] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [io/fs, net/http, strings] +params: + - name: fsys + desc: "fs.FS (tipico iofs.Sub de embed.FS) que contiene los archivos estaticos de la SPA" + - name: indexFile + desc: "ruta dentro de fsys del HTML de fallback (ej. \"index.html\")" +output: "http.Handler que sirve archivos de fsys y hace fallback a indexFile cuando el path no existe — patron SPA para React Router" +tested: true +tests: + - "sirve archivo estatico existente con status 200" + - "ruta inexistente hace fallback a index.html con Content-Type text/html" + - "raiz retorna index.html" + - "path con .. retorna 400" +test_file_path: "functions/infra/spa_handler_test.go" +file_path: "functions/infra/spa_handler.go" +--- + +## Ejemplo + +```go +//go:embed all:frontend/dist +var staticFiles embed.FS + +func main() { + sub, _ := iofs.Sub(staticFiles, "frontend/dist") + spa := SPAHandler(sub, "index.html") + + mux := http.NewServeMux() + mux.Handle("/api/", apiRouter()) + mux.Handle("/", spa) + + http.ListenAndServe(":8080", mux) +} +``` + +## Notas + +Funcion pura — construye el handler a partir de los parametros sin I/O. El handler resultante tiene los siguientes comportamientos: + +1. Si el path contiene `..`, responde 400 (defensa contra path traversal). +2. Si el path normalizado corresponde a un archivo real en `fsys` (no directorio), lo sirve con `http.FileServer`. +3. En cualquier otro caso (ruta no existe o es directorio), sirve `indexFile` con `Content-Type: text/html; charset=utf-8` y status 200. +4. Si `indexFile` no puede leerse, responde 500. + +Pensado para apps que usan `//go:embed all:frontend/dist` y React Router (o cualquier router del lado del cliente). Centraliza el patron que de otro modo cada app escribiria inline. diff --git a/functions/infra/spa_handler_test.go b/functions/infra/spa_handler_test.go new file mode 100644 index 00000000..92f86017 --- /dev/null +++ b/functions/infra/spa_handler_test.go @@ -0,0 +1,77 @@ +package infra + +import ( + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" +) + +func TestSPAHandler(t *testing.T) { + mapFS := fstest.MapFS{ + "index.html": { + Data: []byte(`SPA`), + }, + "assets/app.js": { + Data: []byte(`console.log("app")`), + }, + } + + handler := SPAHandler(mapFS, "index.html") + + t.Run("sirve archivo estatico existente con status 200", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("got status %d, want %d", rec.Code, http.StatusOK) + } + body := rec.Body.String() + if body != `console.log("app")` { + t.Errorf("unexpected body: %q", body) + } + }) + + t.Run("ruta inexistente hace fallback a index.html con Content-Type text/html", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/some/route", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("got status %d, want %d", rec.Code, http.StatusOK) + } + ct := rec.Header().Get("Content-Type") + if ct != "text/html; charset=utf-8" { + t.Errorf("got Content-Type %q, want %q", ct, "text/html; charset=utf-8") + } + body := rec.Body.String() + if body == "" { + t.Error("expected non-empty body (index.html)") + } + }) + + t.Run("raiz retorna index.html", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("got status %d, want %d", rec.Code, http.StatusOK) + } + ct := rec.Header().Get("Content-Type") + if ct != "text/html; charset=utf-8" { + t.Errorf("got Content-Type %q, want %q", ct, "text/html; charset=utf-8") + } + }) + + t.Run("path con .. retorna 400", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/foo/../bar", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest) + } + }) +}